Skill: bolander72/imessage-voice-reply (version 1.0.3)

--- name: imessage-voice-reply version: 1.0.1 description: Send voice message replies in iMessage using local Kokoro-ONNX TTS. Generates native iMessage voice bubbles (CAF/Opus) that play inline with waveform — not file attachments. Use when receiving a voice message in iMessage and wanting to reply with voice, enabling voice-to-voice iMessage conversations, or sending audio responses. Zero cost — all TTS runs locally. Requires BlueBubbles channel configured in OpenClaw. --- # iMessage Voice Reply Generate and send native iMessage voice messages using local Kokoro TTS. Voice messages appear as inline playable bubbles with waveforms — identical to voice messages recorded in Messages.app. ## How It Works ``` Your text response → Kokoro TTS (local) → afconvert (native Apple encoder) → CAF/Opus → BlueBubbles → iMessage voice bubble ``` ## Setup ```bash bash ${baseDir}/scripts/setup.sh ``` Installs: kokoro-onnx, soundfile, numpy. Downloads Kokoro models (~136MB) to `~/.cache/kokoro-onnx/`. Requires: BlueBubbles channel configured in OpenClaw (`channels.bluebubbles`). ## Generating and Sending a Voice Reply ### Step 1: Generate audio Write the response text to a temp file, then pass it via `--text-file` to avoid shell injection: ```bash echo "Your response text here" > /tmp/voice_text.txt ${baseDir}/.venv/bin/python ${baseDir}/scripts/generate_voice_reply.py --text-file /tmp/voice_text.txt --output /tmp/voice_reply.caf ``` Alternatively, pass text directly (ensure proper shell escaping): ```bash ${baseDir}/.venv/bin/python ${baseDir}/scripts/generate_voice_reply.py --text "Your response text here" --output /tmp/voice_reply.caf ``` Options: - `--voice af_heart` — Kokoro voice (default: af_heart) - `--speed 1.15` — Playback speed (default: 1.15) - `--lang en-us` — Language code (default: en-us) **Security note:** The Python script uses argparse and subprocess.run with list arguments (no shell=True). Input is handled safely within the script. When calling from a shell, prefer `--text-file` for untrusted input to avoid shell metacharacter issues. ### Step 2: Send via BlueBubbles Use the `message` tool: ```json { "action": "sendAttachment", "channel": "bluebubbles", "target": "+1XXXXXXXXXX", "path": "/tmp/voice_reply.caf", "filename": "Audio Message.caf", "contentType": "audio/x-caf", "asVoice": true } ``` **Critical parameters for native voice bubble:** - `filename` must be `"Audio Message.caf"` - `contentType` must be `"audio/x-caf"` - `asVoice` must be `true` All three are required for iMessage to render the message as an inline voice bubble with waveform instead of a file attachment. ## Voice Options | Language | Female | Male | |----------|--------|------| | English | af_heart ⭐ | am_puck | | Spanish | ef_dora | em_alex | | French | ff_siwis | — | | Japanese | jf_alpha | jm_beta | | Chinese | zf_xiaobei | zm_yunjian | ## When to Reply with Voice Reply with a voice message when: - The user sent you a voice message (voice-for-voice) - The user explicitly asks for an audio/voice response Always include a text reply alongside the voice message for accessibility. ## Audio Format - **macOS:** CAF container, Opus codec, 48kHz mono, 32kbps — encoded by Apple's native `afconvert`. Identical to what Messages.app produces. - **Fallback:** MP3 via ffmpeg (works but may not render as native voice bubble on all iMessage versions). ## Cost $0. Kokoro TTS runs entirely locally. No API calls for voice generation. ## Troubleshooting **Voice message shows as file attachment** — Ensure all three parameters are set: `filename="Audio Message.caf"`, `contentType="audio/x-caf"`, `asVoice=true`. **First word clipped** — The script prepends 150ms silence automatically. If still clipped, increase the silence pad in the script. **Kokoro model not found** — Run `bash ${baseDir}/scripts/setup.sh`. **afconvert not found** — Only available on macOS. Script falls back to ffmpeg/MP3 on Linux.

path: _meta.json
content: version: 1.0.0

path: scripts/generate_voice_reply.py
content: #!/usr/bin/env python3
"""
Generate a Kokoro TTS voice reply as a native iMessage voice message.

On macOS: uses afconvert → CAF/Opus at 48kHz mono (identical to Messages.app format).
On other platforms: uses ffmpeg → MP3 fallback.

Usage:
    generate_voice_reply.py --text "text to speak" --output /tmp/voice_reply.caf
    generate_voice_reply.py --text-file /tmp/text.txt --output /tmp/voice_reply.caf
    generate_voice_reply.py "text to speak" [output_path]  # backward compat

Stdout line 1: output file path (for piping to other tools).
Stderr: human-readable status.
"""

import sys
import os
import subprocess
import tempfile
import argparse
import shutil

import numpy as np


def encode_caf_afconvert(wav_path: str, out_path: str) -> None:
    """Encode to CAF/Opus using Apple's native afconvert (macOS only)."""
    wav48 = tempfile.mktemp(suffix="_48k.wav")
    try:
        subprocess.run(
            ["afconvert", wav_path, "-o", wav48, "-d", "LEI16", "-c", "1", "-r", "48000"],
            capture_output=True, check=True,
        )
        subprocess.run(
            ["afconvert", wav48, "-o", out_path, "-f", "caff", "-d", "opus", "-b", "32000"],
            capture_output=True, check=True,
        )
    finally:
        if os.path.exists(wav48):
            os.unlink(wav48)


