Skip to content

program247365/looper

Repository files navigation

Looper

A CLI audio looper with a real-time FFT visualizer, startup screen, default history browser, favorites, fullscreen mode, and online URL support.

Looper fullscreen visualizer

What It Does

looper plays audio in a terminal UI built with ratatui.

It supports:

  • local audio files
  • YouTube URLs
  • SoundCloud URLs
  • HypeM URLs
  • single tracks and playlists
  • infinite looping for single tracks
  • whole-playlist looping for playlists
  • pause / resume
  • fullscreen visualizer
  • centered ASCII startup/loading screen with cheeky boot logs
  • default no-arg startup into playlist history
  • SQLite-backed playback history and favorites
  • remote download/loading UI with progress, speed, and ETA
  • small source badges in the TUI for supported services (YT, SC, HM)
  • animated terminal/tab title with playback, pause, and loading status

Install

Homebrew (Apple Silicon)

Fresh install:

brew tap program247365/tap
brew install looper

Upgrade an existing install:

brew update
brew upgrade program247365/tap/looper

The Homebrew formula ships a prebuilt binary for aarch64-apple-darwin, so install and upgrade are a small download and a file move — no compile step on your machine. ffmpeg and yt-dlp are pulled in automatically as runtime dependencies.

Intel macOS users: there is no prebuilt binary. See "Build from source" below or use brew install --HEAD program247365/tap/looper to compile from main.

Build from source

git clone https://github.com/program247365/looper.git
cd looper
make install

Requires Rust. Install via rustup if needed.

For remote URL playback (YouTube, SoundCloud, HypeM), also install yt-dlp and ffmpeg:

brew install yt-dlp ffmpeg

If YouTube playback starts failing with 403 errors, update yt-dlp first.

Usage

Default startup

looper

This opens the playlist history browser with no active playback. Press Enter on a row to start playing it.

If you want to skip the browser and jump straight into playback, use looper play --url ....

Local file

looper play --url "/path/to/your/song.mp3"

YouTube

looper play --url "https://www.youtube.com/watch?v=xAR6N9N8e6U"

SoundCloud

looper play --url "https://soundcloud.com/odesza/line-of-sight-feat-wynne-mansionair"

HypeM

looper play --url "https://hypem.com/track/2gq0d/CHVRCHES+-+Clearest+Blue"

Playlists

looper play --url "https://www.youtube.com/playlist?list=PLFgquLnL59alCl_2TQvOiD5Vgm1hCaGSI"

How Remote Playback Works

  • startup opens the local SQLite database, runs embedded migrations, and then begins loading playback
  • yt-dlp extracts track metadata and media URLs
  • remote audio is cached locally (see Data and Cache Locations)
  • uncached remote tracks show a full-screen loading scene before playback
  • single tracks loop forever
  • playlists play each track once, then loop the entire playlist
  • background prefetch caches upcoming playlist tracks when possible

Current behavior is intentionally pragmatic:

  • YouTube uses a download-first cached path for reliability
  • SoundCloud and HypeM prefer a stream-first path and fall back to cached download when needed

Data and Cache Locations

Remote tracks are cached locally after download:

Platform Cache directory
macOS ~/Library/Caches/sh.kbr.looper/
Linux ~/.cache/looper/

Playback history and favorites live in a SQLite database (looper.sqlite3). Where it lives depends on your sync setup — see Cross-Device Sync below.

  • startup applies pending embedded migrations automatically — no manual steps needed when upgrading
  • bare looper loads this history first and lets you replay from it
  • history is tracked per playable URL or canonical local file path
  • each track stores title, platform, favorite state, last played timestamp, play count, cumulative time played, and which computer played it last

Cross-Device Sync

looper always reads and writes a fast local copy of looper.sqlite3. If you point it at a cloud folder (iCloud Drive, Dropbox, anything that syncs files), looper will pull from that folder at startup and push to it on quit. The cloud folder never holds the live database — it's just a passive copy that the cloud provider replicates between your machines on its own.

This avoids the long-standing footguns of running SQLite directly on a cloud-synced filesystem (corrupted WAL/SHM sidecars, surprise permission denials, evicted files).

Where the live database lives

Platform Live database path
macOS ~/Library/Application Support/sh.kbr.looper/looper.sqlite3
Linux ~/.local/share/looper/looper.sqlite3

By default no replication runs. Looper just uses the local path above.

Replicate via a cloud folder

Point looper at any folder your cloud provider keeps in sync:

