initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
---
|
||||
description: Skills for working with media content — YouTube transcripts, GIF search, music generation, and audio visualization.
|
||||
---
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: audio-sonic-analysis
|
||||
description: Batch sonic/spectral analysis of music folders — tempo, energy, brightness, key estimation via librosa. Useful for Plex Sonic Analysis prep and music library characterization.
|
||||
category: media
|
||||
---
|
||||
|
||||
# Audio Sonic Analysis
|
||||
|
||||
Batch-extract sonic features from music folders for Plex Sonic Analysis or general library characterization.
|
||||
|
||||
## Triggers
|
||||
|
||||
- User wants sonic analysis, spectral analysis, or audio feature extraction of a music folder
|
||||
- User mentions Plex Sonic Analysis for a music library
|
||||
- User asks for tempo, energy, brightness, or key distribution of a music collection
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install --break-system-packages librosa
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Scope the folder
|
||||
|
||||
Count files first to gauge runtime:
|
||||
|
||||
```bash
|
||||
find "/path/to/music/folder" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.m4a" \) | wc -l
|
||||
```
|
||||
|
||||
### 2. Run batch analysis
|
||||
|
||||
Use the script at `scripts/batch_analyze.py`. It extracts per-track:
|
||||
|
||||
- **Tempo** (BPM) — beat tracking
|
||||
- **RMS energy** — perceived loudness
|
||||
- **Spectral centroid** (Hz) — brightness/darkness
|
||||
- **Zero-crossing rate** — noisiness
|
||||
- **Estimated key** — chroma CQT → pitch class
|
||||
- **Duration** (seconds)
|
||||
|
||||
```bash
|
||||
python3 scripts/batch_analyze.py "/path/to/music/folder"
|
||||
```
|
||||
|
||||
The script samples the first 45 seconds of each track at 22,050 Hz for speed. Output goes to stdout (summary) and `/tmp/<folder_name>_sonic_results.json` (per-track detail).
|
||||
|
||||
### 3. Interpret results for Plex
|
||||
|
||||
Key features Plex Sonic Analysis cares about:
|
||||
|
||||
| Feature | What it means for Plex |
|
||||
|---|---|
|
||||
| Tempo | Fast/slow radio seeding, BPM-based playlists |
|
||||
| Spectral centroid | "Bright" vs "dark" — acoustic vs electronic, vocal-forward vs bass-heavy |
|
||||
| RMS energy | Loudness/dynamics — quiet vs intense mood grouping |
|
||||
| Key | Harmonic mixing compatibility |
|
||||
|
||||
Plex computes these server-side via its own analysis pipeline. Running this script is useful for **previewing** what Plex will see before committing to a full library scan, or for libraries Plex can't access directly.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Go/songsee not available**: The `songsee` skill requires Go (rarely installed). Fall back to librosa — it's Python-only and covers 90% of the same features.
|
||||
- **Large folders**: 169 tracks took ~10 minutes. For 1000+ tracks, consider sampling (e.g., first 100 tracks) or running in the background with `notify_on_complete=true`.
|
||||
- **librosa warnings**: `librosa.beat.tempo` moved to `librosa.feature.rhythm.tempo` in 0.10+. The script uses the current path; warnings are cosmetic.
|
||||
- **Key estimation is approximate**: Chroma CQT works best on tonal music with clear pitch. Electronic/bass-heavy tracks may produce noisy estimates.
|
||||
|
||||
## Linked files
|
||||
|
||||
- `scripts/batch_analyze.py` — Reusable batch sonic analysis script
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Batch sonic/spectral analysis of all audio files in a directory.
|
||||
Outputs a summary and saves per-track JSON results.
|
||||
|
||||
Usage: python3 batch_analyze.py "/path/to/music/folder"
|
||||
"""
|
||||
|
||||
import os, sys, json
|
||||
import numpy as np
|
||||
import librosa
|
||||
from collections import Counter
|
||||
|
||||
def analyze_folder(music_dir: str):
|
||||
exts = ('.mp3', '.flac', '.m4a', '.ogg', '.wav')
|
||||
files = sorted(f for f in os.listdir(music_dir) if f.lower().endswith(exts))
|
||||
if not files:
|
||||
print("No audio files found.")
|
||||
return
|
||||
|
||||
results = []
|
||||
for i, f in enumerate(files):
|
||||
path = os.path.join(music_dir, f)
|
||||
try:
|
||||
y, sr = librosa.load(path, sr=22050, duration=45)
|
||||
tempo = librosa.feature.rhythm.tempo(y=y, sr=sr)[0]
|
||||
rms = float(np.nanmean(librosa.feature.rms(y=y)[0]))
|
||||
centroid = float(np.nanmean(librosa.feature.spectral_centroid(y=y, sr=sr)[0]))
|
||||
zcr = float(np.nanmean(librosa.feature.zero_crossing_rate(y=y)[0]))
|
||||
dur = float(librosa.get_duration(y=y, sr=sr))
|
||||
chroma = librosa.feature.chroma_cqt(y=y, sr=sr).mean(axis=1)
|
||||
keys = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
|
||||
key = keys[int(np.argmax(chroma))]
|
||||
results.append({
|
||||
'file': f,
|
||||
'tempo': round(tempo, 1),
|
||||
'rms': round(rms, 4),
|
||||
'centroid': round(centroid, 1),
|
||||
'zcr': round(zcr, 4),
|
||||
'duration': round(dur, 1),
|
||||
'key': key,
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({'file': f, 'error': str(e)})
|
||||
|
||||
good = [r for r in results if 'tempo' in r]
|
||||
bad = [r for r in results if 'error' in r]
|
||||
|
||||
tempos = [r['tempo'] for r in good]
|
||||
energies = [r['rms'] for r in good]
|
||||
centroids = [r['centroid'] for r in good]
|
||||
durations = [r['duration'] for r in good]
|
||||
keys = [r['key'] for r in good]
|
||||
kc = Counter(keys)
|
||||
|
||||
folder_name = os.path.basename(music_dir.rstrip('/'))
|
||||
print(f"=== {folder_name} — SONIC ANALYSIS ===")
|
||||
print(f"Total files: {len(files)} | Analyzed: {len(good)} | Errors: {len(bad)}")
|
||||
if bad:
|
||||
for r in bad:
|
||||
print(f" ✗ {r['file']}: {r['error']}")
|
||||
|
||||
print(f"\n--- COLLECTIVE STATS ---")
|
||||
print(f"Tempo: mean={np.mean(tempos):.1f} std={np.std(tempos):.1f} min={min(tempos):.1f} max={max(tempos):.1f}")
|
||||
print(f"Energy: mean={np.mean(energies):.4f} std={np.std(energies):.4f}")
|
||||
print(f"Brightness (centroid): mean={np.mean(centroids):.0f} Hz std={np.std(centroids):.0f} Hz")
|
||||
print(f"Duration: mean={np.mean(durations):.1f}s total={sum(durations)/60:.1f} min")
|
||||
|
||||
print(f"\n--- KEY DISTRIBUTION ---")
|
||||
for k, _ in kc.most_common():
|
||||
print(f" {k}: {kc[k]}")
|
||||
|
||||
print(f"\n--- FASTEST ---")
|
||||
for r in sorted(good, key=lambda x: x['tempo'], reverse=True)[:5]:
|
||||
print(f" {r['tempo']:.0f} BPM — {r['file']}")
|
||||
|
||||
print(f"\n--- SLOWEST ---")
|
||||
for r in sorted(good, key=lambda x: x['tempo'])[:5]:
|
||||
print(f" {r['tempo']:.0f} BPM — {r['file']}")
|
||||
|
||||
print(f"\n--- HIGHEST ENERGY ---")
|
||||
for r in sorted(good, key=lambda x: x['rms'], reverse=True)[:5]:
|
||||
print(f" {r['rms']:.4f} — {r['file']}")
|
||||
|
||||
print(f"\n--- BRIGHTEST (highest centroid) ---")
|
||||
for r in sorted(good, key=lambda x: x['centroid'], reverse=True)[:5]:
|
||||
print(f" {r['centroid']:.0f} Hz — {r['file']}")
|
||||
|
||||
print(f"\n--- DARKEST (lowest centroid) ---")
|
||||
for r in sorted(good, key=lambda x: x['centroid'])[:5]:
|
||||
print(f" {r['centroid']:.0f} Hz — {r['file']}")
|
||||
|
||||
out_path = f"/tmp/{folder_name.replace(' ', '_')}_sonic_results.json"
|
||||
with open(out_path, "w") as fh:
|
||||
json.dump(results, fh, indent=2, ensure_ascii=False)
|
||||
print(f"\n\nFull results saved to {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} /path/to/music/folder")
|
||||
sys.exit(1)
|
||||
analyze_folder(sys.argv[1])
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: gif-search
|
||||
description: "Search/download GIFs from Tenor via curl + jq."
|
||||
version: 1.1.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
prerequisites:
|
||||
env_vars: [TENOR_API_KEY]
|
||||
commands: [curl, jq]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [GIF, Media, Search, Tenor, API]
|
||||
---
|
||||
|
||||
# GIF Search (Tenor API)
|
||||
|
||||
Search and download GIFs directly via the Tenor API using curl. No extra tools needed.
|
||||
|
||||
## When to use
|
||||
|
||||
Useful for finding reaction GIFs, creating visual content, and sending GIFs in chat.
|
||||
|
||||
## Setup
|
||||
|
||||
Set your Tenor API key in your environment (add to `${HERMES_HOME:-~/.hermes}/.env`):
|
||||
|
||||
```bash
|
||||
TENOR_API_KEY=your_key_here
|
||||
```
|
||||
|
||||
Get a free API key at https://developers.google.com/tenor/guides/quickstart — the Google Cloud Console Tenor API key is free and has generous rate limits.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `curl` and `jq` (both standard on macOS/Linux)
|
||||
- `TENOR_API_KEY` environment variable
|
||||
|
||||
## Search for GIFs
|
||||
|
||||
```bash
|
||||
# Search and get GIF URLs
|
||||
curl -s "https://tenor.googleapis.com/v2/search?q=thumbs+up&limit=5&key=${TENOR_API_KEY}" | jq -r '.results[].media_formats.gif.url'
|
||||
|
||||
# Get smaller/preview versions
|
||||
curl -s "https://tenor.googleapis.com/v2/search?q=nice+work&limit=3&key=${TENOR_API_KEY}" | jq -r '.results[].media_formats.tinygif.url'
|
||||
```
|
||||
|
||||
## Download a GIF
|
||||
|
||||
```bash
|
||||
# Search and download the top result
|
||||
URL=$(curl -s "https://tenor.googleapis.com/v2/search?q=celebration&limit=1&key=${TENOR_API_KEY}" | jq -r '.results[0].media_formats.gif.url')
|
||||
curl -sL "$URL" -o celebration.gif
|
||||
```
|
||||
|
||||
## Get Full Metadata
|
||||
|
||||
```bash
|
||||
curl -s "https://tenor.googleapis.com/v2/search?q=cat&limit=3&key=${TENOR_API_KEY}" | jq '.results[] | {title: .title, url: .media_formats.gif.url, preview: .media_formats.tinygif.url, dimensions: .media_formats.gif.dims}'
|
||||
```
|
||||
|
||||
## API Parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `q` | Search query (URL-encode spaces as `+`) |
|
||||
| `limit` | Max results (1-50, default 20) |
|
||||
| `key` | API key (from `$TENOR_API_KEY` env var) |
|
||||
| `media_filter` | Filter formats: `gif`, `tinygif`, `mp4`, `tinymp4`, `webm` |
|
||||
| `contentfilter` | Safety: `off`, `low`, `medium`, `high` |
|
||||
| `locale` | Language: `en_US`, `es`, `fr`, etc. |
|
||||
|
||||
## Available Media Formats
|
||||
|
||||
Each result has multiple formats under `.media_formats`:
|
||||
|
||||
| Format | Use case |
|
||||
|--------|----------|
|
||||
| `gif` | Full quality GIF |
|
||||
| `tinygif` | Small preview GIF |
|
||||
| `mp4` | Video version (smaller file size) |
|
||||
| `tinymp4` | Small preview video |
|
||||
| `webm` | WebM video |
|
||||
| `nanogif` | Tiny thumbnail |
|
||||
|
||||
## Notes
|
||||
|
||||
- URL-encode the query: spaces as `+`, special chars as `%XX`
|
||||
- For sending in chat, `tinygif` URLs are lighter weight
|
||||
- GIF URLs can be used directly in markdown: ``
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
name: heartmula
|
||||
description: "HeartMuLa: Suno-like song generation from lyrics + tags."
|
||||
version: 1.0.0
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [music, audio, generation, ai, heartmula, heartcodec, lyrics, songs]
|
||||
related_skills: [audiocraft]
|
||||
---
|
||||
|
||||
# HeartMuLa - Open-Source Music Generation
|
||||
|
||||
## Overview
|
||||
HeartMuLa is a family of open-source music foundation models (Apache-2.0) that generates music conditioned on lyrics and tags, with multilingual support. Generates full songs from lyrics + tags. Comparable to Suno for open-source. Includes:
|
||||
- **HeartMuLa** - Music language model (3B/7B) for generation from lyrics + tags
|
||||
- **HeartCodec** - 12.5Hz music codec for high-fidelity audio reconstruction
|
||||
- **HeartTranscriptor** - Whisper-based lyrics transcription
|
||||
- **HeartCLAP** - Audio-text alignment model
|
||||
|
||||
## When to Use
|
||||
- User wants to generate music/songs from text descriptions
|
||||
- User wants an open-source Suno alternative
|
||||
- User wants local/offline music generation
|
||||
- User asks about HeartMuLa, heartlib, or AI music generation
|
||||
|
||||
## Hardware Requirements
|
||||
- **Minimum**: 8GB VRAM with `--lazy_load true` (loads/unloads models sequentially)
|
||||
- **Recommended**: 16GB+ VRAM for comfortable single-GPU usage
|
||||
- **Multi-GPU**: Use `--mula_device cuda:0 --codec_device cuda:1` to split across GPUs
|
||||
- 3B model with lazy_load peaks at ~6.2GB VRAM
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Clone Repository
|
||||
```bash
|
||||
cd ~/ # or desired directory
|
||||
git clone https://github.com/HeartMuLa/heartlib.git
|
||||
cd heartlib
|
||||
```
|
||||
|
||||
### 2. Create Virtual Environment (Python 3.10 required)
|
||||
```bash
|
||||
uv venv --python 3.10 .venv
|
||||
. .venv/bin/activate
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
### 3. Fix Dependency Compatibility Issues
|
||||
|
||||
**IMPORTANT**: As of Feb 2026, the pinned dependencies have conflicts with newer packages. Apply these fixes:
|
||||
|
||||
```bash
|
||||
# Upgrade datasets (old version incompatible with current pyarrow)
|
||||
uv pip install --upgrade datasets
|
||||
|
||||
# Upgrade transformers (needed for huggingface-hub 1.x compatibility)
|
||||
uv pip install --upgrade transformers
|
||||
```
|
||||
|
||||
### 4. Patch Source Code (Required for transformers 5.x)
|
||||
|
||||
**Patch 1 - RoPE cache fix** in `src/heartlib/heartmula/modeling_heartmula.py`:
|
||||
|
||||
In the `setup_caches` method of the `HeartMuLa` class, add RoPE reinitialization after the `reset_caches` try/except block and before the `with device:` block:
|
||||
|
||||
```python
|
||||
# Re-initialize RoPE caches that were skipped during meta-device loading
|
||||
from torchtune.models.llama3_1._position_embeddings import Llama3ScaledRoPE
|
||||
for module in self.modules():
|
||||
if isinstance(module, Llama3ScaledRoPE) and not module.is_cache_built:
|
||||
module.rope_init()
|
||||
module.to(device)
|
||||
```
|
||||
|
||||
**Why**: `from_pretrained` creates model on meta device first; `Llama3ScaledRoPE.rope_init()` skips cache building on meta tensors, then never rebuilds after weights are loaded to real device.
|
||||
|
||||
**Patch 2 - HeartCodec loading fix** in `src/heartlib/pipelines/music_generation.py`:
|
||||
|
||||
Add `ignore_mismatched_sizes=True` to ALL `HeartCodec.from_pretrained()` calls (there are 2: the eager load in `__init__` and the lazy load in the `codec` property).
|
||||
|
||||
**Why**: VQ codebook `initted` buffers have shape `[1]` in checkpoint vs `[]` in model. Same data, just scalar vs 0-d tensor. Safe to ignore.
|
||||
|
||||
### 5. Download Model Checkpoints
|
||||
```bash
|
||||
cd heartlib # project root
|
||||
hf download --local-dir './ckpt' 'HeartMuLa/HeartMuLaGen'
|
||||
hf download --local-dir './ckpt/HeartMuLa-oss-3B' 'HeartMuLa/HeartMuLa-oss-3B-happy-new-year'
|
||||
hf download --local-dir './ckpt/HeartCodec-oss' 'HeartMuLa/HeartCodec-oss-20260123'
|
||||
```
|
||||
|
||||
All 3 can be downloaded in parallel. Total size is several GB.
|
||||
|
||||
## GPU / CUDA
|
||||
|
||||
HeartMuLa uses CUDA by default (`--mula_device cuda --codec_device cuda`). No extra setup needed if the user has an NVIDIA GPU with PyTorch CUDA support installed.
|
||||
|
||||
- The installed `torch==2.4.1` includes CUDA 12.1 support out of the box
|
||||
- `torchtune` may report version `0.4.0+cpu` — this is just package metadata, it still uses CUDA via PyTorch
|
||||
- To verify GPU is being used, look for "CUDA memory" lines in the output (e.g. "CUDA memory before unloading: 6.20 GB")
|
||||
- **No GPU?** You can run on CPU with `--mula_device cpu --codec_device cpu`, but expect generation to be **extremely slow** (potentially 30-60+ minutes for a single song vs ~4 minutes on GPU). CPU mode also requires significant RAM (~12GB+ free). If the user has no NVIDIA GPU, recommend using a cloud GPU service (Google Colab free tier with T4, Lambda Labs, etc.) or the online demo at https://heartmula.github.io/ instead.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Generation
|
||||
```bash
|
||||
cd heartlib
|
||||
. .venv/bin/activate
|
||||
python ./examples/run_music_generation.py \
|
||||
--model_path=./ckpt \
|
||||
--version="3B" \
|
||||
--lyrics="./assets/lyrics.txt" \
|
||||
--tags="./assets/tags.txt" \
|
||||
--save_path="./assets/output.mp3" \
|
||||
--lazy_load true
|
||||
```
|
||||
|
||||
### Input Formatting
|
||||
|
||||
**Tags** (comma-separated, no spaces):
|
||||
```
|
||||
piano,happy,wedding,synthesizer,romantic
|
||||
```
|
||||
or
|
||||
```
|
||||
rock,energetic,guitar,drums,male-vocal
|
||||
```
|
||||
|
||||
**Lyrics** (use bracketed structural tags):
|
||||
```
|
||||
[Intro]
|
||||
|
||||
[Verse]
|
||||
Your lyrics here...
|
||||
|
||||
[Chorus]
|
||||
Chorus lyrics...
|
||||
|
||||
[Bridge]
|
||||
Bridge lyrics...
|
||||
|
||||
[Outro]
|
||||
```
|
||||
|
||||
### Key Parameters
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `--max_audio_length_ms` | 240000 | Max length in ms (240s = 4 min) |
|
||||
| `--topk` | 50 | Top-k sampling |
|
||||
| `--temperature` | 1.0 | Sampling temperature |
|
||||
| `--cfg_scale` | 1.5 | Classifier-free guidance scale |
|
||||
| `--lazy_load` | false | Load/unload models on demand (saves VRAM) |
|
||||
| `--mula_dtype` | bfloat16 | Dtype for HeartMuLa (bf16 recommended) |
|
||||
| `--codec_dtype` | float32 | Dtype for HeartCodec (fp32 recommended for quality) |
|
||||
|
||||
### Performance
|
||||
- RTF (Real-Time Factor) ≈ 1.0 — a 4-minute song takes ~4 minutes to generate
|
||||
- Output: MP3, 48kHz stereo, 128kbps
|
||||
|
||||
## Pitfalls
|
||||
1. **Do NOT use bf16 for HeartCodec** — degrades audio quality. Use fp32 (default).
|
||||
2. **Tags may be ignored** — known issue (#90). Lyrics tend to dominate; experiment with tag ordering.
|
||||
3. **Triton not available on macOS** — Linux/CUDA only for GPU acceleration.
|
||||
4. **RTX 5080 incompatibility** reported in upstream issues.
|
||||
5. The dependency pin conflicts require the manual upgrades and patches described above.
|
||||
|
||||
## Links
|
||||
- Repo: https://github.com/HeartMuLa/heartlib
|
||||
- Models: https://huggingface.co/HeartMuLa
|
||||
- Paper: https://arxiv.org/abs/2601.10547
|
||||
- License: Apache-2.0
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: music-library-management
|
||||
description: Manage local music libraries — tag, rename, and organize files with beets and mutagen. Absorbed music-renamer (mutagen-based in-place rename workflow).
|
||||
category: media
|
||||
---
|
||||
|
||||
# Music Library Management
|
||||
|
||||
Tag, rename, and organize local music files. Covers beets (music library manager) for metadata-aware
|
||||
workflows and mutagen for fast bulk operations when files already have clean ID3 tags.
|
||||
|
||||
## Triggers
|
||||
|
||||
- User wants to rename/tag/organize music files
|
||||
- User asks about beets, mutagen, or music file metadata
|
||||
- User has a collection of mp3/flac/m4a files that need cleanup
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install beets (includes mutagen as a dependency):
|
||||
|
||||
```bash
|
||||
pip install --break-system-packages beets
|
||||
```
|
||||
|
||||
Config lives at `~/.config/beets/config.yaml`.
|
||||
|
||||
## Workflow Decision
|
||||
|
||||
### Use beets when:
|
||||
- Files need MusicBrainz tagging (missing or wrong metadata)
|
||||
- You want to reorganize files into a standard Artist/Album/Track structure
|
||||
- You need beets' query and library features long-term
|
||||
- Small batch (<100 files) — even with MusicBrainz timeout, it's manageable
|
||||
|
||||
### Use mutagen directly when:
|
||||
- Files already have clean ID3 tags (artist, title, album present)
|
||||
- Bulk renaming is the only goal (format change, not metadata enrichment)
|
||||
- Large batch (100+ files) — beets import is too slow per-file
|
||||
- In-place renaming (keep directory structure, just fix filenames)
|
||||
|
||||
## Beets Workflow
|
||||
|
||||
### Configuration (`~/.config/beets/config.yaml`)
|
||||
|
||||
```yaml
|
||||
directory: /path/to/music/root
|
||||
library: /path/to/music/root/musiclibrary.db
|
||||
|
||||
import:
|
||||
copy: no
|
||||
move: no # no for import-only; yes when ready to reorganize
|
||||
write: yes
|
||||
quiet: yes # skip confirmation prompts
|
||||
|
||||
# Treat loose singles as a compilation
|
||||
singletons:
|
||||
album: Singles
|
||||
albumartist: Various Artists
|
||||
compilation: yes
|
||||
|
||||
paths:
|
||||
singleton: %(artist)s - %(title)s
|
||||
comp: Compilations/%(album)s/%(artist)s - %(title)s
|
||||
default: %(albumartist)s/%(album)s/%(track)02d - %(title)s
|
||||
```
|
||||
|
||||
### Import files into the library
|
||||
|
||||
```bash
|
||||
# Slow — hits MusicBrainz per file. Use -A (no autotag) if tags are already good.
|
||||
beet import -A -q --singletons /path/to/music/dir/
|
||||
```
|
||||
|
||||
### Query and rename already-imported files
|
||||
|
||||
```bash
|
||||
# Dry run: see what would change
|
||||
beet ls -f '$path || $artist - $title' 'query'
|
||||
|
||||
# Actually rename (moves per path template in config)
|
||||
beet move 'query'
|
||||
```
|
||||
|
||||
For in-place renaming with beets, the path template must match the directory
|
||||
structure. Beets always uses `directory + path_template` — it cannot rename
|
||||
within arbitrary subdirectories without moving.
|
||||
|
||||
## Mutagen Bulk Rename
|
||||
|
||||
When beets import is too slow and files already have ID3 tags, use the script at
|
||||
`scripts/bulk-rename.py`. It walks a directory tree, reads artist/title from tags
|
||||
via mutagen, and renames in-place to `Artist - Title.ext`.
|
||||
|
||||
## Linked files
|
||||
|
||||
- `scripts/bulk-rename.py` — Bulk in-place rename using mutagen ID3 tags
|
||||
- `scripts/rename_by_tags.py` — Alternative rename script (absorbed from music-renamer)
|
||||
|
||||
The script handles:
|
||||
- Collisions (appends `(1)`, `(2)` to duplicates in the same directory)
|
||||
- Special characters (strips `/` and null bytes)
|
||||
- Files already correctly named (skips)
|
||||
|
||||
## Quick Rename Workflow (mutagen, absorbed from `music-renamer`)
|
||||
|
||||
For the specific use case of renaming files in-place using embedded ID3 tags
|
||||
(files already have good tags, just need cleaner filenames), use the script at
|
||||
`scripts/rename_by_tags.py`. It walks a directory tree, reads artist/title from
|
||||
mutagen, and renames to `Artist - Title.ext`.
|
||||
|
||||
### Trigger for this workflow
|
||||
|
||||
User asks to rename/organize music files, clean up filenames, strip number prefixes
|
||||
from downloaded music.
|
||||
|
||||
### Step 1 — Determine scope
|
||||
|
||||
**CRITICAL**: Confirm which directories the user wants renamed. Never assume "all music."
|
||||
Use `find` with a `-regex` pattern to count files with number prefixes:
|
||||
|
||||
```bash
|
||||
find /path/to/music -type f -regex ".*/[0-9]+\. .*"
|
||||
```
|
||||
|
||||
Files with number prefixes (e.g., `123. Title.mp3`) are the unrenamed ones.
|
||||
|
||||
### Step 2 — Run the rename script
|
||||
|
||||
Use `scripts/rename_by_tags.py` via `execute_code`. The script:
|
||||
- Walks a base directory recursively
|
||||
- Reads artist/title from ID3 tags via mutagen
|
||||
- Renames files to `Artist - Title.ext` in-place (same directory)
|
||||
- Skips files already in the correct format
|
||||
- Handles collisions by appending `(1)`, `(2)` etc.
|
||||
- Handles slashes in artist/title by replacing with `-`
|
||||
|
||||
### Step 3 — Verify
|
||||
|
||||
```bash
|
||||
ls /path/to/music/some-folder/ | head -10
|
||||
find /path/to/music -regex ".*/[0-9]+\. .*" | wc -l # should be 0
|
||||
```
|
||||
|
||||
### When to use mutagen vs beets
|
||||
|
||||
| Scenario | Tool |
|
||||
|---|---|
|
||||
| Files already have good ID3 tags, just need renaming | **mutagen** (scripts/rename_by_tags.py) |
|
||||
| Files have NO tags, need to be matched against MusicBrainz | **beets import** (with autotag) |
|
||||
| Small batch (< 50 files) needing autotag | **beets import** is fine |
|
||||
| Large batch (> 100 files) | **mutagen** — beets import will time out |
|
||||
|
||||
### User preferences
|
||||
|
||||
- Ray prefers mutagen over beets for bulk renaming
|
||||
- Always confirm directory scope — don't expand beyond what was asked
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Beets import is slow**: Even with `-A` (no autotag), beets still does per-file
|
||||
MusicBrainz lookups. Expect ~1-3 seconds per file. For 1000+ files, mutagen is
|
||||
the right choice.
|
||||
- **Beets `move` vs in-place**: `beet move` respects `directory + path_template`.
|
||||
It cannot do truly in-place renames within arbitrary subdirectories. For in-place
|
||||
renames, use mutagen or a script as above.
|
||||
- **`beet update`**: Updates metadata FROM files TO library — not the reverse.
|
||||
Does not update stored paths after external renames.
|
||||
- **Config YAML quoting**: Beets path templates use `%(var)s` syntax. The YAML
|
||||
linter may warn about unquoted `%` characters but beets parses them correctly.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Beets config — in-place (no-move) setup
|
||||
|
||||
directory: /mnt/seagate8tb/Music
|
||||
library: /mnt/seagate8tb/Music/musiclibrary.db
|
||||
|
||||
import:
|
||||
copy: no
|
||||
move: no
|
||||
write: yes
|
||||
quiet: yes
|
||||
|
||||
# Singleton mode: treat all files as individual tracks (not albums).
|
||||
# Use this when you have compilation/playlist folders, not proper albums.
|
||||
singletons:
|
||||
album: Singles
|
||||
albumartist: Various Artists
|
||||
compilation: yes
|
||||
|
||||
paths:
|
||||
singleton: %(artist)s - %(title)s
|
||||
comp: Compilations/%(album)s/%(artist)s - %(title)s
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bulk-rename music files in-place using ID3 tags via mutagen.
|
||||
|
||||
Reads artist/title from file metadata and renames to `Artist - Title.ext`.
|
||||
Handles collisions, special characters, and files already correctly named.
|
||||
|
||||
Usage:
|
||||
python3 bulk-rename.py /path/to/music/dir
|
||||
|
||||
Place in ~/.hermes/skills/media/music-library-management/scripts/
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from mutagen import File
|
||||
|
||||
def sanitize(name):
|
||||
return name.replace('/', '-').replace('\x00', '')
|
||||
|
||||
def rename_tree(base):
|
||||
renamed = 0
|
||||
skipped_tag = 0
|
||||
skipped_ok = 0
|
||||
errors = []
|
||||
|
||||
for root, dirs, files in os.walk(base):
|
||||
for fname in files:
|
||||
if not fname.lower().endswith(('.mp3', '.flac', '.m4a', '.ogg')):
|
||||
continue
|
||||
|
||||
fullpath = os.path.join(root, fname)
|
||||
|
||||
try:
|
||||
audio = File(fullpath, easy=True)
|
||||
except Exception as e:
|
||||
errors.append(f"READ {fullpath}: {e}")
|
||||
continue
|
||||
|
||||
if audio is None or not audio.tags:
|
||||
skipped_tag += 1
|
||||
continue
|
||||
|
||||
artist = audio.tags.get('artist', [None])[0]
|
||||
title = audio.tags.get('title', [None])[0]
|
||||
|
||||
if not artist or not title:
|
||||
skipped_tag += 1
|
||||
continue
|
||||
|
||||
ext = os.path.splitext(fname)[1]
|
||||
new_name = f"{sanitize(artist)} - {sanitize(title)}{ext}"
|
||||
new_path = os.path.join(root, new_name)
|
||||
|
||||
if fullpath == new_path:
|
||||
skipped_ok += 1
|
||||
continue
|
||||
|
||||
# Handle collisions
|
||||
counter = 1
|
||||
while os.path.exists(new_path) and new_path != fullpath:
|
||||
name_no_ext = f"{sanitize(artist)} - {sanitize(title)} ({counter})"
|
||||
new_path = os.path.join(root, name_no_ext + ext)
|
||||
counter += 1
|
||||
|
||||
try:
|
||||
os.rename(fullpath, new_path)
|
||||
renamed += 1
|
||||
except OSError as e:
|
||||
errors.append(f"RENAME {fullpath}: {e}")
|
||||
|
||||
print(f"Renamed: {renamed}")
|
||||
print(f"Skipped (no tags): {skipped_tag}")
|
||||
print(f"Skipped (already correct): {skipped_ok}")
|
||||
print(f"Errors: {len(errors)}")
|
||||
for e in errors[:20]:
|
||||
print(f" {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} /path/to/music/dir")
|
||||
sys.exit(1)
|
||||
rename_tree(sys.argv[1])
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Rename music files in-place using embedded ID3 tags.
|
||||
Format: Artist - Title.ext
|
||||
Handles collisions by appending (1), (2), etc.
|
||||
Run via execute_code in an Hermes session — mutagen is available in the agent environment.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from mutagen import File
|
||||
|
||||
|
||||
def rename_by_tags(base_dir: str, extensions: tuple = ('.mp3', '.flac', '.m4a', '.ogg')) -> dict:
|
||||
"""Walk base_dir and rename all music files to 'Artist - Title.ext' in-place."""
|
||||
renamed = 0
|
||||
skipped_tag = 0
|
||||
skipped_ok = 0
|
||||
errors = []
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for fname in files:
|
||||
if not fname.lower().endswith(extensions):
|
||||
continue
|
||||
|
||||
fullpath = os.path.join(root, fname)
|
||||
|
||||
try:
|
||||
audio = File(fullpath, easy=True)
|
||||
except Exception as e:
|
||||
errors.append(f"READ {fullpath}: {e}")
|
||||
continue
|
||||
|
||||
if audio is None or not audio.tags:
|
||||
skipped_tag += 1
|
||||
continue
|
||||
|
||||
artist = audio.tags.get('artist', [None])[0]
|
||||
title = audio.tags.get('title', [None])[0]
|
||||
|
||||
if not artist or not title:
|
||||
skipped_tag += 1
|
||||
continue
|
||||
|
||||
new_name = f"{artist} - {title}".replace('/', '-').replace('\x00', '')
|
||||
ext = os.path.splitext(fname)[1]
|
||||
new_name += ext
|
||||
new_path = os.path.join(root, new_name)
|
||||
|
||||
if fullpath == new_path:
|
||||
skipped_ok += 1
|
||||
continue
|
||||
|
||||
# Handle collisions
|
||||
counter = 1
|
||||
while os.path.exists(new_path) and new_path != fullpath:
|
||||
name_no_ext = f"{artist} - {title} ({counter})".replace('/', '-')
|
||||
new_path = os.path.join(root, name_no_ext + ext)
|
||||
counter += 1
|
||||
|
||||
try:
|
||||
os.rename(fullpath, new_path)
|
||||
renamed += 1
|
||||
except OSError as e:
|
||||
errors.append(f"RENAME {fullpath}: {e}")
|
||||
|
||||
return {
|
||||
'renamed': renamed,
|
||||
'skipped_tag': skipped_tag,
|
||||
'skipped_ok': skipped_ok,
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python rename_by_tags.py /path/to/music")
|
||||
sys.exit(1)
|
||||
result = rename_by_tags(sys.argv[1])
|
||||
print(f"Renamed: {result['renamed']}")
|
||||
print(f"Skipped (no tags): {result['skipped_tag']}")
|
||||
print(f"Skipped (already OK): {result['skipped_ok']}")
|
||||
print(f"Errors: {len(result['errors'])}")
|
||||
for e in result['errors'][:10]:
|
||||
print(f" {e}")
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: songsee
|
||||
description: "Audio spectrograms/features (mel, chroma, MFCC) via CLI."
|
||||
version: 1.0.0
|
||||
author: community
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Audio, Visualization, Spectrogram, Music, Analysis]
|
||||
homepage: https://github.com/steipete/songsee
|
||||
prerequisites:
|
||||
commands: [songsee]
|
||||
---
|
||||
|
||||
# songsee
|
||||
|
||||
Generate spectrograms and multi-panel audio feature visualizations from audio files.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Requires [Go](https://go.dev/doc/install):
|
||||
```bash
|
||||
go install github.com/steipete/songsee/cmd/songsee@latest
|
||||
```
|
||||
|
||||
Optional: `ffmpeg` for formats beyond WAV/MP3.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Basic spectrogram
|
||||
songsee track.mp3
|
||||
|
||||
# Save to specific file
|
||||
songsee track.mp3 -o spectrogram.png
|
||||
|
||||
# Multi-panel visualization grid
|
||||
songsee track.mp3 --viz spectrogram,mel,chroma,hpss,selfsim,loudness,tempogram,mfcc,flux
|
||||
|
||||
# Time slice (start at 12.5s, 8s duration)
|
||||
songsee track.mp3 --start 12.5 --duration 8 -o slice.jpg
|
||||
|
||||
# From stdin
|
||||
cat track.mp3 | songsee - --format png -o out.png
|
||||
```
|
||||
|
||||
## Visualization Types
|
||||
|
||||
Use `--viz` with comma-separated values:
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `spectrogram` | Standard frequency spectrogram |
|
||||
| `mel` | Mel-scaled spectrogram |
|
||||
| `chroma` | Pitch class distribution |
|
||||
| `hpss` | Harmonic/percussive separation |
|
||||
| `selfsim` | Self-similarity matrix |
|
||||
| `loudness` | Loudness over time |
|
||||
| `tempogram` | Tempo estimation |
|
||||
| `mfcc` | Mel-frequency cepstral coefficients |
|
||||
| `flux` | Spectral flux (onset detection) |
|
||||
|
||||
Multiple `--viz` types render as a grid in a single image.
|
||||
|
||||
## Common Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--viz` | Visualization types (comma-separated) |
|
||||
| `--style` | Color palette: `classic`, `magma`, `inferno`, `viridis`, `gray` |
|
||||
| `--width` / `--height` | Output image dimensions |
|
||||
| `--window` / `--hop` | FFT window and hop size |
|
||||
| `--min-freq` / `--max-freq` | Frequency range filter |
|
||||
| `--start` / `--duration` | Time slice of the audio |
|
||||
| `--format` | Output format: `jpg` or `png` |
|
||||
| `-o` | Output file path |
|
||||
|
||||
## Notes
|
||||
|
||||
- WAV and MP3 are decoded natively; other formats require `ffmpeg`
|
||||
- Output images can be inspected with `vision_analyze` for automated audio analysis
|
||||
- Useful for comparing audio outputs, debugging synthesis, or documenting audio processing pipelines
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
name: spotify
|
||||
description: "Spotify: play, search, queue, manage playlists and devices."
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
prerequisites:
|
||||
tools: [spotify_playback, spotify_devices, spotify_queue, spotify_search, spotify_playlists, spotify_albums, spotify_library]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [spotify, music, playback, playlists, media]
|
||||
related_skills: [gif-search]
|
||||
---
|
||||
|
||||
# Spotify
|
||||
|
||||
Control the user's Spotify account via the Hermes Spotify toolset (7 tools). Setup guide: https://hermes-agent.nousresearch.com/docs/user-guide/features/spotify
|
||||
|
||||
## When to use this skill
|
||||
|
||||
The user says something like "play X", "pause", "skip", "queue up X", "what's playing", "search for X", "add to my X playlist", "make a playlist", "save this to my library", etc.
|
||||
|
||||
## The 7 tools
|
||||
|
||||
- `spotify_playback` — play, pause, next, previous, seek, set_repeat, set_shuffle, set_volume, get_state, get_currently_playing, recently_played
|
||||
- `spotify_devices` — list, transfer
|
||||
- `spotify_queue` — get, add
|
||||
- `spotify_search` — search the catalog
|
||||
- `spotify_playlists` — list, get, create, add_items, remove_items, update_details
|
||||
- `spotify_albums` — get, tracks
|
||||
- `spotify_library` — list/save/remove with `kind: "tracks"|"albums"`
|
||||
|
||||
Playback-mutating actions require Spotify Premium; search/library/playlist ops work on Free.
|
||||
|
||||
## Canonical patterns (minimize tool calls)
|
||||
|
||||
### "Play <artist/track/album>"
|
||||
One search, then play by URI. Do NOT loop through search results describing them unless the user asked for options.
|
||||
|
||||
```
|
||||
spotify_search({"query": "miles davis kind of blue", "types": ["album"], "limit": 1})
|
||||
→ got album URI spotify:album:1weenld61qoidwYuZ1GESA
|
||||
spotify_playback({"action": "play", "context_uri": "spotify:album:1weenld61qoidwYuZ1GESA"})
|
||||
```
|
||||
|
||||
For "play some <artist>" (no specific song), prefer `types: ["artist"]` and play the artist context URI — Spotify handles smart shuffle. If the user says "the song" or "that track", search `types: ["track"]` and pass `uris: [track_uri]` to play.
|
||||
|
||||
### "What's playing?" / "What am I listening to?"
|
||||
Single call — don't chain get_state after get_currently_playing.
|
||||
|
||||
```
|
||||
spotify_playback({"action": "get_currently_playing"})
|
||||
```
|
||||
|
||||
If it returns 204/empty (`is_playing: false`), tell the user nothing is playing. Don't retry.
|
||||
|
||||
### "Pause" / "Skip" / "Volume 50"
|
||||
Direct action, no preflight inspection needed.
|
||||
|
||||
```
|
||||
spotify_playback({"action": "pause"})
|
||||
spotify_playback({"action": "next"})
|
||||
spotify_playback({"action": "set_volume", "volume_percent": 50})
|
||||
```
|
||||
|
||||
### "Add to my <playlist name> playlist"
|
||||
1. `spotify_playlists list` to find the playlist ID by name
|
||||
2. Get the track URI (from currently playing, or search)
|
||||
3. `spotify_playlists add_items` with the playlist_id and URIs
|
||||
|
||||
```
|
||||
spotify_playlists({"action": "list"})
|
||||
→ found "Late Night Jazz" = 37i9dQZF1DX4wta20PHgwo
|
||||
spotify_playback({"action": "get_currently_playing"})
|
||||
→ current track uri = spotify:track:0DiWol3AO6WpXZgp0goxAV
|
||||
spotify_playlists({"action": "add_items",
|
||||
"playlist_id": "37i9dQZF1DX4wta20PHgwo",
|
||||
"uris": ["spotify:track:0DiWol3AO6WpXZgp0goxAV"]})
|
||||
```
|
||||
|
||||
### "Create a playlist called X and add the last 3 songs I played"
|
||||
```
|
||||
spotify_playback({"action": "recently_played", "limit": 3})
|
||||
spotify_playlists({"action": "create", "name": "Focus 2026"})
|
||||
→ got playlist_id back in response
|
||||
spotify_playlists({"action": "add_items", "playlist_id": <id>, "uris": [<3 uris>]})
|
||||
```
|
||||
|
||||
### "Save / unsave / is this saved?"
|
||||
Use `spotify_library` with the right `kind`.
|
||||
|
||||
```
|
||||
spotify_library({"kind": "tracks", "action": "save", "uris": ["spotify:track:..."]})
|
||||
spotify_library({"kind": "albums", "action": "list", "limit": 50})
|
||||
```
|
||||
|
||||
### "Transfer playback to my <device>"
|
||||
```
|
||||
spotify_devices({"action": "list"})
|
||||
→ pick the device_id by matching name/type
|
||||
spotify_devices({"action": "transfer", "device_id": "<id>", "play": true})
|
||||
```
|
||||
|
||||
## Critical failure modes
|
||||
|
||||
**`403 Forbidden — No active device found`** on any playback action means Spotify isn't running anywhere. Tell the user: "Open Spotify on your phone/desktop/web player first, start any track for a second, then retry." Don't retry the tool call blindly — it will fail the same way. You can call `spotify_devices list` to confirm; an empty list means no active device.
|
||||
|
||||
**`403 Forbidden — Premium required`** means the user is on Free and tried to mutate playback. Don't retry; tell them this action needs Premium. Reads still work (search, playlists, library, get_state).
|
||||
|
||||
**`204 No Content` on `get_currently_playing`** is NOT an error — it means nothing is playing. The tool returns `is_playing: false`. Just report that to the user.
|
||||
|
||||
**`429 Too Many Requests`** = rate limit. Wait and retry once. If it keeps happening, you're looping — stop.
|
||||
|
||||
**`401 Unauthorized` after a retry** — refresh token revoked. Tell the user to run `hermes auth spotify` again.
|
||||
|
||||
## URI and ID formats
|
||||
|
||||
Spotify uses three interchangeable ID formats. The tools accept all three and normalize:
|
||||
|
||||
- URI: `spotify:track:0DiWol3AO6WpXZgp0goxAV` (preferred)
|
||||
- URL: `https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV`
|
||||
- Bare ID: `0DiWol3AO6WpXZgp0goxAV`
|
||||
|
||||
When in doubt, use full URIs. Search results return URIs in the `uri` field — pass those directly.
|
||||
|
||||
Entity types: `track`, `album`, `artist`, `playlist`, `show`, `episode`. Use the right type for the action — `spotify_playback.play` with a `context_uri` expects album/playlist/artist; `uris` expects an array of track URIs.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- **Don't call `get_state` before every action.** Spotify accepts play/pause/skip without preflight. Only inspect state when the user asked "what's playing" or you need to reason about device/track.
|
||||
- **Don't describe search results unless asked.** If the user said "play X", search, grab the top URI, play it. They'll hear it's wrong if it's wrong.
|
||||
- **Don't retry on `403 Premium required` or `403 No active device`.** Those are permanent until user action.
|
||||
- **Don't use `spotify_search` to find a playlist by name** — that searches the public Spotify catalog. User playlists come from `spotify_playlists list`.
|
||||
- **Don't mix `kind: "tracks"` with album URIs** in `spotify_library` (or vice versa). The tool normalizes IDs but the API endpoint differs.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: youtube-content
|
||||
description: "YouTube transcripts to summaries, threads, blogs."
|
||||
platforms: [linux, macos, windows]
|
||||
---
|
||||
|
||||
# YouTube Content Tool
|
||||
|
||||
## When to use
|
||||
|
||||
Use when the user shares a YouTube URL or video link, asks to summarize a video, requests a transcript, or wants to extract and reformat content from any YouTube video. Transforms transcripts into structured content (chapters, summaries, threads, blog posts).
|
||||
|
||||
Extract transcripts from YouTube videos and convert them into useful formats.
|
||||
|
||||
## Setup
|
||||
|
||||
Use `uv` so the dependency is installed into the same Hermes-managed environment
|
||||
that runs the helper script:
|
||||
|
||||
```bash
|
||||
uv pip install youtube-transcript-api
|
||||
```
|
||||
|
||||
## Helper Script
|
||||
|
||||
`SKILL_DIR` is the directory containing this SKILL.md file. The script accepts any standard YouTube URL format, short links (youtu.be), shorts, embeds, live links, or a raw 11-character video ID.
|
||||
|
||||
```bash
|
||||
# JSON output with metadata
|
||||
uv run python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID"
|
||||
|
||||
# Plain text (good for piping into further processing)
|
||||
uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only
|
||||
|
||||
# With timestamps
|
||||
uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps
|
||||
|
||||
# Specific language with fallback chain
|
||||
uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
After fetching the transcript, format it based on what the user asks for:
|
||||
|
||||
- **Chapters**: Group by topic shifts, output timestamped chapter list
|
||||
- **Summary**: Concise 5-10 sentence overview of the entire video
|
||||
- **Chapter summaries**: Chapters with a short paragraph summary for each
|
||||
- **Thread**: Twitter/X thread format — numbered posts, each under 280 chars
|
||||
- **Blog post**: Full article with title, sections, and key takeaways
|
||||
- **Quotes**: Notable quotes with timestamps
|
||||
|
||||
### Example — Chapters Output
|
||||
|
||||
```
|
||||
00:00 Introduction — host opens with the problem statement
|
||||
03:45 Background — prior work and why existing solutions fall short
|
||||
12:20 Core method — walkthrough of the proposed approach
|
||||
24:10 Results — benchmark comparisons and key takeaways
|
||||
31:55 Q&A — audience questions on scalability and next steps
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Fetch** the transcript using the helper script with `--text-only --timestamps` via `uv run python3`.
|
||||
2. **Validate**: confirm the output is non-empty and in the expected language. If empty, retry without `--language` to get any available transcript. If still empty, tell the user the video likely has transcripts disabled.
|
||||
3. **Chunk if needed**: if the transcript exceeds ~50K characters, split into overlapping chunks (~40K with 2K overlap) and summarize each chunk before merging.
|
||||
4. **Transform** into the requested output format. If the user did not specify a format, default to a summary.
|
||||
5. **Verify**: re-read the transformed output to check for coherence, correct timestamps, and completeness before presenting.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Transcript disabled**: tell the user; suggest they check if subtitles are available on the video page.
|
||||
- **Private/unavailable video**: relay the error and ask the user to verify the URL.
|
||||
- **No matching language**: retry without `--language` to fetch any available transcript, then note the actual language to the user.
|
||||
- **Dependency missing**: run `uv pip install youtube-transcript-api` and retry.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Output Format Examples
|
||||
|
||||
## Chapters
|
||||
|
||||
```
|
||||
00:00 Introduction
|
||||
02:15 Background and motivation
|
||||
05:30 Main approach
|
||||
12:45 Results and evaluation
|
||||
18:20 Limitations and future work
|
||||
21:00 Q&A
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
A 5-10 sentence overview covering the video's main points, key arguments, and conclusions. Written in third person, present tense.
|
||||
|
||||
## Chapter Summaries
|
||||
|
||||
```
|
||||
## 00:00 Introduction (2 min)
|
||||
The speaker introduces the topic of X and explains why it matters for Y.
|
||||
|
||||
## 02:15 Background (3 min)
|
||||
A review of prior work in the field, covering approaches A, B, and C.
|
||||
```
|
||||
|
||||
## Thread (Twitter/X)
|
||||
|
||||
```
|
||||
1/ Just watched an incredible talk on [topic]. Here are the key takeaways: 🧵
|
||||
|
||||
2/ First insight: [point]. This matters because [reason].
|
||||
|
||||
3/ The surprising part: [unexpected finding]. Most people assume [common belief], but the data shows otherwise.
|
||||
|
||||
4/ Practical takeaway: [actionable advice].
|
||||
|
||||
5/ Full video: [URL]
|
||||
```
|
||||
|
||||
## Blog Post
|
||||
|
||||
Full article with:
|
||||
- Title
|
||||
- Introduction paragraph
|
||||
- H2 sections for each major topic
|
||||
- Key quotes (with timestamps)
|
||||
- Conclusion / takeaways
|
||||
|
||||
## Quotes
|
||||
|
||||
```
|
||||
"The most important thing is not the model size, but the data quality." — 05:32
|
||||
"We found that scaling past 70B parameters gave diminishing returns." — 12:18
|
||||
```
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch a YouTube video transcript and output it as structured JSON.
|
||||
|
||||
Usage:
|
||||
uv run python3 fetch_transcript.py <url_or_video_id> [--language en,tr] [--timestamps]
|
||||
|
||||
Output (JSON):
|
||||
{
|
||||
"video_id": "...",
|
||||
"language": "en",
|
||||
"segments": [{"text": "...", "start": 0.0, "duration": 2.5}, ...],
|
||||
"full_text": "complete transcript as plain text",
|
||||
"timestamped_text": "00:00 first line\n00:05 second line\n..."
|
||||
}
|
||||
|
||||
Install dependency: uv pip install youtube-transcript-api
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def extract_video_id(url_or_id: str) -> str:
|
||||
"""Extract the 11-character video ID from various YouTube URL formats."""
|
||||
url_or_id = url_or_id.strip()
|
||||
patterns = [
|
||||
r'(?:v=|youtu\.be/|shorts/|embed/|live/)([a-zA-Z0-9_-]{11})',
|
||||
r'^([a-zA-Z0-9_-]{11})$',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, url_or_id)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return url_or_id
|
||||
|
||||
|
||||
def format_timestamp(seconds: float) -> str:
|
||||
"""Convert seconds to HH:MM:SS or MM:SS format."""
|
||||
total = int(seconds)
|
||||
h, remainder = divmod(total, 3600)
|
||||
m, s = divmod(remainder, 60)
|
||||
if h > 0:
|
||||
return f"{h}:{m:02d}:{s:02d}"
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def fetch_transcript(video_id: str, languages: list = None):
|
||||
"""Fetch transcript segments from YouTube.
|
||||
|
||||
Returns a list of dicts with 'text', 'start', and 'duration' keys.
|
||||
Compatible with youtube-transcript-api v1.x.
|
||||
"""
|
||||
try:
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
except ImportError:
|
||||
print("Error: youtube-transcript-api not installed. Run: uv pip install youtube-transcript-api",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
api = YouTubeTranscriptApi()
|
||||
if languages:
|
||||
result = api.fetch(video_id, languages=languages)
|
||||
else:
|
||||
result = api.fetch(video_id)
|
||||
|
||||
# v1.x returns FetchedTranscriptSnippet objects; normalize to dicts
|
||||
return [
|
||||
{"text": seg.text, "start": seg.start, "duration": seg.duration}
|
||||
for seg in result
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fetch YouTube transcript as JSON")
|
||||
parser.add_argument("url", help="YouTube URL or video ID")
|
||||
parser.add_argument("--language", "-l", default=None,
|
||||
help="Comma-separated language codes (e.g. en,tr). Default: auto")
|
||||
parser.add_argument("--timestamps", "-t", action="store_true",
|
||||
help="Include timestamped text in output")
|
||||
parser.add_argument("--text-only", action="store_true",
|
||||
help="Output plain text instead of JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
video_id = extract_video_id(args.url)
|
||||
languages = [l.strip() for l in args.language.split(",")] if args.language else None
|
||||
|
||||
try:
|
||||
segments = fetch_transcript(video_id, languages)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "disabled" in error_msg.lower():
|
||||
print(json.dumps({"error": "Transcripts are disabled for this video."}))
|
||||
elif "no transcript" in error_msg.lower():
|
||||
print(json.dumps({"error": "No transcript found. Try specifying a language with --language."}))
|
||||
else:
|
||||
print(json.dumps({"error": error_msg}))
|
||||
sys.exit(1)
|
||||
|
||||
full_text = " ".join(seg["text"] for seg in segments)
|
||||
timestamped = "\n".join(
|
||||
f"{format_timestamp(seg['start'])} {seg['text']}" for seg in segments
|
||||
)
|
||||
|
||||
if args.text_only:
|
||||
print(timestamped if args.timestamps else full_text)
|
||||
return
|
||||
|
||||
result = {
|
||||
"video_id": video_id,
|
||||
"segment_count": len(segments),
|
||||
"duration": format_timestamp(segments[-1]["start"] + segments[-1]["duration"]) if segments else "0:00",
|
||||
"full_text": full_text,
|
||||
}
|
||||
if args.timestamps:
|
||||
result["timestamped_text"] = timestamped
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user