def encode_mp3_ffmpeg(wav_path: str, out_path: str) -> None:
    """Encode to MP3 using ffmpeg (cross-platform fallback)."""
    subprocess.run(
        ["ffmpeg", "-y", "-i", wav_path, "-ar", "44100", "-ac", "1",
         "-c:a", "libmp3lame", "-b:a", "128k", out_path],
        capture_output=True, check=True,
    )


def main():
    parser = argparse.ArgumentParser(description="Generate Kokoro TTS iMessage voice reply")
    parser.add_argument("text_positional", nargs="?", default=None, help="Text to speak (positional, for backward compat)")
    parser.add_argument("output_positional", nargs="?", default=None, help="Output path (positional, for backward compat)")
    parser.add_argument("--text", default=None, help="Text to speak (preferred)")
    parser.add_argument("--text-file", default=None, help="Path to file containing text to speak (safest for untrusted input)")
    parser.add_argument("--output", default=None, help="Output file path (preferred)")
    parser.add_argument("--voice", default="af_heart", help="Kokoro voice (default: af_heart)")
    parser.add_argument("--speed", type=float, default=1.15, help="Speed (default: 1.15)")
    parser.add_argument("--lang", default="en-us", help="Language code (default: en-us)")
    args = parser.parse_args()

    # Resolve text: --text-file > --text > positional
    if args.text_file:
        with open(args.text_file, "r") as f:
            args.text = f.read()
    elif args.text:
        pass  # already set
    elif args.text_positional:
        args.text = args.text_positional
    else:
        print("Error: no text provided. Use --text, --text-file, or positional arg.", file=sys.stderr)
        sys.exit(1)
    args.output = args.output or args.output_positional

    if not args.text.strip():
        print("Error: empty text", file=sys.stderr)
        sys.exit(1)

    from kokoro_onnx import Kokoro
    import soundfile as sf

    model_dir = os.path.expanduser("~/.cache/kokoro-onnx")
    kokoro = Kokoro(
        os.path.join(model_dir, "kokoro-v1.0.onnx"),
        os.path.join(model_dir, "voices-v1.0.bin"),
    )
    samples, sr = kokoro.create(args.text, voice=args.voice, speed=args.speed, lang=args.lang)

    # Prepend 150ms silence — iMessage player clips the first few ms
    silence = np.zeros(int(sr * 0.15), dtype=samples.dtype)
    samples = np.concatenate([silence, samples])

    # Write intermediate WAV
    wav_path = tempfile.mktemp(suffix=".wav")
    sf.write(wav_path, samples, sr)

    # Choose encoder and output format
    has_afconvert = shutil.which("afconvert") is not None

    if has_afconvert:
        ext = ".caf"
        encode = encode_caf_afconvert
    else:
        ext = ".mp3"
        encode = encode_mp3_ffmpeg

    out_path = args.output or tempfile.mktemp(suffix=ext, prefix="voice_reply_")
    encode(wav_path, out_path)
    os.unlink(wav_path)

    duration = len(samples) / sr
    size = os.path.getsize(out_path)
    fmt = "CAF/Opus" if has_afconvert else "MP3"
    print(out_path)
    print(f"Generated {duration:.1f}s {fmt} audio ({size} bytes)", file=sys.stderr)


if __name__ == "__main__":
    main()


path: scripts/setup.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
VENV_DIR="$SKILL_DIR/.venv"
KOKORO_DIR="$HOME/.cache/kokoro-onnx"

echo "🎤 Setting up iMessage Voice Reply..."

# Create venv
if [ ! -d "$VENV_DIR" ]; then
    echo "📦 Creating Python virtual environment..."
    python3 -m venv "$VENV_DIR"
fi

echo "📦 Installing Python dependencies..."
"$VENV_DIR/bin/pip" install -q --upgrade pip
"$VENV_DIR/bin/pip" install -q kokoro-onnx soundfile numpy

# Download Kokoro models
if [ ! -f "$KOKORO_DIR/kokoro-v1.0.onnx" ] || [ ! -f "$KOKORO_DIR/voices-v1.0.bin" ]; then
    echo "🧠 Downloading Kokoro TTS models (~136MB)..."
    mkdir -p "$KOKORO_DIR"
    "$VENV_DIR/bin/python" -c "
from kokoro_onnx import Kokoro
import os
model_dir = os.path.expanduser('~/.cache/kokoro-onnx')
Kokoro(os.path.join(model_dir, 'kokoro-v1.0.onnx'), os.path.join(model_dir, 'voices-v1.0.bin'))
print('Models ready.')
"
else
    echo "✅ Kokoro models already downloaded."
fi

# Check for ffmpeg (needed as fallback on non-macOS)
if ! command -v afconvert &>/dev/null; then
    echo "⚠️  afconvert not found (non-macOS). Checking ffmpeg..."
    if ! command -v ffmpeg &>/dev/null; then
        echo "❌ ffmpeg required on non-macOS. Install: brew install ffmpeg"
        exit 1
    fi
    echo "✅ ffmpeg available (will use as fallback encoder)."
else
    echo "✅ afconvert available (native Apple audio encoder)."
fi

echo ""
echo "✅ Setup complete!"
echo "   Generate voice: $VENV_DIR/bin/python $SCRIPT_DIR/generate_voice_reply.py \"Hello world\""


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)