looper config set sync-folder "$HOME/Library/Mobile Documents/com~apple~CloudDocs/looper"
# Replication folder set to: ...
# looper will pull from this folder at startup and push to it on quit.
# The live DB stays at the platform data directory.

Run this once on each computer that should share history. The cloud provider takes care of moving looper.sqlite3 between machines in the background.

Verify it's working:

looper config show
# sync_folder = /Users/you/Library/Mobile Documents/.../looper (replicated on startup/quit)

ls "$HOME/Library/Mobile Documents/com~apple~CloudDocs/looper/"
# looper.sqlite3

macOS: iCloud Drive needs Files-and-Folders permission

The first time looper tries to read or write inside ~/Library/Mobile Documents/..., macOS will silently deny access until you grant the terminal app that launches looper permission. Open System Settings → Privacy & Security → Files and Folders (or Full Disk Access) and toggle on iCloud Drive for your terminal (Terminal, iTerm, Ghostty, etc.). Restart the terminal so the new entitlement takes effect.

If permission isn't granted, looper still runs against the local DB, surfaces a History sync disabled banner at startup, and prints a one-line warning to stderr on quit. Nothing crashes.

Sync semantics: last-quitter wins

Replication is a file copy in both directions:

  • At startup: if the cloud copy has a more recent MAX(last_played_at) than the local copy, looper replaces local with the cloud copy.
  • At quit: looper checkpoints the WAL, then atomically replaces the cloud copy with the local one.

This is enough for one-human-at-a-time use across multiple Macs (typical single-user setup). It is not a general-purpose multi-master merge: if you play on two machines simultaneously, whichever quits last overwrites the other's session, and your cloud provider may produce conflict copies (e.g. looper.sqlite3 conflicted-copy 2). Resolve by closing one, picking the version you want to keep, and deleting the rest.

Disable replication

rm "$HOME/.config/looper/sync_folder"

Or just don't set it. Looper falls back to local-only without complaint.

Keys

Key Action
Enter Replay the selected track from the default history browser
Space Pause / Resume
f Toggle fullscreen visualizer
s Toggle favorite for the currently playing track
p Toggle the played-songs panel
Cmd-P Attempt to toggle the played-songs panel when the terminal forwards the modifier
q / Ctrl-C Quit

Played-Songs Panel

Bare looper opens directly into playlist history. During playback, the played-songs panel is hidden by default and opens over the minimal UI.

Key Action
j / k Move selection down / up
h / l Change sort field
r Reverse sort direction
s Toggle favorite for the selected row
Enter Replay the selected track
p / Esc Close the panel

Sort fields:

  • time played
  • last played
  • platform
  • title
  • times played

Development

make run           # play fixture file (tests/fixtures/sound.mp3)
make test          # run tests
make build         # debug build
make build-release # optimized release binary

Useful direct commands:

cargo build
cargo build --release
cargo test

Notes

  • Public online URLs work best. Private, age-restricted, or members-only content may still fail depending on yt-dlp access.
  • If a YouTube watch URL includes both v= and list=, looper currently normalizes it toward single-video playback unless you use the playlist URL directly.
  • The remote loading UI is designed to hand off into playback cleanly rather than waiting on a full silent download.
  • The startup screen and loading copy are intentionally a little cheeky.

Releasing

make release-patch    # bump patch version (0.5.x → 0.5.x+1) and release
make release-minor    # bump minor version (0.5.x → 0.6.0) and release
make smoke-test       # (optional) verify the published formula installs cleanly

make release-patch / make release-minor runs end-to-end:

  1. Bumps the version in Cargo.toml and commits it
  2. Tags v<version> and pushes the tag
  3. The Release GitHub Actions workflow (.github/workflows/release.yml) fires on the tag, builds an aarch64-apple-darwin binary on a macos-14 runner, and attaches it to the GitHub release
  4. make bump-formula (auto-invoked) polls the release, computes the SHA256, regenerates the Homebrew formula via scripts/render-formula.sh, and pushes the update to program247365/homebrew-tap

Total wall-clock time is typically 3–4 minutes (most of it the arm64 cargo build on CI).

make smoke-test then reinstalls the formula on your machine and asserts:

  • the formula uses the prebuilt-binary install path (bin.install "looper")
  • the tap version matches Cargo.toml
  • looper --help runs successfully

If you need to recover from a partial release (e.g. CI flaked between tag push and formula update), re-run make bump-formula directly — it is idempotent and will wait for the asset, then push to the tap.

About

A simple Rust CLI program to play music files on loop.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors