initial commit

This commit is contained in:
ray
2026-07-12 10:17:17 -04:00
commit dab5a4ebc6
1424 changed files with 330463 additions and 0 deletions
+3
View File
@@ -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])
+91
View File
@@ -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: `![alt](url)`
+171
View File
@@ -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}")
+83
View File
@@ -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
+135
View File
@@ -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.
+76
View File
@@ -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()