chore: archive mac mini automation baseline
This commit is contained in:
+433
@@ -0,0 +1,433 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { writeFile, rm, mkdtemp } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function swiftSourceLLMPolish() {
|
||||
// From whisper-translation/apple_speech/Sources/AppleLLMPolish/main.swift
|
||||
// Added instant-reply mode + general conversation
|
||||
return `
|
||||
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
|
||||
}
|
||||
// Keep sessions warm — separate useCases
|
||||
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":
|
||||
// For chat, rebuild prompt from history if provided, else use text directly
|
||||
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 {
|
||||
// history is JSON array serialized
|
||||
return "Conversation history: \\(historyJSON)\\nUser says (draft/final): \\(newText)\\n\\(instructions != nil ? \"Instructions: \\(instructions!)\\n\" : \"\")Reply concisely for voice device (<40 words):"
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
class AppleLLMSession {
|
||||
constructor() {
|
||||
this.proc = null;
|
||||
this.tmpDir = null;
|
||||
this.binFile = null;
|
||||
this.ready = false;
|
||||
this.reqId = 0;
|
||||
this.pending = new Map();
|
||||
this.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
async ensureBuilt() {
|
||||
const tmpBase = path.join(os.tmpdir(), "apple-llm-");
|
||||
this.tmpDir = await mkdtemp(tmpBase);
|
||||
const swiftFile = path.join(this.tmpDir, "Main.swift");
|
||||
this.binFile = path.join(this.tmpDir, "apple-llm-polish");
|
||||
await writeFile(swiftFile, swiftSourceLLMPolish(), "utf8");
|
||||
try {
|
||||
await execFileAsync("/usr/bin/swiftc", ["-O", "-parse-as-library", swiftFile, "-o", this.binFile, "-framework", "Foundation", "-framework", "FoundationModels"], { timeout: 60000, maxBuffer: 20*1024*1024 });
|
||||
} catch (e) {
|
||||
throw new Error(`swiftc LLM build failed: ${e.stderr||e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.proc && this.proc.exitCode === null && this.ready) return;
|
||||
if (!this.binFile) await this.ensureBuilt();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.proc = spawn(this.binFile, [], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stderrBuf = "";
|
||||
let stdoutBuf = "";
|
||||
this.proc.stderr.on("data", d => { stderrBuf += d.toString(); });
|
||||
this.proc.stdout.on("data", d => {
|
||||
const txt = d.toString();
|
||||
stdoutBuf += txt;
|
||||
// Parse lines for responses
|
||||
let lines = stdoutBuf.split("\n");
|
||||
stdoutBuf = lines.pop() || "";
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
const id = obj.id;
|
||||
if (id && this.pending.has(id)) {
|
||||
const {resolve} = this.pending.get(id);
|
||||
this.pending.delete(id);
|
||||
resolve(obj);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.proc.exitCode !== null) {
|
||||
reject(new Error(`apple-llm exited early code=${this.proc.exitCode} stderr=${stderrBuf.slice(0,2000)}`));
|
||||
} else {
|
||||
this.ready = true;
|
||||
// Capture remaining stdout buffering setup
|
||||
this._stdoutLeftover = "";
|
||||
resolve();
|
||||
}
|
||||
}, 1500);
|
||||
this.proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure reader continues after initial start
|
||||
_ensureReader() {
|
||||
if (this._readerSetup) return;
|
||||
this._readerSetup = true;
|
||||
// Already setup in start() via stdout.on data - but need to handle leftover buffering for late responses
|
||||
let buf = "";
|
||||
if (this.proc) {
|
||||
// Additional listener for any missed
|
||||
this.proc.stdout.on("data", chunk => {
|
||||
buf += chunk.toString();
|
||||
let lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
const id = obj.id;
|
||||
if (id && this.pending.has(id)) {
|
||||
const {resolve} = this.pending.get(id);
|
||||
this.pending.delete(id);
|
||||
resolve(obj);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async call(args, timeoutMs = 10000) {
|
||||
await this.start();
|
||||
this._ensureReader();
|
||||
const id = String(this.reqId++);
|
||||
const payload = { id, ...args };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer = setTimeout(() => {
|
||||
if (this.pending.has(id)) {
|
||||
this.pending.delete(id);
|
||||
resolve({ ok: false, text: args.text || "", error: `timeout ${timeoutMs}ms`, id, ms: timeoutMs });
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
this.pending.set(id, {
|
||||
resolve: (obj) => {
|
||||
clearTimeout(timer);
|
||||
resolve(obj);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
this.proc.stdin.write(JSON.stringify(payload) + "\n");
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(id);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
try {
|
||||
if (this.proc) {
|
||||
try { this.proc.stdin.end(); } catch {}
|
||||
await new Promise(r => { this.proc.on("close", r); setTimeout(r, 1500); });
|
||||
try { this.proc.kill(); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
try { if (this.tmpDir) await rm(this.tmpDir, {recursive:true, force:true}); } catch {}
|
||||
this.proc = null;
|
||||
this.ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton global session for all LLM calls (fast, keeps KV cache warm)
|
||||
let globalSession = null;
|
||||
|
||||
async function getSession() {
|
||||
if (!globalSession) {
|
||||
globalSession = new AppleLLMSession();
|
||||
await globalSession.start();
|
||||
}
|
||||
globalSession.lastActivity = Date.now();
|
||||
return globalSession;
|
||||
}
|
||||
|
||||
export async function appleLLMCheck() {
|
||||
const s = await getSession();
|
||||
const res = await s.call({ mode: "check", text: "check" }, 10000);
|
||||
return {
|
||||
ok: res.ok,
|
||||
available: res.ok,
|
||||
text: res.text || res.error || "",
|
||||
ms: res.ms,
|
||||
engine: "Apple FoundationModels 3B ANE"
|
||||
};
|
||||
}
|
||||
|
||||
export async function appleLLMPolish({ text, prev1, prev2, mode } = {}) {
|
||||
if (!text) throw new Error("text required");
|
||||
const s = await getSession();
|
||||
const m = mode || "line";
|
||||
const res = await s.call({ mode: m, text, prev1: prev1||"", prev2: prev2||"" }, 8000);
|
||||
return {
|
||||
ok: res.ok,
|
||||
text: res.text || text,
|
||||
original: text,
|
||||
ms: res.ms,
|
||||
mode: m,
|
||||
error: res.error || undefined
|
||||
};
|
||||
}
|
||||
|
||||
export async function appleLLMQuickReply({ draft, context, instructions } = {}) {
|
||||
if (!draft) throw new Error("draft required");
|
||||
const s = await getSession();
|
||||
const res = await s.call({ mode: "quick_reply", text: draft, context: context||"", instructions: instructions||"" }, 3000);
|
||||
return {
|
||||
ok: res.ok,
|
||||
text: res.text || "Got it, working on it...",
|
||||
draft,
|
||||
ms: res.ms,
|
||||
engine: "Apple FoundationModels 3B instant"
|
||||
};
|
||||
}
|
||||
|
||||
export async function appleLLMChat({ text, history, instructions } = {}) {
|
||||
if (!text) throw new Error("text required");
|
||||
const s = await getSession();
|
||||
const histStr = history ? JSON.stringify(history) : "";
|
||||
const res = await s.call({ mode: "chat", text, history: histStr, instructions: instructions||"" }, 5000);
|
||||
return {
|
||||
ok: res.ok,
|
||||
text: res.text || "",
|
||||
input: text,
|
||||
ms: res.ms,
|
||||
engine: "Apple FoundationModels 3B voice"
|
||||
};
|
||||
}
|
||||
|
||||
export async function appleLLMClose() {
|
||||
if (globalSession) {
|
||||
await globalSession.close();
|
||||
globalSession = null;
|
||||
}
|
||||
return { ok: true, closed: true };
|
||||
}
|
||||
|
||||
export async function appleLLMStatus() {
|
||||
return {
|
||||
active: !!globalSession,
|
||||
ready: globalSession?.ready || false,
|
||||
lastActivity: globalSession ? new Date(globalSession.lastActivity).toISOString() : null,
|
||||
pid: globalSession?.proc?.pid || null
|
||||
};
|
||||
}
|
||||
|
||||
// Idle cleanup 2 min
|
||||
setInterval(async () => {
|
||||
if (globalSession && Date.now() - globalSession.lastActivity > 120000) {
|
||||
try { await globalSession.close(); } catch {}
|
||||
globalSession = null;
|
||||
}
|
||||
}, 20000);
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { dateFromInput, runJxa } from "../apple-events.js";
|
||||
|
||||
export const FOCUS_CALENDAR = Object.freeze({
|
||||
calendarIndex: 2,
|
||||
calendar: "Home",
|
||||
});
|
||||
|
||||
const CALENDAR_LIST_SCRIPT = String.raw`
|
||||
function run() {
|
||||
const app = Application("/System/Applications/Calendar.app");
|
||||
return JSON.stringify(app.calendars().map(function (calendar, index) {
|
||||
return {
|
||||
index: index,
|
||||
name: String(calendar.name()),
|
||||
writable: Boolean(calendar.writable())
|
||||
};
|
||||
}));
|
||||
}`;
|
||||
|
||||
const EVENTS_LIST_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Calendar.app");
|
||||
const start = new Date(input.start);
|
||||
const end = new Date(input.end);
|
||||
const calendars = app.calendars();
|
||||
const found = [];
|
||||
|
||||
for (let c = 0; c < calendars.length; c++) {
|
||||
const calendar = calendars[c];
|
||||
const calendarName = String(calendar.name());
|
||||
if (input.calendarIndex !== null && c !== input.calendarIndex) continue;
|
||||
if (input.calendar && calendarName !== input.calendar) continue;
|
||||
// Calendar's JXA bridge treats multi-property date tests inconsistently.
|
||||
// Bound one indexed property here, then enforce overlap below.
|
||||
const events = calendar.events.whose({
|
||||
startDate: {_greaterThanEquals: start, _lessThanEquals: end}
|
||||
})();
|
||||
for (let e = 0; e < events.length; e++) {
|
||||
const event = events[e];
|
||||
const eventStart = event.startDate();
|
||||
const eventEnd = event.endDate();
|
||||
if (eventEnd < start || eventStart > end) continue;
|
||||
found.push({
|
||||
id: String(event.uid()),
|
||||
calendarIndex: c,
|
||||
calendar: calendarName,
|
||||
title: String(event.summary()),
|
||||
start: eventStart.toISOString(),
|
||||
end: eventEnd.toISOString(),
|
||||
allDay: Boolean(event.alldayEvent()),
|
||||
location: String(event.location() || "")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
found.sort(function (a, b) { return a.start.localeCompare(b.start); });
|
||||
return JSON.stringify(found.slice(0, input.limit));
|
||||
}`;
|
||||
|
||||
const EVENT_CREATE_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Calendar.app");
|
||||
const calendars = app.calendars();
|
||||
let destination = null;
|
||||
let destinationIndex = null;
|
||||
for (let c = 0; c < calendars.length; c++) {
|
||||
const indexMatches = input.calendarIndex !== null && c === input.calendarIndex &&
|
||||
(!input.calendar || String(calendars[c].name()) === input.calendar);
|
||||
const nameMatches = input.calendarIndex === null && String(calendars[c].name()) === input.calendar;
|
||||
if (indexMatches || nameMatches) {
|
||||
destination = calendars[c];
|
||||
destinationIndex = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!destination) throw new Error("Calendar not found");
|
||||
if (!destination.writable()) throw new Error("Calendar is read-only");
|
||||
const event = app.Event({
|
||||
summary: input.title,
|
||||
startDate: new Date(input.start),
|
||||
endDate: new Date(input.end),
|
||||
alldayEvent: input.allDay,
|
||||
description: input.notes || "",
|
||||
location: input.location || ""
|
||||
});
|
||||
destination.events.push(event);
|
||||
return JSON.stringify({
|
||||
id: String(event.uid()),
|
||||
calendarIndex: destinationIndex,
|
||||
calendar: String(destination.name()),
|
||||
title: String(event.summary()),
|
||||
start: event.startDate().toISOString(),
|
||||
end: event.endDate().toISOString()
|
||||
});
|
||||
}`;
|
||||
|
||||
export function listCalendars() {
|
||||
return runJxa(CALENDAR_LIST_SCRIPT);
|
||||
}
|
||||
|
||||
export function listEvents({ start, end, calendar, calendarIndex, limit }) {
|
||||
dateFromInput(start, "start");
|
||||
dateFromInput(end, "end");
|
||||
if (new Date(start) > new Date(end)) {
|
||||
throw new Error("start must occur before end.");
|
||||
}
|
||||
return runJxa(EVENTS_LIST_SCRIPT, {
|
||||
start,
|
||||
end,
|
||||
calendar,
|
||||
calendarIndex: calendarIndex ?? null,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export function createEvent(input) {
|
||||
const start = dateFromInput(input.start, "start");
|
||||
const end = dateFromInput(input.end, "end");
|
||||
if (start >= end) {
|
||||
throw new Error("start must occur before end.");
|
||||
}
|
||||
return runJxa(EVENT_CREATE_SCRIPT, input);
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_OUTPUT_DIR = new URL("../../generated-images", import.meta.url).pathname;
|
||||
const DEFAULT_CODEX_PATH = "codex";
|
||||
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function getOutputDir() {
|
||||
return process.env.CODEX_IMAGE_OUTPUT_DIR || DEFAULT_OUTPUT_DIR;
|
||||
}
|
||||
|
||||
function getCodexPath() {
|
||||
return process.env.CODEX_CLI_PATH || DEFAULT_CODEX_PATH;
|
||||
}
|
||||
|
||||
function safeFilename(filename) {
|
||||
const fallback = `codex-${new Date().toISOString().replaceAll(/[:.]/g, "-")}.png`;
|
||||
const base = path.basename(filename || fallback).replaceAll(/[^a-zA-Z0-9._-]/g, "-");
|
||||
const trimmed = base.replaceAll(/-+/g, "-").replaceAll(/^\.+/g, "");
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
function parseJsonFromOutput(output) {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
const match = trimmed.match(/\{[\s\S]*\}$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(match[0]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runCodex(codexPath, args, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(codexPath, args, {
|
||||
env: process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
if (timedOut) {
|
||||
reject(new Error(`timed out after ${timeoutMs}ms. ${stderr.trim()}`));
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || stdout.trim() || `exited with ${signal || code}`));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
export function getCodexImageConfigStatus() {
|
||||
return {
|
||||
codexCliPath: getCodexPath(),
|
||||
outputDir: getOutputDir(),
|
||||
model: process.env.CODEX_IMAGE_MODEL || "(Codex CLI default)",
|
||||
timeoutMs: Number.parseInt(process.env.CODEX_IMAGE_TIMEOUT_MS || String(DEFAULT_TIMEOUT_MS), 10),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateCodexImage({
|
||||
prompt,
|
||||
filename,
|
||||
size,
|
||||
quality,
|
||||
style,
|
||||
referenceImage,
|
||||
}) {
|
||||
const outputDir = getOutputDir();
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const outputFilename = safeFilename(filename);
|
||||
const outputPath = path.join(outputDir, outputFilename);
|
||||
const codexPath = getCodexPath();
|
||||
const timeoutMs = Number.parseInt(process.env.CODEX_IMAGE_TIMEOUT_MS || String(DEFAULT_TIMEOUT_MS), 10);
|
||||
|
||||
const details = [
|
||||
size ? `Requested size/aspect: ${size}` : null,
|
||||
quality ? `Requested quality: ${quality}` : null,
|
||||
style ? `Requested style: ${style}` : null,
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
const workerPrompt = [
|
||||
"Use $imagegen to generate exactly one raster image.",
|
||||
`Save the final image file at this exact absolute path: ${outputPath}`,
|
||||
"Do not modify any other files.",
|
||||
"After saving the file, respond only with JSON matching this shape:",
|
||||
`{"ok":true,"path":"${outputPath.replaceAll("\\", "\\\\")}","note":"short description"}`,
|
||||
details ? `Generation details:\n${details}` : null,
|
||||
`Image prompt:\n${prompt}`,
|
||||
].filter(Boolean).join("\n\n");
|
||||
|
||||
const args = [
|
||||
"exec",
|
||||
"--ephemeral",
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--enable",
|
||||
"image_generation",
|
||||
"-C",
|
||||
new URL("../..", import.meta.url).pathname,
|
||||
];
|
||||
|
||||
if (process.env.CODEX_IMAGE_MODEL) {
|
||||
args.push("--model", process.env.CODEX_IMAGE_MODEL);
|
||||
}
|
||||
if (referenceImage) {
|
||||
args.push("--image", referenceImage);
|
||||
}
|
||||
|
||||
args.push(workerPrompt);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
try {
|
||||
const result = await runCodex(codexPath, args, timeoutMs);
|
||||
stdout = result.stdout;
|
||||
stderr = result.stderr;
|
||||
} catch (error) {
|
||||
throw new Error(`Codex image generation failed. ${error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await stat(outputPath);
|
||||
const parsed = parseJsonFromOutput(stdout);
|
||||
return {
|
||||
ok: true,
|
||||
path: outputPath,
|
||||
filename: outputFilename,
|
||||
bytes: file.size,
|
||||
codexCliPath: codexPath,
|
||||
note: parsed?.note || null,
|
||||
stderr: stderr.trim() || null,
|
||||
};
|
||||
} catch {
|
||||
throw new Error(`Codex completed but did not create ${outputPath}. Output: ${stdout.trim() || "(empty)"}`);
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { runJxa } from "../apple-events.js";
|
||||
|
||||
const CONTACTS_SEARCH_SCRIPT = String.raw`
|
||||
function text(value) {
|
||||
try { return value ? String(value) : ""; } catch (_) { return ""; }
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Contacts.app");
|
||||
const query = (input.query || "").toLowerCase();
|
||||
const people = app.people();
|
||||
const found = [];
|
||||
|
||||
for (let i = 0; i < people.length && found.length < input.limit; i++) {
|
||||
const person = people[i];
|
||||
const name = text(person.name());
|
||||
const organization = text(person.organization());
|
||||
if (query && (name + "\n" + organization).toLowerCase().indexOf(query) === -1) continue;
|
||||
found.push({
|
||||
id: String(person.id()),
|
||||
name: name,
|
||||
organization: organization,
|
||||
modifiedAt: person.modificationDate().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify(found);
|
||||
}`;
|
||||
|
||||
const CONTACTS_READ_SCRIPT = String.raw`
|
||||
function text(value) {
|
||||
try { return value ? String(value) : ""; } catch (_) { return ""; }
|
||||
}
|
||||
|
||||
function items(values) {
|
||||
return values.map(function (value) {
|
||||
return { label: text(value.label()), value: text(value.value()) };
|
||||
});
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Contacts.app");
|
||||
const people = app.people();
|
||||
for (let i = 0; i < people.length; i++) {
|
||||
const person = people[i];
|
||||
if (String(person.id()) !== input.id) continue;
|
||||
return JSON.stringify({
|
||||
id: String(person.id()),
|
||||
name: text(person.name()),
|
||||
firstName: text(person.firstName()),
|
||||
lastName: text(person.lastName()),
|
||||
organization: text(person.organization()),
|
||||
jobTitle: text(person.jobTitle()),
|
||||
emails: items(person.emails()),
|
||||
phones: items(person.phones()),
|
||||
modifiedAt: person.modificationDate().toISOString()
|
||||
});
|
||||
}
|
||||
throw new Error("Contact not found");
|
||||
}`;
|
||||
|
||||
const CONTACTS_CREATE_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Contacts.app");
|
||||
const person = app.Person({
|
||||
firstName: input.firstName || "",
|
||||
lastName: input.lastName || "",
|
||||
organization: input.organization || "",
|
||||
jobTitle: input.jobTitle || "",
|
||||
note: input.note || ""
|
||||
});
|
||||
|
||||
app.people.push(person);
|
||||
if (input.email) {
|
||||
person.emails.push(app.Email({label: input.email.label, value: input.email.value}));
|
||||
}
|
||||
if (input.phone) {
|
||||
person.phones.push(app.Phone({label: input.phone.label, value: input.phone.value}));
|
||||
}
|
||||
app.save();
|
||||
|
||||
return JSON.stringify({
|
||||
id: String(person.id()),
|
||||
name: String(person.name()),
|
||||
organization: input.organization || ""
|
||||
});
|
||||
}`;
|
||||
|
||||
export function searchContacts(input) {
|
||||
return runJxa(CONTACTS_SEARCH_SCRIPT, input);
|
||||
}
|
||||
|
||||
export function readContact(id) {
|
||||
return runJxa(CONTACTS_READ_SCRIPT, { id });
|
||||
}
|
||||
|
||||
export function createContact(input) {
|
||||
return runJxa(CONTACTS_CREATE_SCRIPT, input);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { loadEnvFile } from "node:process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
try {
|
||||
loadEnvFile();
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const PYTHON = new URL("../../.venv/bin/python", import.meta.url).pathname;
|
||||
const BRIDGE = new URL("../../scripts/deco_bridge.py", import.meta.url).pathname;
|
||||
const HA_CLIENTS_BRIDGE = new URL("../../scripts/deco_ha_bridge.py", import.meta.url).pathname;
|
||||
|
||||
export async function getDecoStats(action) {
|
||||
try {
|
||||
const args = action === "clients" ? [HA_CLIENTS_BRIDGE] : [BRIDGE, action];
|
||||
const { stdout } = await execFileAsync(PYTHON, args, {
|
||||
timeout: 90_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
env: process.env,
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
} catch (error) {
|
||||
const detail = error.stderr?.trim() || error.message;
|
||||
throw new Error(`Deco stats request failed. ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDecoConfigStatus() {
|
||||
return {
|
||||
host: process.env.DECO_HOST || "(default gateway)",
|
||||
username: process.env.DECO_USERNAME || "admin",
|
||||
passwordConfigured: Boolean(process.env.DECO_PASSWORD),
|
||||
passwordLength: process.env.DECO_PASSWORD?.length || 0,
|
||||
verifySsl: process.env.DECO_VERIFY_SSL ?? "true",
|
||||
timeout: process.env.DECO_TIMEOUT || "10",
|
||||
};
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_OUTPUT_DIR = new URL("../../generated-images", import.meta.url).pathname;
|
||||
const DEFAULT_PROFILE_NAME = "ReynaFamilyBot";
|
||||
|
||||
function getOutputDir() {
|
||||
return process.env.GEMINI_CHROME_IMAGE_OUTPUT_DIR || process.env.CODEX_IMAGE_OUTPUT_DIR || DEFAULT_OUTPUT_DIR;
|
||||
}
|
||||
|
||||
function getProfileName() {
|
||||
return process.env.GEMINI_CHROME_PROFILE_NAME || DEFAULT_PROFILE_NAME;
|
||||
}
|
||||
|
||||
function safeFilename(filename) {
|
||||
const fallback = `gemini-chrome-${new Date().toISOString().replaceAll(/[:.]/g, "-")}.png`;
|
||||
const base = path.basename(filename || fallback).replaceAll(/[^a-zA-Z0-9._-]/g, "-");
|
||||
const trimmed = base.replaceAll(/-+/g, "-").replaceAll(/^\.+/g, "");
|
||||
const name = trimmed || fallback;
|
||||
return name.endsWith(".png") ? name : `${name}.png`;
|
||||
}
|
||||
|
||||
function codexPrompt({ prompt, outputPath, profileName }) {
|
||||
return [
|
||||
"Use the Chrome skill, not Playwright and not MacMiniMCP browser tools.",
|
||||
"",
|
||||
"Goal: generate an image in Gemini using my Chrome profile named `" + profileName + "`, then save the downloaded image locally.",
|
||||
"",
|
||||
"Steps:",
|
||||
"1. Connect to Chrome through the Codex Chrome Extension.",
|
||||
"2. Verify the selected Chrome browser metadata has `profileName: \"" + profileName + "\"`. If not, stop and tell me.",
|
||||
"3. Open or create a Gemini tab at https://gemini.google.com/app.",
|
||||
"4. If Gemini shows the first-run notice, click `Got it`.",
|
||||
"5. Submit this image prompt:",
|
||||
"",
|
||||
prompt,
|
||||
"",
|
||||
"6. Wait until Gemini finishes and shows `Download full size image`.",
|
||||
"7. Click `Download full size image`.",
|
||||
"8. Find the newest `Gemini_Generated_Image_*.png` in `/Users/adolforeyna/Downloads`.",
|
||||
"9. Copy it to:",
|
||||
" `" + outputPath + "`",
|
||||
"10. Show me the saved path and render the image in the response.",
|
||||
"",
|
||||
"Do not expose browser control through MCP. Do not use arbitrary browsing. Only use Chrome for this Gemini image-generation task.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function getGeminiChromePromptConfigStatus() {
|
||||
return {
|
||||
outputDir: getOutputDir(),
|
||||
profileName: getProfileName(),
|
||||
note: "This MCP tool builds a Codex prompt. It does not control Chrome itself because the Chrome skill is only available inside an active Codex session.",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGeminiChromePrompt({ prompt, filename } = {}) {
|
||||
const outputFilename = safeFilename(filename);
|
||||
const outputPath = path.join(getOutputDir(), outputFilename);
|
||||
const profileName = getProfileName();
|
||||
|
||||
return {
|
||||
prompt: codexPrompt({ prompt, outputPath, profileName }),
|
||||
outputPath,
|
||||
filename: outputFilename,
|
||||
profileName,
|
||||
};
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const defaultOutputDir = path.join(projectRoot, "generated-images");
|
||||
const defaultModel = "gemini-3.1-flash-image";
|
||||
const interactionsUrl = "https://generativelanguage.googleapis.com/v1beta/interactions";
|
||||
|
||||
function outputDir() {
|
||||
return process.env.GEMINI_IMAGE_OUTPUT_DIR || defaultOutputDir;
|
||||
}
|
||||
|
||||
function apiKey() {
|
||||
return process.env.GEMINI_API_KEY || "";
|
||||
}
|
||||
|
||||
function safeFilename(name) {
|
||||
const fallback = `gemini-${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
|
||||
const base = path.basename(name || fallback).replace(/[^a-zA-Z0-9._-]/g, "-");
|
||||
if (!base) {
|
||||
return fallback;
|
||||
}
|
||||
return base.endsWith(".png") ? base : `${base}.png`;
|
||||
}
|
||||
|
||||
function buildResponseFormat({ aspectRatio, imageSize }) {
|
||||
if (!aspectRatio && !imageSize) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
mime_type: "image/png",
|
||||
...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),
|
||||
...(imageSize ? { image_size: imageSize } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getGeminiImageConfigStatus() {
|
||||
return {
|
||||
configured: Boolean(apiKey()),
|
||||
outputDir: outputDir(),
|
||||
defaultModel,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateGeminiImage({
|
||||
prompt,
|
||||
filename,
|
||||
model = defaultModel,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
useGoogleSearch = false,
|
||||
} = {}) {
|
||||
const key = apiKey();
|
||||
if (!key) {
|
||||
throw new Error("GEMINI_API_KEY is required for gemini_image_generate.");
|
||||
}
|
||||
|
||||
const responseFormat = buildResponseFormat({ aspectRatio, imageSize });
|
||||
const body = {
|
||||
model,
|
||||
input: [{ type: "text", text: prompt }],
|
||||
...(responseFormat ? { response_format: responseFormat } : {}),
|
||||
...(useGoogleSearch ? { tools: [{ type: "google_search" }] } : {}),
|
||||
};
|
||||
|
||||
const response = await fetch(interactionsUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-goog-api-key": key,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = data?.error?.message || response.statusText || "Gemini image generation failed.";
|
||||
throw new Error(`Gemini API error ${response.status}: ${message}`);
|
||||
}
|
||||
|
||||
const image = data?.output_image;
|
||||
if (!image?.data) {
|
||||
throw new Error("Gemini API did not return output_image.data.");
|
||||
}
|
||||
|
||||
const destinationDir = outputDir();
|
||||
await fs.mkdir(destinationDir, { recursive: true });
|
||||
const destination = path.join(destinationDir, safeFilename(filename));
|
||||
await fs.writeFile(destination, Buffer.from(image.data, "base64"));
|
||||
|
||||
return {
|
||||
path: destination,
|
||||
model,
|
||||
mimeType: image.mime_type || "image/png",
|
||||
interactionId: data.id,
|
||||
};
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
|
||||
const DEFAULT_KSAY_URL = process.env.KSAY_URL || "http://127.0.0.1:7332";
|
||||
|
||||
function cleanBaseUrl(url) {
|
||||
return String(url || DEFAULT_KSAY_URL).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function requestJson(path, { method = "GET", body } = {}) {
|
||||
const url = `${cleanBaseUrl()}${path}`;
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { "content-type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Kokoro ksay daemon is not reachable at ${cleanBaseUrl()}: ${error.message}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let value;
|
||||
try {
|
||||
value = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
throw new Error(`Kokoro ksay daemon returned non-JSON response: ${text.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
if (!response.ok || value.ok === false) {
|
||||
throw new Error(value.error || `Kokoro ksay daemon returned HTTP ${response.status}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSpeed(speed) {
|
||||
if (speed === undefined || speed === null) return 1.0;
|
||||
const n = Number(speed);
|
||||
if (!Number.isFinite(n) || n < 0.5 || n > 2.0) {
|
||||
throw new Error("speed must be a number between 0.5 and 2.0.");
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizeVoice(voice) {
|
||||
return String(voice || process.env.KSAY_VOICE || "af_heart").trim().slice(0, 100);
|
||||
}
|
||||
|
||||
function normalizeLangCode(langCode) {
|
||||
return String(langCode || process.env.KSAY_LANG_CODE || "a").trim().slice(0, 8);
|
||||
}
|
||||
|
||||
export async function speechKokoroStatus() {
|
||||
return requestJson("/health");
|
||||
}
|
||||
|
||||
export async function speechKokoroSynthesize({ text, voice, speed, langCode, outputPath }) {
|
||||
if (!text || !String(text).trim()) throw new Error("text required");
|
||||
const cleanText = String(text).slice(0, 8000);
|
||||
const result = await requestJson("/say", {
|
||||
method: "POST",
|
||||
body: {
|
||||
text: cleanText,
|
||||
voice: normalizeVoice(voice),
|
||||
speed: normalizeSpeed(speed),
|
||||
langCode: normalizeLangCode(langCode),
|
||||
output: outputPath || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
text: cleanText,
|
||||
format: "wav 24kHz mono",
|
||||
fileSize: await stat(result.filePath).then((s) => s.size).catch(() => 0),
|
||||
note: "Uses the warm ksay Kokoro daemon. Use speech_kokoro_synthesize_base64 when the caller needs audio bytes.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function speechKokoroSynthesizeBase64({ text, voice, speed, langCode }) {
|
||||
const result = await speechKokoroSynthesize({ text, voice, speed, langCode });
|
||||
const buf = await readFile(result.filePath);
|
||||
const wavBase64 = buf.toString("base64");
|
||||
return {
|
||||
ok: true,
|
||||
text: result.text,
|
||||
voice: result.voice,
|
||||
speed: result.speed,
|
||||
langCode: result.langCode,
|
||||
model: result.model,
|
||||
filePath: result.filePath,
|
||||
wavBase64,
|
||||
size: buf.length,
|
||||
base64Length: wavBase64.length,
|
||||
sampleRate: result.sampleRate,
|
||||
seconds: result.seconds,
|
||||
format: "wav 24kHz mono",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { runJxa } from "../apple-events.js";
|
||||
|
||||
const MAX_MESSAGES = 50;
|
||||
|
||||
const ACCOUNTS_SCRIPT = String.raw`
|
||||
function stringList(value) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
function run() {
|
||||
const app = Application("/System/Applications/Mail.app");
|
||||
return JSON.stringify(app.accounts().map(function (account) {
|
||||
let addresses = [];
|
||||
try { addresses = stringList(account.emailAddresses()); } catch (_) {}
|
||||
return { id: String(account.id()), name: String(account.name()), emailAddresses: addresses };
|
||||
}));
|
||||
}`;
|
||||
|
||||
const MAILBOXES_SCRIPT = String.raw`
|
||||
function addMailbox(found, role, mailbox) {
|
||||
try { found.push({ role: role, name: String(mailbox.name()) }); } catch (_) {}
|
||||
}
|
||||
|
||||
function run() {
|
||||
const app = Application("/System/Applications/Mail.app");
|
||||
const found = [];
|
||||
addMailbox(found, "inbox", app.inbox());
|
||||
addMailbox(found, "sent", app.sentMailbox());
|
||||
addMailbox(found, "drafts", app.draftsMailbox());
|
||||
addMailbox(found, "junk", app.junkMailbox());
|
||||
addMailbox(found, "trash", app.trashMailbox());
|
||||
return JSON.stringify(found);
|
||||
}`;
|
||||
|
||||
const LIST_MESSAGES_SCRIPT = String.raw`
|
||||
function isoDate(value) {
|
||||
try { return value ? value.toISOString() : null; } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function globalMailbox(app, requestedName) {
|
||||
const candidates = [app.inbox(), app.sentMailbox(), app.draftsMailbox(), app.junkMailbox(), app.trashMailbox()];
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
try { if (String(candidates[i].name()) === requestedName) return candidates[i]; } catch (_) {}
|
||||
}
|
||||
throw new Error("Mailbox not found; use mail_list_mailboxes first");
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Mail.app");
|
||||
const mailbox = globalMailbox(app, input.mailbox);
|
||||
const messages = mailbox.messages();
|
||||
const found = [];
|
||||
for (let i = 0; i < messages.length && found.length < input.limit; i++) {
|
||||
const message = messages[i];
|
||||
let account = null;
|
||||
try { account = message.mailbox().account(); } catch (_) { continue; }
|
||||
if (String(account.id()) !== input.accountId) continue;
|
||||
const read = Boolean(message.readStatus());
|
||||
if (input.unreadOnly && read) continue;
|
||||
found.push({
|
||||
id: String(message.id()),
|
||||
accountId: String(account.id()),
|
||||
account: String(account.name()),
|
||||
mailbox: String(mailbox.name()),
|
||||
subject: String(message.subject() || ""),
|
||||
sender: String(message.sender() || ""),
|
||||
dateSent: isoDate(message.dateSent()),
|
||||
read: read
|
||||
});
|
||||
}
|
||||
return JSON.stringify(found);
|
||||
}`;
|
||||
|
||||
const READ_MESSAGE_SCRIPT = String.raw`
|
||||
function isoDate(value) {
|
||||
try { return value ? value.toISOString() : null; } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function globalMailbox(app, requestedName) {
|
||||
const candidates = [app.inbox(), app.sentMailbox(), app.draftsMailbox(), app.junkMailbox(), app.trashMailbox()];
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
try { if (String(candidates[i].name()) === requestedName) return candidates[i]; } catch (_) {}
|
||||
}
|
||||
throw new Error("Mailbox not found; use mail_list_mailboxes first");
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Mail.app");
|
||||
const mailbox = globalMailbox(app, input.mailbox);
|
||||
let message;
|
||||
try { message = mailbox.messages.byId(Number(input.id)); } catch (_) { throw new Error("Message not found in the selected mailbox"); }
|
||||
let account;
|
||||
try { account = message.mailbox().account(); } catch (_) { throw new Error("Message not found in the selected mailbox"); }
|
||||
if (String(account.id()) !== input.accountId) throw new Error("Message does not belong to the selected account");
|
||||
return JSON.stringify({
|
||||
id: String(message.id()),
|
||||
accountId: String(account.id()),
|
||||
account: String(account.name()),
|
||||
mailbox: String(mailbox.name()),
|
||||
subject: String(message.subject() || ""),
|
||||
sender: String(message.sender() || ""),
|
||||
dateSent: isoDate(message.dateSent()),
|
||||
read: Boolean(message.readStatus()),
|
||||
body: String(message.content() || "")
|
||||
});
|
||||
}`;
|
||||
|
||||
function requireAccountId(value) {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error("accountId is required");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireMailbox(value) {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error("mailbox is required");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createMailClient(execute = runJxa) {
|
||||
return {
|
||||
accounts() {
|
||||
return execute(ACCOUNTS_SCRIPT, {});
|
||||
},
|
||||
mailboxes({ accountId }) {
|
||||
requireAccountId(accountId);
|
||||
return execute(MAILBOXES_SCRIPT, {});
|
||||
},
|
||||
listMessages({ accountId, mailbox, limit = 10, unreadOnly = false }) {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_MESSAGES) throw new Error(`limit must be between 1 and ${MAX_MESSAGES}`);
|
||||
return execute(LIST_MESSAGES_SCRIPT, { accountId: requireAccountId(accountId), mailbox: requireMailbox(mailbox), limit, unreadOnly: Boolean(unreadOnly) });
|
||||
},
|
||||
readMessage({ accountId, mailbox, id }) {
|
||||
if (typeof id !== "string" || !id.trim()) throw new Error("id is required");
|
||||
return execute(READ_MESSAGE_SCRIPT, { accountId: requireAccountId(accountId), mailbox: requireMailbox(mailbox), id });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mail = createMailClient();
|
||||
export const listMailAccounts = () => mail.accounts();
|
||||
export const listMailboxes = (input) => mail.mailboxes(input);
|
||||
export const listMailMessages = (input) => mail.listMessages(input);
|
||||
export const readMailMessage = (input) => mail.readMessage(input);
|
||||
@@ -0,0 +1,109 @@
|
||||
import { plainTextToNoteHtml, runJxa } from "../apple-events.js";
|
||||
|
||||
const NOTES_LIST_SCRIPT = String.raw`
|
||||
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 limit = input.limit;
|
||||
const found = [];
|
||||
const accounts = app.accounts();
|
||||
|
||||
for (let a = 0; a < accounts.length && found.length < limit; a++) {
|
||||
const folders = accounts[a].folders();
|
||||
for (let f = 0; f < folders.length && found.length < 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 < 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);
|
||||
}`;
|
||||
|
||||
const NOTES_READ_SCRIPT = String.raw`
|
||||
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");
|
||||
}`;
|
||||
|
||||
const NOTES_CREATE_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Notes.app");
|
||||
const accounts = app.accounts();
|
||||
let destination = null;
|
||||
|
||||
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: input.html});
|
||||
destination.notes.push(note);
|
||||
return JSON.stringify({
|
||||
id: String(note.id()),
|
||||
title: String(note.name()),
|
||||
folder: String(destination.name())
|
||||
});
|
||||
}`;
|
||||
|
||||
export function listNotes(input) {
|
||||
return runJxa(NOTES_LIST_SCRIPT, input);
|
||||
}
|
||||
|
||||
export function readNote(id) {
|
||||
return runJxa(NOTES_READ_SCRIPT, { id });
|
||||
}
|
||||
|
||||
export function createNote({ title, body, folder }) {
|
||||
return runJxa(NOTES_CREATE_SCRIPT, {
|
||||
folder,
|
||||
html: plainTextToNoteHtml(title, body),
|
||||
});
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import { dateFromInput, runJxa } from "../apple-events.js";
|
||||
|
||||
const LISTS_SCRIPT = String.raw`
|
||||
function run() {
|
||||
const app = Application("/System/Applications/Reminders.app");
|
||||
function stringValue(value) {
|
||||
try {
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function accountName(list) {
|
||||
try {
|
||||
const container = list.container();
|
||||
return stringValue(container.name ? container.name() : container);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(app.lists().map(function (list) {
|
||||
return {
|
||||
id: String(list.id()),
|
||||
name: String(list.name()),
|
||||
account: accountName(list),
|
||||
shared: null,
|
||||
assignmentMetadata: {
|
||||
available: null,
|
||||
note: "Apple Reminders automation does not expose shared-list participant metadata directly."
|
||||
}
|
||||
};
|
||||
}));
|
||||
}`;
|
||||
|
||||
const REMINDERS_LIST_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Reminders.app");
|
||||
const lists = app.lists();
|
||||
const found = [];
|
||||
function stringValue(value) {
|
||||
try {
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function compact(value) {
|
||||
if (!value) return null;
|
||||
const text = String(value).trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
function propertyValue(object, names) {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
try {
|
||||
const getter = object[names[i]];
|
||||
if (typeof getter !== "function") continue;
|
||||
const value = getter.call(object);
|
||||
const text = compact(stringValue(value));
|
||||
if (text && !text.startsWith("[object ")) return text;
|
||||
if (value && typeof value.name === "function") {
|
||||
const name = compact(stringValue(value.name()));
|
||||
if (name) return name;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function assignmentHint(title, notes) {
|
||||
const titleMatch = title.match(/\(([^()\n]{2,80})\)\s*$/);
|
||||
if (titleMatch) return { assignee: titleMatch[1].trim(), source: "title" };
|
||||
|
||||
const explicit = notes.match(/\b(?:assigned to|assignee)\s*:\s*([^.;,\n]{2,80})/i);
|
||||
if (explicit) return { assignee: explicit[1].trim(), source: "notes" };
|
||||
|
||||
const captured = notes.match(/\bCaptured\s+\d{4}-\d{2}-\d{2},\s*([^.;,\n]{2,80})\s*[.;]/i);
|
||||
if (captured) return { assignee: captured[1].trim(), source: "notes" };
|
||||
|
||||
return { assignee: null, source: null };
|
||||
}
|
||||
function assignmentFor(reminder, title, notes) {
|
||||
const nativeAssignee = propertyValue(reminder, [
|
||||
"assignedTo",
|
||||
"assignee",
|
||||
"assignment",
|
||||
"responsiblePerson",
|
||||
"principal"
|
||||
]);
|
||||
if (nativeAssignee) {
|
||||
return {
|
||||
assignee: nativeAssignee,
|
||||
source: "remindersAutomation",
|
||||
available: true
|
||||
};
|
||||
}
|
||||
|
||||
const hint = assignmentHint(title, notes);
|
||||
return {
|
||||
assignee: hint.assignee,
|
||||
source: hint.source,
|
||||
available: hint.assignee !== null
|
||||
};
|
||||
}
|
||||
for (let l = 0; l < lists.length && found.length < input.limit; l++) {
|
||||
const list = lists[l];
|
||||
const name = String(list.name());
|
||||
if (input.list && name !== input.list) continue;
|
||||
const reminders = list.reminders();
|
||||
for (let r = 0; r < reminders.length && found.length < input.limit; r++) {
|
||||
const reminder = reminders[r];
|
||||
const completed = Boolean(reminder.completed());
|
||||
if (input.completed !== null && completed !== input.completed) continue;
|
||||
let due = null;
|
||||
try {
|
||||
const value = reminder.dueDate();
|
||||
due = value ? value.toISOString() : null;
|
||||
} catch (_) {}
|
||||
const title = String(reminder.name());
|
||||
const notes = String(reminder.body() || "");
|
||||
found.push({
|
||||
id: String(reminder.id()),
|
||||
list: name,
|
||||
title: title,
|
||||
completed: completed,
|
||||
due: due,
|
||||
notes: notes,
|
||||
assignment: assignmentFor(reminder, title, notes)
|
||||
});
|
||||
}
|
||||
}
|
||||
return JSON.stringify(found);
|
||||
}`;
|
||||
|
||||
const REMINDER_CREATE_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Reminders.app");
|
||||
const lists = app.lists();
|
||||
let destination = null;
|
||||
for (let l = 0; l < lists.length; l++) {
|
||||
if (!input.list || String(lists[l].name()) === input.list) {
|
||||
destination = lists[l];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!destination) throw new Error("Reminders list not found");
|
||||
const properties = {name: input.title, body: input.notes || ""};
|
||||
if (input.due) properties.dueDate = new Date(input.due);
|
||||
const reminder = app.Reminder(properties);
|
||||
destination.reminders.push(reminder);
|
||||
return JSON.stringify({
|
||||
id: String(reminder.id()),
|
||||
list: String(destination.name()),
|
||||
title: String(reminder.name())
|
||||
});
|
||||
}`;
|
||||
|
||||
export function listReminderLists() {
|
||||
return runJxa(LISTS_SCRIPT);
|
||||
}
|
||||
|
||||
export function listReminders(input) {
|
||||
return runJxa(REMINDERS_LIST_SCRIPT, input);
|
||||
}
|
||||
|
||||
export function createReminder(input) {
|
||||
if (input.due) {
|
||||
dateFromInput(input.due, "due");
|
||||
}
|
||||
return runJxa(REMINDER_CREATE_SCRIPT, input);
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { writeFile, readFile, stat, rm, mkdir, mkdtemp } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function swiftSourcePipeTranscriber() {
|
||||
return `
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
func parseArgs() -> (String, Bool) {
|
||||
var localeId = "en-US"
|
||||
var verbose = false
|
||||
var i = 1
|
||||
let raw = CommandLine.arguments
|
||||
while i < raw.count {
|
||||
let a = raw[i]
|
||||
if a == "--locale", i+1 < raw.count { localeId = raw[i+1]; i+=1 }
|
||||
else if a == "-v" || a == "--verbose" { verbose = true }
|
||||
i+=1
|
||||
}
|
||||
return (localeId, verbose)
|
||||
}
|
||||
func logv(_ msg: String, verbose: Bool) { if verbose { fputs("[apple-pipe] \\(msg)\\n", stderr) } }
|
||||
|
||||
@main
|
||||
struct ApplePipeCLI {
|
||||
static func main() async {
|
||||
let (localeId, verbose) = parseArgs()
|
||||
guard SpeechTranscriber.isAvailable else { fputs("Not available\\n", stderr); exit(1) }
|
||||
let reqLocale = Locale(identifier: localeId)
|
||||
let locale: Locale
|
||||
if let sup = await SpeechTranscriber.supportedLocale(equivalentTo: reqLocale) { locale = sup }
|
||||
else { locale = reqLocale }
|
||||
// warm asset check
|
||||
let warm = SpeechTranscriber(locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [])
|
||||
let status = await AssetInventory.status(forModules: [warm])
|
||||
switch status {
|
||||
case .installed: logv("Assets installed", verbose: verbose)
|
||||
case .unsupported: fputs("Locale unsupported\\n", stderr); exit(2)
|
||||
case .supported:
|
||||
logv("Downloading assets...", verbose: true)
|
||||
do { if let req = try await AssetInventory.assetInstallationRequest(supporting: [warm]) { try await req.downloadAndInstall() } }
|
||||
catch { fputs("Asset download failed: \\(error)\\n", stderr); exit(3) }
|
||||
case .downloading:
|
||||
logv("Waiting assets...", verbose: true)
|
||||
for _ in 0..<30 { try? await Task.sleep(nanoseconds: 1_000_000_000); if await AssetInventory.status(forModules: [warm]) == .installed { break } }
|
||||
@unknown default: break
|
||||
}
|
||||
logv("Pipe ready locale=\\(locale.identifier)", verbose: true)
|
||||
let stdinH = FileHandle.standardInput
|
||||
var leftover = Data()
|
||||
var chunkIdx = 0
|
||||
func readExact(_ n: Int) -> Data? {
|
||||
var out = Data(); out.reserveCapacity(n)
|
||||
if leftover.count >= n { let d = leftover.prefix(n); leftover = leftover.dropFirst(n); return Data(d) }
|
||||
if leftover.count > 0 { out.append(leftover); leftover = Data() }
|
||||
while out.count < n {
|
||||
let d = stdinH.readData(ofLength: n - out.count)
|
||||
if d.isEmpty { if out.count==0 { return nil }; return nil }
|
||||
out.append(d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
while true {
|
||||
guard let lenData = readExact(4) else { break }
|
||||
let length = lenData.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
|
||||
if length == 0 { logv("Pipe EOF", verbose: true); break }
|
||||
if length > 20_000_000 { fputs("Chunk too large \\(length)\\n", stderr); break }
|
||||
guard let chunkData = readExact(Int(length)) else { fputs("Truncated expected \\(length)\\n", stderr); break }
|
||||
chunkIdx += 1
|
||||
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("apple-pipe-\\(ProcessInfo.processInfo.processIdentifier)-\\(chunkIdx).wav")
|
||||
do { try chunkData.write(to: tmpURL) } catch { fputs("Write err: \\(error)\\n", stderr); continue }
|
||||
guard let audioFile = try? AVAudioFile(forReading: tmpURL) else { try? FileManager.default.removeItem(at: tmpURL); fputs("Open fail \\(chunkIdx)\\n", stderr); continue }
|
||||
let t = SpeechTranscriber(locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [.audioTimeRange])
|
||||
guard let analyzer = try? await SpeechAnalyzer(inputAudioFile: audioFile, modules: [t], finishAfterFile: true) else { try? FileManager.default.removeItem(at: tmpURL); continue }
|
||||
do {
|
||||
for try await res in t.results {
|
||||
let txt = String(res.text.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if txt.isEmpty { continue }
|
||||
let d: [String: Any] = [
|
||||
"event": res.isFinal ? "final" : "draft",
|
||||
"text": txt, "isFinal": res.isFinal,
|
||||
"chunk": chunkIdx, "start": res.range.start.seconds,
|
||||
"duration": res.range.duration.seconds
|
||||
]
|
||||
if let jd = try? JSONSerialization.data(withJSONObject: d), let s = String(data: jd, encoding: .utf8) {
|
||||
print(s); fflush(stdout)
|
||||
}
|
||||
}
|
||||
} catch { fputs("Results err \\(chunkIdx): \\(error)\\n", stderr) }
|
||||
_ = analyzer
|
||||
try? FileManager.default.removeItem(at: tmpURL)
|
||||
}
|
||||
logv("Pipe done \\(chunkIdx)", verbose: true)
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
class ApplePipeSession {
|
||||
constructor(locale) {
|
||||
this.locale = locale;
|
||||
this.proc = null;
|
||||
this.tmpDir = null;
|
||||
this.binFile = null;
|
||||
this.ready = false;
|
||||
this.chunkIdx = 0;
|
||||
this.lastActivity = Date.now();
|
||||
this._lineCallback = null;
|
||||
}
|
||||
async ensureBuilt() {
|
||||
const tmpBase = path.join(os.tmpdir(), "speech-live-");
|
||||
this.tmpDir = await mkdtemp(tmpBase);
|
||||
const swiftFile = path.join(this.tmpDir, "Main.swift");
|
||||
this.binFile = path.join(this.tmpDir, "apple-pipe-transcribe");
|
||||
await writeFile(swiftFile, swiftSourcePipeTranscriber(), "utf8");
|
||||
try {
|
||||
await execFileAsync("/usr/bin/swiftc", ["-O", "-parse-as-library", swiftFile, "-o", this.binFile, "-framework", "AVFoundation", "-framework", "Speech"], { timeout: 60000, maxBuffer: 20*1024*1024 });
|
||||
} catch (e) {
|
||||
throw new Error(`swiftc build failed: ${e.stderr||e.message}`);
|
||||
}
|
||||
}
|
||||
async start() {
|
||||
if (this.proc && this.proc.exitCode === null && this.ready) return;
|
||||
if (!this.binFile) await this.ensureBuilt();
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = ["--locale", this.locale];
|
||||
this.proc = spawn(this.binFile, args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stderrBuf = "";
|
||||
this.proc.stderr.on("data", d => { stderrBuf += d.toString(); });
|
||||
setTimeout(() => {
|
||||
if (this.proc.exitCode !== null) {
|
||||
reject(new Error(`apple-pipe exited early code=${this.proc.exitCode} stderr=${stderrBuf.slice(0,2000)}`));
|
||||
} else {
|
||||
this.ready = true;
|
||||
this._setupReader();
|
||||
resolve();
|
||||
}
|
||||
}, 800);
|
||||
this.proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
_setupReader() {
|
||||
let buf = "";
|
||||
this.proc.stdout.on("data", chunk => {
|
||||
buf += chunk.toString("utf8");
|
||||
let lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
if (this._lineCallback) this._lineCallback(obj);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
async transcribeChunk(wavBytes, { timeoutMs = 6000 } = {}) {
|
||||
await this.start();
|
||||
this.chunkIdx++;
|
||||
const myIdx = this.chunkIdx;
|
||||
return new Promise((resolve, reject) => {
|
||||
let drafts = [];
|
||||
let finals = [];
|
||||
let timer = null;
|
||||
let done = false;
|
||||
const cleanup = () => { done = true; if (timer) clearTimeout(timer); this._lineCallback = null; };
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
cleanup();
|
||||
const fullFinal = finals.map(f=>f.text).join(" ").trim();
|
||||
const lastDraft = drafts.length ? drafts[drafts.length-1].text : "";
|
||||
const text = fullFinal || lastDraft || "";
|
||||
resolve({ text, finals, drafts, chunk: myIdx, isFinal: finals.length>0 });
|
||||
};
|
||||
this._lineCallback = (obj) => {
|
||||
if (obj.chunk !== myIdx) return;
|
||||
if (obj.isFinal || obj.event==="final") {
|
||||
finals.push(obj);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(finish, 350);
|
||||
} else {
|
||||
drafts.push(obj);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(finish, 700);
|
||||
}
|
||||
};
|
||||
timer = setTimeout(() => { if (!done) finish(); }, timeoutMs);
|
||||
try {
|
||||
const lenBuf = Buffer.alloc(4);
|
||||
lenBuf.writeUInt32BE(wavBytes.length, 0);
|
||||
this.proc.stdin.write(Buffer.concat([lenBuf, Buffer.from(wavBytes)]));
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
async close() {
|
||||
try {
|
||||
if (this.proc && this.proc.stdin.writable) {
|
||||
const eof = Buffer.alloc(4); eof.writeUInt32BE(0,0);
|
||||
this.proc.stdin.write(eof);
|
||||
this.proc.stdin.end();
|
||||
}
|
||||
if (this.proc) {
|
||||
await new Promise(r => { this.proc.on("close", r); setTimeout(r, 1500); });
|
||||
try { this.proc.kill(); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
try { if (this.tmpDir) await rm(this.tmpDir, {recursive:true, force:true}); } catch {}
|
||||
this.proc = null;
|
||||
this.ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = new Map();
|
||||
|
||||
async function getOrCreateSession(locale) {
|
||||
let s = sessions.get(locale);
|
||||
if (!s) {
|
||||
s = new ApplePipeSession(locale);
|
||||
sessions.set(locale, s);
|
||||
}
|
||||
await s.start();
|
||||
s.lastActivity = Date.now();
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function speechLiveTranscribe({ audioBase64, locale } = {}) {
|
||||
if (!audioBase64) throw new Error("audioBase64 required (WAV 16k mono base64)");
|
||||
const wavBytes = Buffer.from(audioBase64, "base64");
|
||||
const loc = locale || "en-US";
|
||||
const session = await getOrCreateSession(loc);
|
||||
const res = await session.transcribeChunk(wavBytes, { timeoutMs: 8000 });
|
||||
return {
|
||||
ok: true,
|
||||
engine: "ApplePipeTranscriber/macOS26.5 --pipe volatile drafts",
|
||||
locale: loc,
|
||||
text: res.text,
|
||||
isFinal: res.isFinal,
|
||||
drafts: res.drafts,
|
||||
finals: res.finals,
|
||||
chunk: res.chunk,
|
||||
realtime: true
|
||||
};
|
||||
}
|
||||
|
||||
export async function speechLiveClose({ locale } = {}) {
|
||||
const loc = locale || "en-US";
|
||||
const s = sessions.get(loc);
|
||||
if (s) {
|
||||
await s.close();
|
||||
sessions.delete(loc);
|
||||
}
|
||||
return { ok: true, closed: loc };
|
||||
}
|
||||
|
||||
export async function speechLiveStatus() {
|
||||
const info = [];
|
||||
for (let [loc, s] of sessions.entries()) {
|
||||
info.push({ locale: loc, ready: s.ready, chunkIdx: s.chunkIdx, lastActivity: new Date(s.lastActivity).toISOString(), pid: s.proc?.pid || null });
|
||||
}
|
||||
return { sessions: info, count: info.length };
|
||||
}
|
||||
|
||||
setInterval(async () => {
|
||||
const now = Date.now();
|
||||
for (let [loc, s] of sessions.entries()) {
|
||||
if (now - s.lastActivity > 60000) {
|
||||
try { await s.close(); } catch {}
|
||||
sessions.delete(loc);
|
||||
}
|
||||
}
|
||||
}, 15000);
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { writeFile, readFile, stat, rm, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const OUTPUT_BASE = path.join(os.homedir(), "Projects", "MacMiniMCP", "generated-transcriptions");
|
||||
|
||||
// Swift source for file transcription using the REAL macOS 26.5 API
|
||||
// From swiftinterface: SpeechAnalyzer has init(inputAudioFile:modules:) and analyzeSequence(from:)
|
||||
// No AssetInputSequenceProvider - it's direct AVAudioFile
|
||||
|
||||
function swiftSourceFileTranscriber() {
|
||||
return `import Speech
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct TranscribeCLI {
|
||||
static func main() async {
|
||||
let args = CommandLine.arguments
|
||||
let audioPath = args.count > 1 ? args[1] : ""
|
||||
let localeId = args.count > 2 ? args[2] : "en-US"
|
||||
let jsonOut = args.count > 3 ? args[3] : ""
|
||||
|
||||
if audioPath.isEmpty {
|
||||
fputs("Usage: transcriber <audioPath> [locale] [jsonOut]\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let startTime = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
guard FileManager.default.fileExists(atPath: audioPath) else {
|
||||
fputs("ERROR: File not found \\(audioPath)\\n", stderr)
|
||||
exit(3)
|
||||
}
|
||||
|
||||
let audioURL = URL(fileURLWithPath: audioPath)
|
||||
let requestedLocale = Locale(identifier: localeId)
|
||||
|
||||
let resolvedLocale: Locale
|
||||
if let l = await SpeechTranscriber.supportedLocale(equivalentTo: requestedLocale) {
|
||||
resolvedLocale = l
|
||||
} else if let fb = await SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: "en-US")) {
|
||||
resolvedLocale = fb
|
||||
} else {
|
||||
fputs("ERROR: No supported locale for \\(localeId)\\n", stderr)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
let isAvail = SpeechTranscriber.isAvailable
|
||||
fputs("Locale \\(resolvedLocale.identifier) isAvailable=\\(isAvail)\\n", stderr)
|
||||
|
||||
let transcriber = SpeechTranscriber(locale: resolvedLocale, preset: .transcription)
|
||||
|
||||
// Assets
|
||||
do {
|
||||
if let req = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
||||
fputs("Downloading assets for \\(resolvedLocale.identifier)...\\n", stderr)
|
||||
try await req.downloadAndInstall()
|
||||
fputs("Assets ready\\n", stderr)
|
||||
} else {
|
||||
fputs("No asset download needed\\n", stderr)
|
||||
}
|
||||
} catch {
|
||||
fputs("Asset note (continuing): \\(error)\\n", stderr)
|
||||
}
|
||||
|
||||
let avFile: AVAudioFile
|
||||
do {
|
||||
avFile = try AVAudioFile(forReading: audioURL)
|
||||
fputs("File: frames=\\(avFile.length) sr=\\(avFile.processingFormat.sampleRate) fmt=\\(avFile.fileFormat)\\n", stderr)
|
||||
} catch {
|
||||
fputs("ERROR opening file: \\(error)\\n", stderr)
|
||||
exit(4)
|
||||
}
|
||||
|
||||
var allSegments: [String] = []
|
||||
|
||||
do {
|
||||
// Use the new convenience: analyzer from audio file
|
||||
let analyzer = try await SpeechAnalyzer(inputAudioFile: avFile, modules: [transcriber], finishAfterFile: true)
|
||||
|
||||
// Collect results - must be concurrent
|
||||
let collector = Task {
|
||||
do {
|
||||
for try await r in transcriber.results {
|
||||
let plain = String(r.text.characters)
|
||||
if !plain.isEmpty {
|
||||
allSegments.append(plain)
|
||||
fputs("[result] \\(plain)\\n", stderr)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
fputs("Results error: \\(error)\\n", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// analysis was already started by init with finishAfterFile=true, just wait
|
||||
// Alternatively use analyzeSequence(from:) pattern:
|
||||
// But since init already starts with file, we just wait for collector
|
||||
// The analyzer will finish automatically due to finishAfterFile:true
|
||||
|
||||
// Wait for collector - it finishes when analyzer finishes file and finalizes
|
||||
await collector.value
|
||||
|
||||
let elapsed = CFAbsoluteTimeGetCurrent() - startTime
|
||||
let full = allSegments.joined(separator: " ")
|
||||
let durationSec = avFile.length > 0 ? Double(avFile.length) / avFile.processingFormat.sampleRate : 0
|
||||
let installed = await SpeechTranscriber.installedLocales.map { $0.identifier }.sorted()
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"ok": true,
|
||||
"engine": "SpeechAnalyzer+SpeechTranscriber/macOS26.5",
|
||||
"locale": resolvedLocale.identifier,
|
||||
"requestedLocale": localeId,
|
||||
"transcript": full,
|
||||
"segments": allSegments,
|
||||
"elapsedSeconds": elapsed,
|
||||
"audioPath": audioPath,
|
||||
"durationSeconds": durationSec,
|
||||
"realtimeFactor": durationSec > 0 ? elapsed / durationSec : 0,
|
||||
"rtfx": durationSec > 0 ? durationSec / elapsed : 0,
|
||||
"frames": Int(avFile.length),
|
||||
"sampleRate": avFile.processingFormat.sampleRate,
|
||||
"macOS": ProcessInfo.processInfo.operatingSystemVersionString,
|
||||
"isAvailable": isAvail,
|
||||
"installedLocales": installed
|
||||
]
|
||||
|
||||
let dataOut = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
|
||||
if !jsonOut.isEmpty {
|
||||
try dataOut.write(to: URL(fileURLWithPath: jsonOut))
|
||||
}
|
||||
FileHandle.standardOutput.write(dataOut)
|
||||
|
||||
} catch {
|
||||
fputs("Analysis failed: \\(error)\\n", stderr)
|
||||
// Dump chain
|
||||
var cur: Error? = error
|
||||
while let e = cur {
|
||||
fputs(" -> \\(e)\\n", stderr)
|
||||
cur = (e as NSError).userInfo[NSUnderlyingErrorKey] as? Error
|
||||
}
|
||||
exit(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
async function ensureOutputDir() {
|
||||
await mkdir(OUTPUT_BASE, { recursive: true });
|
||||
}
|
||||
|
||||
async function cleanup(dir) {
|
||||
try { await rm(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
async function buildAndRun({ audioPath, locale = "en-US" }) {
|
||||
const tmpDir = await (await import("node:fs/promises")).mkdtemp.call(null, path.join(os.tmpdir(), "speech-t-"));
|
||||
// compatible mkdtemp
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "speech-t-"));
|
||||
const swiftFile = path.join(dir, "Main.swift");
|
||||
const binFile = path.join(dir, "transcriber");
|
||||
const jsonOut = path.join(dir, "result.json");
|
||||
|
||||
await writeFile(swiftFile, swiftSourceFileTranscriber(), "utf8");
|
||||
|
||||
try {
|
||||
await execFileAsync("/usr/bin/swiftc", ["-O", "-parse-as-library", swiftFile, "-o", binFile, "-framework", "AVFoundation", "-framework", "Speech"], { timeout: 60_000, maxBuffer: 20*1024*1024 });
|
||||
} catch (e) {
|
||||
await cleanup(dir);
|
||||
throw new Error(`swiftc compile failed:\n${e.stderr || e.message}\n${e.stdout||""}`);
|
||||
}
|
||||
|
||||
try { await stat(binFile); } catch { await cleanup(dir); throw new Error("Binary not built"); }
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(binFile, [audioPath, locale, jsonOut], { timeout: 120_000, maxBuffer: 20*1024*1024 });
|
||||
if (stderr) { try { process.stderr.write(stderr.slice(0,4000)); } catch {} }
|
||||
let result;
|
||||
try { result = JSON.parse(await readFile(jsonOut, "utf8")); } catch { result = JSON.parse(stdout); }
|
||||
await ensureOutputDir();
|
||||
const persistPath = path.join(OUTPUT_BASE, `transcription-${Date.now()}.json`);
|
||||
try { await writeFile(persistPath, JSON.stringify(result, null, 2), "utf8"); result.persistedTo = persistPath; } catch {}
|
||||
await cleanup(dir);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const out = e.stdout || "";
|
||||
const er = e.stderr || e.message || "";
|
||||
try {
|
||||
const partial = JSON.parse(out);
|
||||
if (partial && partial.ok) { await cleanup(dir); return partial; }
|
||||
} catch {}
|
||||
await cleanup(dir);
|
||||
throw new Error(`Transcribe failed\nSTDOUT:${out.slice(0,4000)}\nSTDERR:${er.slice(0,6000)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function speechListLocales() {
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "speech-loc-"));
|
||||
const swiftFile = path.join(dir, "List.swift");
|
||||
const binFile = path.join(dir, "list");
|
||||
const swiftSrc = `import Speech
|
||||
import Foundation
|
||||
@main
|
||||
struct L {
|
||||
static func main() async {
|
||||
let isAvail = SpeechTranscriber.isAvailable
|
||||
let supported = await SpeechTranscriber.supportedLocales.map { $0.identifier }.sorted()
|
||||
let installed = await SpeechTranscriber.installedLocales.map { $0.identifier }.sorted()
|
||||
let reserved = await AssetInventory.reservedLocales.map { $0.identifier }
|
||||
let payload: [String: Any] = [
|
||||
"isAvailable": isAvail,
|
||||
"supported": supported,
|
||||
"installed": installed,
|
||||
"macOS": ProcessInfo.processInfo.operatingSystemVersionString,
|
||||
"maxReserved": AssetInventory.maximumReservedLocales,
|
||||
"reserved": reserved
|
||||
]
|
||||
let d = try! JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
|
||||
FileHandle.standardOutput.write(d)
|
||||
}
|
||||
}
|
||||
`;
|
||||
await writeFile(swiftFile, swiftSrc, "utf8");
|
||||
try {
|
||||
await execFileAsync("/usr/bin/swiftc", ["-O", "-parse-as-library", swiftFile, "-o", binFile, "-framework", "Speech"], { timeout: 30_000 });
|
||||
const { stdout } = await execFileAsync(binFile, [], { timeout: 15_000 });
|
||||
await cleanup(dir);
|
||||
return JSON.parse(stdout);
|
||||
} catch (e) {
|
||||
await cleanup(dir);
|
||||
throw new Error(`List locales failed: ${e.stderr||e.message}\n${e.stdout||""}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function speechTranscribeFile({ filePath, locale }) {
|
||||
if (!filePath) throw new Error("filePath required");
|
||||
const resolved = path.resolve(filePath.replace(/^~(?=$|\/)/, os.homedir()));
|
||||
try { await stat(resolved); } catch { throw new Error(`File not found: ${resolved}`); }
|
||||
return await buildAndRun({ audioPath: resolved, locale: locale || "en-US" });
|
||||
}
|
||||
|
||||
export async function speechQuickTest({ text, voice } = {}) {
|
||||
const testText = text || "Hello world this is a test of Apple SpeechAnalyzer on the Mac mini M four";
|
||||
const testVoice = voice || "Alex";
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "speech-qtest-"));
|
||||
const aiffPath = path.join(tmp, "test.aiff");
|
||||
try {
|
||||
await execFileAsync("/usr/bin/say", ["-v", testVoice, "-o", aiffPath, testText], { timeout: 15_000 });
|
||||
} catch {
|
||||
try { await execFileAsync("/usr/bin/say", ["-o", aiffPath, testText], { timeout: 15_000 }); }
|
||||
catch (e2) { await cleanup(tmp); throw new Error(`say failed: ${e2.stderr||e2.message}`); }
|
||||
}
|
||||
try { await stat(aiffPath); } catch { await cleanup(tmp); throw new Error("Generated audio not found"); }
|
||||
|
||||
let result;
|
||||
try { result = await buildAndRun({ audioPath: aiffPath, locale: "en-US" }); }
|
||||
catch (e) { await cleanup(tmp); throw e; }
|
||||
|
||||
result.testInputText = testText;
|
||||
result.testVoice = testVoice;
|
||||
try {
|
||||
await ensureOutputDir();
|
||||
const dest = path.join(OUTPUT_BASE, `qtest-${Date.now()}.aiff`);
|
||||
await execFileAsync("/bin/cp", [aiffPath, dest], { timeout: 5_000 });
|
||||
result.persistedAudio = dest;
|
||||
} catch {}
|
||||
await cleanup(tmp);
|
||||
return result;
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdir, stat, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const OUTPUT_BASE = path.join(os.homedir(), "Projects", "MacMiniMCP", "generated-audio");
|
||||
|
||||
async function ensureOutputDir() {
|
||||
await mkdir(OUTPUT_BASE, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeVoice(v) {
|
||||
if (!v) return null;
|
||||
return String(v).trim().slice(0, 100) || null;
|
||||
}
|
||||
|
||||
function listVoicesParse(stdout) {
|
||||
// format from `say -v ?` : "Alex en_US # Most people recognize me by my voice."
|
||||
const lines = stdout.split("\n").map(l => l.trim()).filter(Boolean);
|
||||
const voices = [];
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(\S+)\s+([a-z]{2}_[A-Z]{2}(?:_[A-Z]+)?)\s+#?\s*(.*)$/);
|
||||
if (match) {
|
||||
voices.push({ name: match[1], locale: match[2], description: match[3] || "" });
|
||||
} else {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 1 && parts[0]) {
|
||||
voices.push({ name: parts[0], locale: parts[1] || "", description: parts.slice(2).join(" ") });
|
||||
}
|
||||
}
|
||||
}
|
||||
return voices;
|
||||
}
|
||||
|
||||
export async function speechListVoices() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/say", ["-v", "?"], { timeout: 10000, maxBuffer: 10 * 1024 * 1024 });
|
||||
const voices = listVoicesParse(stdout);
|
||||
return { ok: true, count: voices.length, voices: voices.slice(0, 150) };
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to list voices: ${e.stderr || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function speechSynthesize({ text, voice, rate, outputFormat }) {
|
||||
if (!text || !String(text).trim()) throw new Error("text required");
|
||||
const cleanText = String(text).slice(0, 5000);
|
||||
const v = sanitizeVoice(voice);
|
||||
await ensureOutputDir();
|
||||
|
||||
const ts = Date.now();
|
||||
const rand = Math.random().toString(16).slice(2, 8);
|
||||
const aiffPath = path.join(OUTPUT_BASE, `tts-${ts}-${rand}.aiff`);
|
||||
const wavPath = path.join(OUTPUT_BASE, `tts-${ts}-${rand}.wav`);
|
||||
const finalWav16k = path.join(OUTPUT_BASE, `tts-${ts}-${rand}-16k.wav`);
|
||||
|
||||
const sayArgs = [];
|
||||
if (v) sayArgs.push("-v", v);
|
||||
if (rate) {
|
||||
const r = parseInt(String(rate), 10);
|
||||
if (!isNaN(r) && r >= 80 && r <= 500) {
|
||||
sayArgs.push("-r", String(r));
|
||||
}
|
||||
}
|
||||
sayArgs.push("-o", aiffPath, cleanText);
|
||||
|
||||
try {
|
||||
await execFileAsync("/usr/bin/say", sayArgs, { timeout: 30000, maxBuffer: 20 * 1024 * 1024 });
|
||||
} catch (e) {
|
||||
throw new Error(`say failed: ${e.stderr || e.message}\nOUT:${e.stdout||""}`);
|
||||
}
|
||||
|
||||
try { await stat(aiffPath); } catch { throw new Error("say output not created"); }
|
||||
|
||||
// Prefer afconvert to make 16k mono wav compatible with iPhone play_audio_base64 (expects 16k PCM s16le mono)
|
||||
// afconvert -f WAVE -d LEI16@16000 -c 1 in.aiff out.wav
|
||||
// Fallback to ffmpeg if afconvert not available is not ideal on Mac, but we try afconvert first.
|
||||
try {
|
||||
try {
|
||||
await execFileAsync("/usr/bin/afconvert", ["-f", "WAVE", "-d", "LEI16@16000", "-c", "1", aiffPath, finalWav16k], { timeout: 15000 });
|
||||
await stat(finalWav16k);
|
||||
// Also create regular wav for compatibility
|
||||
await execFileAsync("/usr/bin/afconvert", ["-f", "WAVE", "-d", "LEI16", aiffPath, wavPath], { timeout: 10000 }).catch(()=>{});
|
||||
} catch {
|
||||
// Fallback: try format without @ rate, then use afinfo, or just keep aiff path
|
||||
await execFileAsync("/usr/bin/afconvert", ["-f", "WAVE", "-d", "LEI16", "-c", "1", aiffPath, finalWav16k], { timeout: 15000 });
|
||||
}
|
||||
} catch (e) {
|
||||
// If afconvert fails, keep aiff and tell caller
|
||||
// We'll still return aiff path
|
||||
}
|
||||
|
||||
let chosenPath = finalWav16k;
|
||||
try { await stat(chosenPath); } catch {
|
||||
try { await stat(wavPath); chosenPath = wavPath; } catch { chosenPath = aiffPath; }
|
||||
}
|
||||
|
||||
let wavBase64 = null;
|
||||
let base64Len = 0;
|
||||
// For fastest iPhone playback, provide base64 of 16k wav
|
||||
try {
|
||||
// try finalWav16k first
|
||||
let b64Target = finalWav16k;
|
||||
try { await stat(b64Target); } catch { b64Target = chosenPath; }
|
||||
const buf = await readFile(b64Target);
|
||||
// If file > 2MB, we still encode but warn - iPhone can handle ~500KB typical
|
||||
if (buf.length < 4 * 1024 * 1024) {
|
||||
wavBase64 = buf.toString("base64");
|
||||
base64Len = wavBase64.length;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
text: cleanText,
|
||||
voice: v || "default",
|
||||
rate: rate || null,
|
||||
aiffPath,
|
||||
wavPath: finalWav16k,
|
||||
filePath: chosenPath,
|
||||
fallbackPath: aiffPath,
|
||||
wavBase64: wavBase64 ? wavBase64.slice(0, 50) + "...(truncated for display)" : null,
|
||||
wavBase64Full: wavBase64 ? "available" : null,
|
||||
base64Length: base64Len,
|
||||
fileSize: (await stat(chosenPath).then(s=>s.size).catch(()=>0)),
|
||||
note: "Use filePath on Mac. For iPhone play_audio_base64, use the base64 wav. Call speech_synthesize_file variant or read endpoint needs full base64."
|
||||
};
|
||||
}
|
||||
|
||||
export async function speechSynthesizeBase64({ text, voice, rate }) {
|
||||
if (!text) throw new Error("text required");
|
||||
const res = await speechSynthesize({ text, voice, rate });
|
||||
// read the 16k wav file full base64
|
||||
const target = res.wavPath || res.filePath;
|
||||
const buf = await readFile(target);
|
||||
const b64 = buf.toString("base64");
|
||||
return {
|
||||
ok: true,
|
||||
text: String(text).slice(0, 5000),
|
||||
voice: res.voice,
|
||||
filePath: target,
|
||||
wavBase64: b64,
|
||||
size: buf.length,
|
||||
base64Length: b64.length,
|
||||
format: "wav 16k mono s16le"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { execFile, execFile as execFileCb } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function getSystemInfo() {
|
||||
const results = {};
|
||||
// sw_vers for macOS version
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/sw_vers", { timeout: 5000 });
|
||||
const info = {};
|
||||
for (const line of stdout.split("\n")) {
|
||||
const [k, ...rest] = line.split(":");
|
||||
if (!k) continue;
|
||||
const key = k.trim();
|
||||
if (!key) continue;
|
||||
info[key] = rest.join(":").trim();
|
||||
}
|
||||
results.sw_vers = info;
|
||||
results.macos_version = info.ProductVersion || info.productVersion || "";
|
||||
results.build = info.BuildVersion || info.buildVersion || "";
|
||||
} catch (e) {
|
||||
results.sw_vers_error = e.message;
|
||||
}
|
||||
|
||||
// uname -a
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/uname", ["-a"], { timeout: 3000 });
|
||||
results.uname = stdout.trim();
|
||||
} catch (e) {
|
||||
results.uname_error = e.message;
|
||||
}
|
||||
|
||||
// Check for SpeechAnalyzer / SpeechTranscriber availability (macOS 26+)
|
||||
// These frameworks exist only on macOS 26+. We probe via swift/python check.
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/python3", ["-c", `
|
||||
import os, glob, sys
|
||||
# Check if Speech framework contains new symbols (macOS 26)
|
||||
frameworks = glob.glob('/System/Library/Frameworks/Speech.framework/*') + glob.glob('/System/Library/PrivateFrameworks/*Speech*')
|
||||
# Simplest: check macOS version parse
|
||||
import platform
|
||||
print(platform.mac_ver()[0])
|
||||
`], { timeout: 5000 });
|
||||
results.python_mac_ver = stdout.trim();
|
||||
} catch (e) {
|
||||
results.python_mac_ver_error = e.message;
|
||||
}
|
||||
|
||||
// Try to detect SpeechAnalyzer via file existence / Swift availability
|
||||
try {
|
||||
// On macOS 26, Speech.framework/Versions should have newer build
|
||||
const { stdout } = await execFileAsync("/bin/ls", ["-la", "/System/Library/Frameworks/Speech.framework/"], { timeout: 3000 });
|
||||
results.speech_framework_ls = stdout.trim().slice(0, 2000);
|
||||
} catch (e) {
|
||||
results.speech_framework_error = e.message;
|
||||
}
|
||||
|
||||
// Check hardware model
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/sbin/sysctl", ["-n", "hw.model"], { timeout: 2000 });
|
||||
results.hw_model = stdout.trim();
|
||||
} catch {}
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/sbin/sysctl", ["-n", "machdep.cpu.brand_string"], { timeout: 2000 });
|
||||
results.cpu_brand = stdout.trim();
|
||||
} catch {}
|
||||
|
||||
// Is this macOS 26+ ?
|
||||
const versionToCheck = results.macos_version || results.python_mac_ver || "";
|
||||
if (versionToCheck) {
|
||||
const major = parseInt(versionToCheck.split(".")[0], 10);
|
||||
results.is_macos_26_plus = major >= 26;
|
||||
results.speech_analyzer_expected = major >= 26 ? "likely available (macOS 26+)" : "not available - requires macOS 26+";
|
||||
} else if (results.python_mac_ver) {
|
||||
const major = parseInt(results.python_mac_ver.split(".")[0], 10);
|
||||
results.macos_version = results.python_mac_ver;
|
||||
results.is_macos_26_plus = major >= 26;
|
||||
results.speech_analyzer_expected = major >= 26 ? "likely available (macOS 26+)" : "not available";
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getSpeechApiStatus() {
|
||||
const sysInfo = await getSystemInfo();
|
||||
// Try to run a tiny Swift snippet that imports Speech and checks for SpeechAnalyzer
|
||||
// Fallback to reporting version info if swiftc not available
|
||||
let swiftCheck = null;
|
||||
try {
|
||||
// Write temp swift file that checks API availability
|
||||
const { stdout: swiftPath } = await execFileAsync("/usr/bin/which", ["swift"], { timeout: 2000 });
|
||||
const swiftBin = swiftPath.trim();
|
||||
if (swiftBin) {
|
||||
// Create a small swift program to test SpeechAnalyzer availability
|
||||
const swiftCode = `
|
||||
import Speech
|
||||
import Foundation
|
||||
#if canImport(Speech)
|
||||
if #available(macOS 26.0, *) {
|
||||
print("SpeechAnalyzer: available")
|
||||
// Try to reference the type
|
||||
let _ = SpeechTranscriber.self
|
||||
print("SpeechTranscriber: available")
|
||||
} else {
|
||||
print("SpeechAnalyzer: requires macOS 26")
|
||||
}
|
||||
#else
|
||||
print("Speech framework not importable")
|
||||
#endif
|
||||
`;
|
||||
const tmpFile = `/tmp/speech_check_${Date.now()}.swift`;
|
||||
const { writeFile, unlink } = await import("node:fs/promises");
|
||||
await writeFile(tmpFile, swiftCode, "utf8");
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(swiftBin, [tmpFile], { timeout: 15000, maxBuffer: 2 * 1024 * 1024 });
|
||||
swiftCheck = { stdout: stdout.trim(), stderr: stderr.trim(), ok: true };
|
||||
} catch (e) {
|
||||
swiftCheck = { stdout: e.stdout?.toString().trim() || "", stderr: (e.stderr?.toString() || e.message).trim().slice(0, 2000), ok: false };
|
||||
} finally {
|
||||
try { await unlink(tmpFile); } catch {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
swiftCheck = { error: e.message };
|
||||
}
|
||||
|
||||
return {
|
||||
system: sysInfo,
|
||||
swift_availability: swiftCheck,
|
||||
conclusion: sysInfo.is_macos_26_plus
|
||||
? "macOS 26+ detected — SpeechAnalyzer/SpeechTranscriber should be available per Apple docs."
|
||||
: `macOS ${sysInfo.macos_version || "unknown"} detected — SpeechAnalyzer requires macOS 26+. ${sysInfo.macos_version ? `You are on ${sysInfo.macos_version}, need to upgrade to 26.` : ""}`,
|
||||
};
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { mkdir, stat, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const VOICEBOX_URL = process.env.VOICEBOX_URL || "http://127.0.0.1:17493";
|
||||
const OUTPUT_BASE = path.join(os.homedir(), "Projects", "MacMiniMCP", "generated-audio");
|
||||
|
||||
const KNOWN_PROFILES = {
|
||||
Aiden: "ff624ec6-5485-4173-a4f0-2ec2196efd39",
|
||||
Adolfo: "0e042c6b-ae52-4f28-835b-528381ed60b4",
|
||||
Nicole: "52330098-6fc3-4e9c-a30c-11164869636e",
|
||||
Jessica: "579c7444-3905-4aab-8067-eb10a0b3e76f",
|
||||
Dora: "1862f224-fd47-4791-9f37-76f76ca0450c",
|
||||
Alex: "a0cf179a-c033-47e9-92b5-f61596f68adc",
|
||||
};
|
||||
|
||||
async function fetchJSON(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
const txt = await res.text();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${txt.slice(0,500)}`);
|
||||
try { return JSON.parse(txt); } catch { return txt; }
|
||||
}
|
||||
|
||||
export async function voiceboxListProfiles() {
|
||||
try {
|
||||
const profiles = await fetchJSON(`${VOICEBOX_URL}/profiles`);
|
||||
const enriched = profiles.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
engine: p.default_engine,
|
||||
voice_type: p.voice_type,
|
||||
preset_voice_id: p.preset_voice_id,
|
||||
sample_count: p.sample_count,
|
||||
generation_count: p.generation_count,
|
||||
}));
|
||||
return { ok: true, url: VOICEBOX_URL, count: enriched.length, profiles: enriched, defaultBoyVoice: "Aiden", defaultGirlVoice: "Jessica", mapping: KNOWN_PROFILES };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message, url: VOICEBOX_URL };
|
||||
}
|
||||
}
|
||||
|
||||
export async function voiceboxHealth() {
|
||||
try {
|
||||
const health = await fetchJSON(`${VOICEBOX_URL}/health`);
|
||||
return { ok: true, ...health, url: VOICEBOX_URL };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message, url: VOICEBOX_URL };
|
||||
}
|
||||
}
|
||||
|
||||
async function pollGeneration(id, timeoutMs = 20000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const gen = await fetchJSON(`${VOICEBOX_URL}/history/${id}`);
|
||||
if (gen.status === "completed" && gen.audio_path) return gen;
|
||||
if (gen.status === "failed") throw new Error(gen.error || "generation failed");
|
||||
} catch (e) {
|
||||
if (e.message && e.message.toLowerCase().includes("failed")) throw e;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error("poll timeout");
|
||||
}
|
||||
|
||||
export async function voiceboxGenerate({ text, profile, engine, language, voice }) {
|
||||
if (!text) throw new Error("text required");
|
||||
let profileId = profile || voice || "Aiden";
|
||||
if (KNOWN_PROFILES[profileId]) profileId = KNOWN_PROFILES[profileId];
|
||||
if (!profileId.includes("-")) {
|
||||
try {
|
||||
const profiles = await fetchJSON(`${VOICEBOX_URL}/profiles`);
|
||||
const match = profiles.find(p => p.name.toLowerCase() === profileId.toLowerCase());
|
||||
if (match) profileId = match.id;
|
||||
} catch {}
|
||||
}
|
||||
let autoEngine = engine;
|
||||
if (!autoEngine && ["ff624ec6-5485-4173-a4f0-2ec2196efd39", "4d0ded93-3b12-465f-aeb4-aa4360f3dc5c", "d6c2e90f-ec01-4f5e-8efc-7822bd79ac56"].includes(profileId)) {
|
||||
autoEngine = "qwen_custom_voice";
|
||||
}
|
||||
const body = {
|
||||
profile_id: profileId,
|
||||
text: String(text).slice(0, 1000),
|
||||
language: language || "en",
|
||||
engine: autoEngine || undefined,
|
||||
};
|
||||
Object.keys(body).forEach(k => body[k] === undefined && delete body[k]);
|
||||
try {
|
||||
const gen = await fetchJSON(`${VOICEBOX_URL}/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let finalGen = gen;
|
||||
if (gen.status === "generating" || !gen.audio_path) {
|
||||
try {
|
||||
finalGen = await pollGeneration(gen.id, 20000);
|
||||
} catch (pollErr) {
|
||||
return { ok: false, id: gen.id, status: gen.status, error: pollErr.message, url: VOICEBOX_URL, polling: true, profile_id: profileId, text: body.text };
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
id: finalGen.id,
|
||||
profile_id: finalGen.profile_id,
|
||||
text: finalGen.text,
|
||||
audio_path: finalGen.audio_path,
|
||||
duration: finalGen.duration,
|
||||
engine: finalGen.engine,
|
||||
status: finalGen.status,
|
||||
url: VOICEBOX_URL,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(`voicebox generate failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function voiceboxGenerateBase64({ text, profile, voice, engine, language }) {
|
||||
const gen = await voiceboxGenerate({ text, profile, voice, engine, language });
|
||||
if (!gen.ok || !gen.audio_path) return { ...gen, ok: false, error: gen.error || `no audio_path, status ${gen.status}`, fileExists: false };
|
||||
|
||||
// audio_path may be relative "generations/xxx.wav" or absolute
|
||||
let filePath = gen.audio_path;
|
||||
let candidates = [filePath];
|
||||
if (!filePath.startsWith("/") && !filePath.startsWith("~")) {
|
||||
candidates.push(path.join(os.homedir(), "Library", "Application Support", "sh.voicebox.app", filePath));
|
||||
candidates.push(path.join(os.homedir(), "Library", "Application Support", "sh.voicebox.app", "generations", path.basename(filePath)));
|
||||
}
|
||||
let foundPath = null;
|
||||
for (const cand of candidates) {
|
||||
try { await stat(cand); foundPath = cand; break; } catch {}
|
||||
}
|
||||
if (!foundPath) {
|
||||
try { await stat(filePath); foundPath = filePath; } catch {}
|
||||
}
|
||||
if (!foundPath) {
|
||||
// try glob latest file matching id
|
||||
return { ...gen, ok: false, error: `audio_path not found on disk: ${filePath}, tried ${candidates.join(",")}`, fileExists: false };
|
||||
}
|
||||
filePath = foundPath;
|
||||
try {
|
||||
const buf = await readFile(filePath);
|
||||
const b64 = buf.toString("base64");
|
||||
return {
|
||||
ok: true,
|
||||
id: gen.id,
|
||||
profile_id: gen.profile_id,
|
||||
text: gen.text,
|
||||
filePath,
|
||||
duration: gen.duration,
|
||||
engine: gen.engine,
|
||||
wavBase64: b64,
|
||||
base64Length: b64.length,
|
||||
size: buf.length,
|
||||
format: filePath.endsWith(".wav") ? "wav" : "mp3",
|
||||
url: VOICEBOX_URL,
|
||||
profile: profile || voice || "Aiden",
|
||||
};
|
||||
} catch (e) {
|
||||
return { ...gen, ok: false, error: `read failed: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function voiceboxQuickReply({ text, voice, profile }) {
|
||||
const chosenProfile = profile || voice || "Aiden";
|
||||
const result = await voiceboxGenerateBase64({ text, profile: chosenProfile, language: "en", engine: undefined });
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user