Skill: my12121-beep/voice-memo-transcribe (version 1.1.0)

--- name: voice-memo-transcribe description: > Transcribe Apple Voice Memos recordings to text, organize content, and save to Apple Notes. Workflow: iPhone Voice Memos → iCloud sync → Mac Voice Memos DB → transcribe → Apple Notes → iCloud sync back to iPhone. Requires macOS with iCloud-enabled Voice Memos. Uses faster-whisper for AI transcription with auto device detection. Triggers on: "语音备忘录", "录音转文字", "voice memo", "transcribe recording", "录音整理", "录音笔记". license: MIT-0 --- # Voice Memo Transcribe / 语音备忘录转写 Transcribe Apple Voice Memos on Mac and save organized notes to Apple Notes — syncing back to iPhone via iCloud. 将 iPhone 语音备忘录通过 iCloud 同步到 Mac 后，AI 转写为文字，整理内容，并存入 Mac 备忘录，再通过 iCloud 同步回 iPhone。 ``` iPhone 录音 → iCloud 同步 → Mac 语音备忘录 → AI 转写 → 整理 → Mac 备忘录 → iCloud → iPhone 备忘录 ``` ## Prerequisites / 前置条件 1. **macOS** with Voice Memos app / 运行语音备忘录的 Mac 2. **iCloud sync** enabled for both Voice Memos and Notes (System Settings → Apple ID → iCloud) / 已启用语音备忘录和备忘录的 iCloud 同步 3. **Full Disk Access** granted to terminal app (System Settings → Privacy & Security → Full Disk Access) / 终端已获取「完全磁盘访问权限」 4. **ffmpeg** — `brew install ffmpeg` 5. **uv** — `brew install uv` ## Database & File Paths / 数据库与文件路径 - Database / 数据库: `~/Library/Group Containers/group.com.apple.VoiceMemos.shared/Recordings/CloudRecordings.db` - Audio files / 音频文件: `~/Library/Group Containers/group.com.apple.VoiceMemos.shared/Recordings/` ## Workflow / 工作流程 ### Step 1: List Voice Memos / 列出语音备忘录 ```bash python3 scripts/list_memos.py [options] ``` | Option | Description / 说明 | Default / 默认 | |--------|-------------|---------| | `--limit N` | Number of results / 返回数量 | 20 | | `--offset N` | Pagination offset / 分页偏移 | 0 | | `--search TERM` | Search by title / 按标题搜索 | — | | `--after YYYY-MM-DD` | Filter after date / 起始日期 | — | | `--before YYYY-MM-DD` | Filter before date / 截止日期 | — | | `--json` | JSON output / JSON 格式输出 | off | Present results as numbered list: title, date, duration. Ask user which to transcribe. 以编号列表展示：标题、日期、时长。询问用户要转写哪些。 **Error "Database not found" / 报错"数据库未找到"** → Enable iCloud sync for Voice Memos, or grant Full Disk Access. / 启用语音备忘录的 iCloud 同步，或授予完全磁盘访问权限。 ### Step 2: Check Built-in Transcript / 检查内置转写 Some Voice Memos have Apple's embedded transcripts (tsrp atom): 部分语音备忘录包含 Apple 自动生成的转写文本（tsrp atom）： ```bash python3 scripts/extract_transcript.py "<PATH>.m4a" ``` If transcript found → skip to Step 4. If `"no embedded transcript"` → proceed to AI transcription. 如果找到转写文本 → 跳到第 4 步。如果显示 `"no embedded transcript"` → 进入 AI 转写。 ### Step 3: AI Transcription / AI 转写 ```bash uv run --with faster-whisper python3 scripts/transcribe.py "<AUDIO_PATH>" [options] ``` | Option | Description / 说明 | Default / 默认 | |--------|-------------|---------| | `--model` | tiny/base/small/medium/large-v3 | base | | `--language` | zh, en, ja, etc. | auto-detect / 自动检测 | | `--output PATH` | Save transcript to file / 保存到文件 | stdout / 标准输出 | | `--device` | cpu/coreml/mps | auto-detect / 自动检测 | **Model guide (15 min audio, Apple Silicon M1) / 模型参考（15 分钟音频，Apple Silicon M1）：** | Model | Time / 耗时 | RAM / 内存 | Quality / 质量 | |--------|------|-----|---------| | base | ~2 min | 1.5 GB | Good / 良好 | | small | ~5 min | 2 GB | Better / 较好 | | medium | ~10 min | 5 GB | Very good / 很好 | **Tip / 提示：** Start with `base` for preview, re-run with `small` or `medium` for final output. / 先用 `base` 快速预览，再用 `small` 或 `medium` 获取最终结果。 ### Step 4: Organize Content / 整理内容 1. Read transcript, identify topics, speakers, key moments / 阅读转写文本，识别话题、发言者、关键时刻 2. Create structured summary with timestamps / 创建带时间戳的结构化摘要 3. Ask user for context corrections if unclear (relationships, names, topic labels) / 如有疑问，向用户确认（人物关系、姓名、话题标签） 4. Misidentified speakers/names make notes worse — always confirm / 错误的发言者识别会降低笔记质量——务必确认 ### Step 5: Save to Apple Notes / 保存到备忘录 ```bash # Write content to temp file / 将内容写入临时文件 python3 -c " content = open('/dev/stdin').read() with open('/tmp/note_content.txt', 'w') as f: f.write(content) " <<< "$NOTE_BODY" # Create note in a specific folder / 在指定文件夹创建备忘录 osascript -e ' tell application "Notes" tell folder "<FOLDER_NAME>" set noteTitle to "<TITLE>" set noteBody to do shell script "cat /tmp/note_content.txt" make new note with properties {name:noteTitle, body:noteBody} end tell end tell ' ``` Omit `tell folder` block to use the default Notes folder. The note syncs to iPhone via iCloud automatically. 去掉 `tell folder` 部分可使用默认文件夹。备忘录会通过 iCloud 自动同步到 iPhone。 ## Batch Processing / 批量处理 1. List memos → user selects multiple / 列出录音 → 用户选择多条 2. Transcribe one at a time (avoids memory pressure) / 逐条转写（避免内存压力） 3. Present each for review / 逐条展示供审阅 4. Save to Notes individually or batch at end / 逐条保存或最后批量保存 ## Tips / 注意事项 - For long recordings (>30 min), warn about processing time / 长录音（>30 分钟）需提醒处理时间 - Auto device detection prefers CoreML (Apple Silicon) > MPS > CPU / 设备自动检测优先级：CoreML > MPS > CPU - If faster-whisper OOMs, fall back to smaller model / 内存不足时回退到更小的模型 - If no Apple transcript exists, suggest user open the memo on iPhone to trigger auto-transcription / 无内置转写时，建议用户在 iPhone 上打开录音触发自动转写 - Clean up `/tmp/note_content.txt` after use / 使用后清理临时文件

path: scripts/extract_transcript.py
content: #!/usr/bin/env python3
"""Extract embedded transcript from Apple Voice Memo m4a file.

Apple Voice Memos may contain auto-generated transcripts stored as
tsrp atoms inside the m4a container. This script extracts them.
"""
import struct
import sys
import os


def find_atom(data, atom_type, offset=0, end=None):
    """Find an atom by type in m4a/MPEG-4 container."""
    if end is None:
        end = len(data)
    while offset < end - 8:
        size = struct.unpack(">I", data[offset:offset + 4])[0]
        if size == 1 and offset + 16 <= end:
            size = struct.unpack(">Q", data[offset + 8:offset + 16])[0]
        elif size == 0:
            size = end - offset
        if size < 8 or offset + size > end:
            break
        atype = data[offset + 4:offset + 8]
        if atype == atom_type:
            return offset, size
        offset += size
    return None, None


def extract_transcript(filepath):
    """Extract tsrp transcript from m4a."""
    if not os.path.exists(filepath):
        print(f"ERROR: File not found: {filepath}")
        return 1

    with open(filepath, "rb") as f:
        data = f.read()

    # Scan all top-level atoms to find moov
    # Handle both 32-bit and 64-bit (extended size) atoms
    offset = 0
    moov_off, moov_size = None, None
    while offset < len(data) - 8:
        size = struct.unpack(">I", data[offset:offset + 4])[0]
        atype = data[offset + 4:offset + 8]
        if size == 1 and offset + 16 <= len(data):
            # 64-bit extended size
            size = struct.unpack(">Q", data[offset + 8:offset + 16])[0]
        elif size == 0:
            # Atom extends to end of file
            size = len(data) - offset
        if size < 8:
            break
        if atype == b"moov":
            moov_off, moov_size = offset, size
            break
        offset += size
    
    if moov_off is None:
        print("ERROR: moov atom not found — not a valid m4a file")
        return 1

    moov_end = moov_off + moov_size

    # Find udta atom inside moov
    udta_off, udta_size = find_atom(data, b"udta", moov_off + 8, moov_end)
    if udta_off is None:
        print("NO_TRANSCRIPT: no embedded transcript found")
        return 0

    udta_end = udta_off + udta_size

    # Find tsrp atom inside udta
    tsrp_off, tsrp_size = find_atom(data, b"tsrp", udta_off + 8, udta_end)
    if tsrp_off is None:
        print("NO_TRANSCRIPT: no embedded transcript found")
        return 0

    # Extract transcript — skip 8 byte atom header + 4 byte version/flags
    header_size = 12
    tsrp_data = data[tsrp_off + header_size:tsrp_off + tsrp_size]

    if len(tsrp_data) == 0:
        print("NO_TRANSCRIPT: transcript atom is empty")
        return 0

    # Try decoding
    for encoding in ["utf-8", "utf-16", "utf-16-le", "utf-16-be"]:
        try:
            text = tsrp_data.decode(encoding)
            # Strip null characters and whitespace
            text = text.replace("\x00", "").strip()
            if text:
                print(text)
                return 0
        except (UnicodeDecodeError, ValueError):
            continue

    print("ERROR: Cannot decode transcript")
    return 1


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python3 extract_transcript.py <m4a_file>")
        sys.exit(1)
    sys.exit(extract_transcript(sys.argv[1]))


path: scripts/list_memos.py
content: #!/usr/bin/env python3
"""List Apple Voice Memos from CloudRecordings.db."""
import sqlite3
import argparse
import os
from datetime import datetime, timedelta

# Apple Core Data epoch: 2001-01-01 00:00:00 UTC
APPLE_EPOCH = datetime(2001, 1, 1)

DB_PATH = os.path.expanduser(
    "~/Library/Group Containers/group.com.apple.VoiceMemos.shared/Recordings/CloudRecordings.db"
)


def format_duration(seconds):
    if seconds is None:
        return "0:00"
    m, s = divmod(int(seconds), 60)
    h, m = divmod(m, 60)
    if h > 0:
        return f"{h}:{m:02d}:{s:02d}"
    return f"{m}:{s:02d}"


def apple_to_datetime(apple_ts):
    """Convert Apple Core Data timestamp to datetime."""
    if apple_ts is None:
        return None
    return APPLE_EPOCH + timedelta(seconds=apple_ts)


def main():
    parser = argparse.ArgumentParser(description="List Apple Voice Memos")
    parser.add_argument("--limit", type=int, default=20)
    parser.add_argument("--offset", type=int, default=0)
    parser.add_argument("--search", type=str, default=None)
    parser.add_argument("--after", type=str, default=None, help="YYYY-MM-DD")
    parser.add_argument("--before", type=str, default=None, help="YYYY-MM-DD")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    args = parser.parse_args()

    if not os.path.exists(DB_PATH):
        print("ERROR: Database not found.")
        print("  1. Enable iCloud sync for Voice Memos (System Settings → Apple ID → iCloud)")
        print("  2. Grant Full Disk Access to your terminal app")
        return 1

    conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)

    query = """
        SELECT ZENCRYPTEDTITLE, ZCUSTOMLABEL, ZDATE, ZDURATION, ZPATH
        FROM ZCLOUDRECORDING
        WHERE ZPATH IS NOT NULL
    """
    params = []

    if args.search:
        query += " AND (ZENCRYPTEDTITLE LIKE ? OR ZCUSTOMLABEL LIKE ?)"
        params.extend([f"%{args.search}%", f"%{args.search}%"])
    if args.after:
        dt = datetime.strptime(args.after, "%Y-%m-%d")
        delta = dt - APPLE_EPOCH
        query += " AND ZDATE >= ?"
        params.append(delta.total_seconds())
    if args.before:
        dt = datetime.strptime(args.before, "%Y-%m-%d")
        delta = dt - APPLE_EPOCH
        query += " AND ZDATE <= ?"
        params.append(delta.total_seconds())

    query += " ORDER BY ZDATE DESC LIMIT ? OFFSET ?"
    params.extend([args.limit, args.offset])

    try:
        rows = conn.execute(query, params).fetchall()
    except sqlite3.OperationalError as e:
        print(f"ERROR: Database query failed: {e}")
        conn.close()
        return 1
    finally:
        conn.close()

    if args.json:
        import json
        results = []
        for enc_title, label, date_val, duration, path in rows:
            ts = apple_to_datetime(date_val)
            title = enc_title or label or "Untitled"
            results.append({
                "title": title,
                "date": ts.isoformat() if ts else "Unknown",
                "duration": format_duration(duration),
                "path": path
            })
        print(json.dumps(results, ensure_ascii=False, indent=2))
    else:
        print(f"{'#':<4} {'Title':<30} {'Date':<20} {'Duration':<10} {'Path'}")
        print("-" * 80)
        for i, (enc_title, label, date_val, duration, path) in enumerate(rows, 1):
            ts = apple_to_datetime(date_val)
            date_str = ts.strftime("%Y-%m-%d %H:%M") if ts else "Unknown"
            title = enc_title or label or "Untitled"
            print(f"{i:<4} {title:<30} {date_str:<20} {format_duration(duration):<10} {path}")

    return 0


if __name__ == "__main__":
    exit(main())


path: scripts/transcribe.py
content: #!/usr/bin/env python3
"""Transcribe audio using faster-whisper with auto device detection."""
import argparse
import sys
import os


def detect_device():
    """Auto-detect best available compute device."""
    try:
        import coremltools  # noqa: F401
        return "coreml"
    except ImportError:
        pass
    try:
        import torch
        if torch.backends.mps.is_available():
            return "mps"
    except ImportError:
        pass
    return "cpu"


def main():
    parser = argparse.ArgumentParser(description="Transcribe audio with faster-whisper")
    parser.add_argument("audio_path", help="Path to audio file")
    parser.add_argument("--model", default="base",
                        choices=["tiny", "base", "small", "medium", "large-v3"],
                        help="Whisper model size (default: base)")
    parser.add_argument("--language", default=None,
                        help="Language code (e.g. zh, en, ja). Omit for auto-detect.")
    parser.add_argument("--output", default=None, help="Save transcript to file")
    parser.add_argument("--device", default=None,
                        choices=["cpu", "coreml", "mps"],
                        help="Compute device (default: auto-detect)")
    parser.add_argument("--no-vad", action="store_true", help="Disable VAD filter")
    args = parser.parse_args()

    # Validate input file
    if not os.path.exists(args.audio_path):
        print(f"ERROR: File not found: {args.audio_path}", file=sys.stderr)
        return 1

    device = args.device or detect_device()
    print(f"Device: {device}, Model: {args.model}", file=sys.stderr)

    try:
        from faster_whisper import WhisperModel
    except ImportError:
        print("ERROR: faster-whisper not installed.", file=sys.stderr)
        print("Run with: uv run --with faster-whisper python3 scripts/transcribe.py ...", file=sys.stderr)
        return 1

    try:
        if device == "coreml":
            model = WhisperModel(args.model, device="cpu", compute_type="int8",
                                 cpu_cores=4)
        elif device == "mps":
            model = WhisperModel(args.model, device="cpu", compute_type="int8")
        else:
            model = WhisperModel(args.model, device="cpu", compute_type="int8")
    except Exception as e:
        print(f"ERROR: Failed to load model '{args.model}': {e}", file=sys.stderr)
        return 1

    try:
        segments, info = model.transcribe(
            args.audio_path,
            language=args.language,
            vad_filter=not args.no_vad,
        )
    except Exception as e:
        print(f"ERROR: Transcription failed: {e}", file=sys.stderr)
        return 1

    print(f"Detected language: {info.language} (p={info.language_probability:.2f})", file=sys.stderr)

    lines = []
    for seg in segments:
        m, s = divmod(int(seg.start), 60)
        h, m = divmod(m, 60)
        if h > 0:
            timestamp = f"[{h:02d}:{m:02d}:{s:02d}]"
        else:
            timestamp = f"[{m:02d}:{s:02d}]"
        line = f"{timestamp} {seg.text.strip()}"
        lines.append(line)
        print(line)

    if args.output:
        with open(args.output, "w", encoding="utf-8") as f:
            f.write("\n".join(lines))
        print(f"\nSaved to {args.output}", file=sys.stderr)

    return 0


if __name__ == "__main__":
    exit(main())


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