Add macOS LLM model engine support and environment setup

This commit is contained in:
Adolfo Reyna
2026-08-07 18:23:38 -04:00
parent 62d805e565
commit 598d55e383
7 changed files with 378 additions and 11 deletions
+8
View File
@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.pyc
scratch/
swift/speech-helper
swift/llm-helper
swift/test_foundation
swift/test_foundation.swift
+2
View File
@@ -34,6 +34,8 @@ when the hold key is working.
Useful flags: Useful flags:
```bash ```bash
./talk --llm-engine apple # macOS on-device LLM model (default)
./talk --llm-engine claude # Claude Code CLI engine
./talk --list-devices # see microphones and speakers ./talk --list-devices # see microphones and speakers
./talk --list-voices # see macOS system voices ./talk --list-voices # see macOS system voices
./talk --input-device "Adolfo i16" # pick a device by index or name substring ./talk --input-device "Adolfo i16" # pick a device by index or name substring
+229
View File
@@ -0,0 +1,229 @@
"""A Pipecat processor that puts the native macOS / Apple Silicon LLM model in the LLM slot.
Supports:
1. Apple Intelligence / FoundationModels (via swift/llm-helper)
2. MLX local on-device LLM on Apple Silicon (via mlx-lm)
Streams response text frames (LLMFullResponseStartFrame, LLMTextFrame,
LLMFullResponseEndFrame) so text-to-speech downstream responds with low latency.
"""
import asyncio
import json
import os
import shutil
import subprocess
from pathlib import Path
from loguru import logger
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InterruptionFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMTextFrame,
StartFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
_NOISE_TRANSCRIPTS = {
"",
".",
"thank you.",
"thanks for watching!",
"you",
"bye.",
"okay.",
"[blank_audio]",
"[silence]",
}
HERE = Path(__file__).parent
LLM_HELPER_PATH = HERE / "swift" / "llm-helper"
def probe_apple_llm() -> tuple[bool, str]:
"""Check if native macOS / Apple Silicon LLM options are available."""
if LLM_HELPER_PATH.exists() and os.access(LLM_HELPER_PATH, os.X_OK):
try:
res = subprocess.run(
[str(LLM_HELPER_PATH), "--check"],
capture_output=True,
text=True,
timeout=5.0,
)
if res.returncode == 0:
try:
payload = json.loads(res.stdout.strip())
if payload.get("available"):
return True, payload.get("reason", "macOS FoundationModels available")
except json.JSONDecodeError:
pass
except Exception:
pass
try:
import mlx_lm
return True, "Apple Silicon MLX local LLM engine available"
except ImportError:
return False, "Neither swift/llm-helper nor mlx-lm is available"
def _latest_user_text(context) -> str:
if not context or not hasattr(context, "messages"):
return ""
for msg in reversed(context.messages):
if isinstance(msg, dict) and msg.get("role") == "user":
content = msg.get("content", "")
if isinstance(content, str):
return content
elif isinstance(content, list):
text_parts = [c.get("text", "") for c in content if isinstance(c, dict)]
return " ".join(text_parts)
return ""
class MacOSLLM(FrameProcessor):
"""Runs user turns through macOS native LLM (Apple Intelligence or MLX)."""
def __init__(
self,
*,
model: str = "mlx-community/Qwen2.5-0.5B-Instruct-4bit",
system_prompt: str | None = None,
observer=None,
**kwargs,
):
super().__init__(**kwargs)
self._model_name = model
self._system_prompt = system_prompt or "You are a helpful voice assistant. Keep answers brief and spoken naturally."
self._on_reply = observer
self._turn_task: asyncio.Task | None = None
self._history: list[dict[str, str]] = []
self._mlx_model = None
self._mlx_tokenizer = None
self._use_swift = False
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, StartFrame):
await self.push_frame(frame, direction)
await self._connect()
elif isinstance(frame, (EndFrame, CancelFrame)):
await self._cancel_turn()
await self.push_frame(frame, direction)
elif isinstance(frame, InterruptionFrame):
await self._cancel_turn()
await self.push_frame(frame, direction)
elif isinstance(frame, LLMContextFrame):
text = _latest_user_text(frame.context)
await self._maybe_start_turn(text)
else:
await self.push_frame(frame, direction)
async def _connect(self):
available, reason = probe_apple_llm()
logger.info(f"macOS LLM engine: {reason}")
if LLM_HELPER_PATH.exists() and "FoundationModels available" in reason:
self._use_swift = True
logger.info("Using Swift FoundationModels engine.")
else:
self._use_swift = False
logger.info(f"Loading MLX model {self._model_name} on Apple Silicon...")
loop = asyncio.get_running_loop()
def _load():
from mlx_lm import load
return load(self._model_name)
self._mlx_model, self._mlx_tokenizer = await loop.run_in_executor(None, _load)
logger.info("MLX model loaded.")
async def _maybe_start_turn(self, text: str):
utterance = text.strip()
if utterance.lower() in _NOISE_TRANSCRIPTS or len(utterance) < 2:
logger.debug(f"Ignoring noise transcript: {utterance!r}")
return
await self._cancel_turn()
logger.info(f"You: {utterance}")
self._turn_task = self.create_task(self._run_turn(utterance))
async def _cancel_turn(self):
if not self._turn_task:
return
task, self._turn_task = self._turn_task, None
await self.cancel_task(task)
async def _run_turn(self, utterance: str):
self._history.append({"role": "user", "content": utterance})
await self.push_frame(LLMFullResponseStartFrame())
chunks: list[str] = []
try:
if self._use_swift:
proc = await asyncio.create_subprocess_exec(
str(LLM_HELPER_PATH),
"--prompt", utterance,
"--system", self._system_prompt,
"--stream",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
while True:
line = await proc.stdout.readline()
if not line:
break
try:
data = json.loads(line.decode().strip())
if "delta" in data:
chunk = data["delta"]
chunks.append(chunk)
await self.push_frame(LLMTextFrame(chunk))
elif "text" in data and not chunks:
chunk = data["text"]
chunks.append(chunk)
await self.push_frame(LLMTextFrame(chunk))
except json.JSONDecodeError:
pass
await proc.wait()
else:
loop = asyncio.get_running_loop()
def _gen():
from mlx_lm import generate
messages = [{"role": "system", "content": self._system_prompt}] + self._history
prompt = self._mlx_tokenizer.apply_chat_template(
messages, add_generation_prompt=True, tokenize=False
)
return generate(
self._mlx_model,
self._mlx_tokenizer,
prompt=prompt,
max_tokens=256,
verbose=False,
)
response_text = await loop.run_in_executor(None, _gen)
chunks.append(response_text)
await self.push_frame(LLMTextFrame(response_text))
except asyncio.CancelledError:
logger.info("Turn cancelled mid-response.")
raise
except Exception as e:
logger.error(f"macOS LLM error: {e}")
err_msg = "Sorry, I ran into an error generating a response."
chunks.append(err_msg)
await self.push_frame(LLMTextFrame(err_msg))
finally:
await self.push_frame(LLMFullResponseEndFrame())
full_reply = "".join(chunks).strip()
if full_reply:
self._history.append({"role": "assistant", "content": full_reply})
logger.info(f"macOS LLM: {full_reply}")
if self._on_reply:
self._on_reply(full_reply)
+24 -4
View File
@@ -151,6 +151,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--list-voices", action="store_true", help="Print macOS system voices and exit." "--list-voices", action="store_true", help="Print macOS system voices and exit."
) )
parser.add_argument(
"--llm-engine",
choices=["apple", "macos", "claude"],
default="apple",
help="LLM engine to use: apple/macos for native on-device macOS LLM, claude for Claude Code.",
)
parser.add_argument( parser.add_argument(
"--claude-model", "--claude-model",
default=DEFAULT_CLAUDE_MODEL, default=DEFAULT_CLAUDE_MODEL,
@@ -475,6 +481,23 @@ def build_claude_options(args: argparse.Namespace, vocabulary=None, brain=None)
) )
def build_llm(args: argparse.Namespace, vocabulary=None, brain=None, observer=None):
if args.llm_engine in ("apple", "macos"):
from apple_llm import MacOSLLM, probe_apple_llm
available, reason = probe_apple_llm()
logger.info(f"LLM: macOS native model ({reason})")
personality = read_personality(args.cwd) or "You are a helpful macOS voice assistant."
system_prompt = personality + "\n\n" + VOICE_STYLE
return MacOSLLM(system_prompt=system_prompt, observer=observer)
logger.info("LLM: Claude Code")
return ClaudeCodeLLM(
options=build_claude_options(args, vocabulary, brain),
observer=observer,
)
async def main() -> int: async def main() -> int:
args = parse_args() args = parse_args()
@@ -523,10 +546,7 @@ async def main() -> int:
if vocabulary: if vocabulary:
vocabulary.observe(text) vocabulary.observe(text)
llm = ClaudeCodeLLM( llm = build_llm(args, vocabulary, brain, observer=on_reply)
options=build_claude_options(args, vocabulary, brain),
observer=on_reply,
)
tts = build_tts(args) tts = build_tts(args)
+11 -3
View File
@@ -171,6 +171,13 @@ def check_mlx_whisper(synthesized):
record("mlx whisper (optional)", matches(heard), f"heard {heard!r}") record("mlx whisper (optional)", matches(heard), f"heard {heard!r}")
def check_macos_llm():
from apple_llm import probe_apple_llm
available, reason = probe_apple_llm()
record("macos llm", available, reason)
async def check_claude(): async def check_claude():
from claude_agent_sdk import ClaudeSDKClient, ResultMessage, StreamEvent from claude_agent_sdk import ClaudeSDKClient, ResultMessage, StreamEvent
@@ -199,16 +206,16 @@ async def check_claude():
if text: if text:
spoken.append(text) spoken.append(text)
elif isinstance(message, ResultMessage) and message.is_error: elif isinstance(message, ResultMessage) and message.is_error:
record("claude session", False, f"error: {message.result}") record("claude session (optional)", False, f"error: {message.result}")
return return
except Exception as e: except Exception as e:
hint = "" hint = ""
if "-9" in str(e): if "-9" in str(e):
hint = " — the CLI was killed applying its own sandbox; run this from a normal terminal" hint = " — the CLI was killed applying its own sandbox; run this from a normal terminal"
record("claude session", False, f"{e}{hint}") record("claude session (optional)", False, f"{e}{hint}")
return return
record( record(
"claude session", "claude session (optional)",
True, True,
f"replied {''.join(spoken).strip()!r} using {DEFAULT_CLAUDE_MODEL}", f"replied {''.join(spoken).strip()!r} using {DEFAULT_CLAUDE_MODEL}",
) )
@@ -223,6 +230,7 @@ async def main():
synthesized = check_kokoro() synthesized = check_kokoro()
check_apple_stt(synthesized) check_apple_stt(synthesized)
check_mlx_whisper(synthesized) check_mlx_whisper(synthesized)
check_macos_llm()
await check_claude() await check_claude()
required_failures = [ required_failures = [
+91
View File
@@ -0,0 +1,91 @@
// LLMHelper.swift - Interface with macOS native on-device LLM (FoundationModels / Apple Intelligence)
//
// Usage:
// llm-helper --check
// llm-helper --prompt "Hello" [--system "System prompt"]
import Foundation
import FoundationModels
struct Options {
var prompt: String = ""
var systemPrompt: String? = nil
var check: Bool = false
}
func parseArguments() -> Options {
var options = Options()
var arguments = Array(CommandLine.arguments.dropFirst())
while let argument = arguments.first {
arguments.removeFirst()
switch argument {
case "--prompt":
if !arguments.isEmpty { options.prompt = arguments.removeFirst() }
case "--system":
if !arguments.isEmpty { options.systemPrompt = arguments.removeFirst() }
case "--check":
options.check = true
default:
if options.prompt.isEmpty && !argument.hasPrefix("--") {
options.prompt = argument
}
}
}
return options
}
func emit(_ payload: [String: Any]) {
if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]),
let str = String(data: data, encoding: .utf8) {
print(str)
fflush(stdout)
}
}
func fail(_ message: String) -> Never {
emit(["error": message])
exit(1)
}
@main
struct LLMHelperApp {
static func main() async {
let options = parseArguments()
if options.check {
let model = SystemLanguageModel.default
let isAvailable = model.isAvailable
emit([
"available": isAvailable,
"model": "macOS SystemLanguageModel",
"reason": isAvailable ? "macOS Apple Intelligence model available" : "SystemLanguageModel unavailable on this hardware/OS configuration"
])
exit(0)
}
guard !options.prompt.isEmpty else {
fail("no prompt provided")
}
do {
let model = SystemLanguageModel.default
guard model.isAvailable else {
fail("macOS SystemLanguageModel is not available on this machine")
}
let instructions = options.systemPrompt ?? ""
let session: LanguageModelSession
if !instructions.isEmpty {
session = LanguageModelSession(model: model, instructions: instructions)
} else {
session = LanguageModelSession(model: model)
}
let response = try await session.respond(to: options.prompt)
let resultText = String(describing: response)
emit(["done": true, "text": resultText])
} catch {
fail("LanguageModel error: \(error.localizedDescription)")
}
}
}
+13 -4
View File
@@ -13,14 +13,23 @@ swiftc -O -parse-as-library SpeechHelper.swift -o speech-helper
echo "built $HERE/speech-helper" echo "built $HERE/speech-helper"
if ./speech-helper --check >/dev/null 2>&1; then if ./speech-helper --check >/dev/null 2>&1; then
echo "it runs — ./speech-helper --check for details" echo "speech-helper runs — ./speech-helper --check for details"
else else
status=$? status=$?
if [ "$status" -eq 137 ]; then if [ "$status" -eq 137 ]; then
echo "built, but killed on launch (137): request binary approval for" echo "built speech-helper, but killed on launch (137): request binary approval for"
echo " $HERE/speech-helper" echo " $HERE/speech-helper"
echo "then wait a few minutes for it to sync."
else else
echo "built, but exited $status — run ./speech-helper --check to see why" echo "built speech-helper, but exited $status — run ./speech-helper --check to see why"
fi fi
fi fi
if swiftc -O LLMHelper.swift -o llm-helper 2>/dev/null; then
echo "built $HERE/llm-helper"
if ./llm-helper --check >/dev/null 2>&1; then
echo "llm-helper runs — ./llm-helper --check for details"
fi
else
echo "could not build llm-helper with FoundationModels; fallback to Python MLX/PyObjC bridge will be available"
fi