Callboard
Rehearsal tracks for a cast π
by Joe Fusco Β· github.com/promptcorner/callboard Β· website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/promptcorner/callboard/archive/refs/heads/main.zip
Callboard
An open-source music player for WordPress. Publish an owned, app-like listening experience with persistent playback, offline sets, waveform seeking, lyrics, and lock-screen controls. Rehearsal workflows for casts and choirs are built in, not bolted on.
Landing page and live demo Β· Open in WordPress Playground Β· Latest release
![]() The board |
![]() A set |
![]() Posting a call |
What it does
- Owned music player. WordPress manages the library while Callboard replaces the theme on the front end with a focused listening app. Share one URL; listeners do not need an account by default.
- Board. The home page shows the next call: time, place, note, and the numbers being worked. Each number is a tap that starts the track. A call is a post under Sets. Publishing one sends a push notification.
- Sets. A set is a post; its tracks are audio attachments. Add audio through the Media Library, fetch a playlist with WP-CLI, or import a folder.
- Player. A bar at the foot of every page with the artwork, what is playing, and previous, play, next. Tap it and Now Playing fills the screen: the cover, the waveform, elapsed and remaining, and what the copy actually is. Waveform scrubbing, an A/B loop (two fingers on the wave, or the bracket keys), count-in, lyrics and director's notes in time with the track, AirPlay, and lock-screen controls. The tab title carries the track as well, for whoever has the board open behind a rehearsal PDF.
- Offline. Save a set once. It plays from the phone with no connection, or load it from a file when there is no signal to save it with.
- Settings. Site name, accent colour, badge, confetti behind a triple tap on the title. Hooks and template overrides for developers.
Getting started
- Install
callboard.zipfrom the latest release. WordPress 6.5+, PHP 8.1+. - Add a set. With
yt-dlpandffmpeginstalled where WP-CLI runs:wp callboard fetch '<playlist url>' --name="Spring Show". Otherwise put audio files and amanifest.jsoninwp-content/uploads/callboard/<slug>/and use Sets β Import.wp callboard doctorreports what is available. - Post a call under Sets β Calls. Publish it or schedule it.
- Share the home page URL. On iPhone, the cast adds it to the Home Screen. The bell turns on notifications where the host supports Web Push (PHP with OpenSSL and GMP or BCMath).
- Adjust Sets β Settings.
Contents
How it works
Rendering and navigationRouter handles two routes: the home page and /<set-slug>/. Unknown paths show the home page with a note. Frontend renders from templates/ on template_redirect and loads only the plugin's stylesheet and script, core's wp-hooks underneath it, and the assets of registered extensions. The theme is not used.
Navigation is client-side. The script fetches the target URL with ?fragment=1, which returns the view without the page shell, and swaps it into <main> inside a view transition. PHP is the only renderer. Fetching starts on the first touch of a link. The home fragment is prefetched when idle.
Elements that change state after load (save marks, header buttons, the AirPlay picker) are rendered in place and hidden, so nothing shifts when the script runs.
No build stepassets/app.js is one IIFE, assets/app.css is one file. No bundler, framework, or transpiler. The plugin has to keep working on sites nobody maintains, and a build chain is the first thing to break. Every browser feature is behind a check; CSS degrades through @supports and media queries. Pwa::write_files() writes the service worker and manifest to the site root, since a worker only controls the scope it is served from. On a multisite network the sites share that folder, so each site saves both in its own options and serves them from its home URL (/?callboard_file=sw.js).
Design rules: animate only transform and opacity, never use font weight for state, prefer native controls, one set of colour tokens for both schemes.
Data model| Where | What |
|---|---|
callboard_call post |
Title and body. _callboard_when (site time zone, empty for a notice with no time), _callboard_where, _callboard_numbers (attachment ids). Leaves the board six hours after its time |
callboard_set post |
Name, slug, order, credits, source link, share image |
| Audio attachment | A track. menu_order is its position. Quality shown to listeners β bit depth and sample rate for lossless, bitrate and sample rate for lossy β reads WordPress's own attachment metadata, not a callboard field; lossy formats show no bit depth, since MP3 does not have one |
_callboard_duration |
Seconds |
_callboard_levels |
Loudness envelope, digits 0β9, ten per second. Drives the waveform and the filament, which auto-ranges to its own recent peak β rising at once, forgotten over ~6s β so a quiet reading lights it as fully as a loud mix. iPhones cannot analyse audio live |
_callboard_lyrics, _callboard_lyrics_approved |
Timed lines from captions, shown after approval |
callboard_note comments on the track |
Director's notes. Text is the comment body, the time into the track is _callboard_at comment meta, and author and date come from the comment. Legacy _callboard_notes post meta is still read as a fallback; wp callboard migrate-notes turns those arrays into comments |
_callboard_bpm |
Tempo, for the count-in |
_callboard_practice |
Anonymous practice counts per hour, written only while Count practice is on |
_callboard_video_id, _callboard_source_url, _callboard_uploader |
Source. The uploader is also sent per track as artist β right for one playlist by one uploader, wrong for a set where every track differs |
_callboard_codec, _callboard_reencoded |
What a fetch actually got (e.g. aac) and whether ffmpeg had to re-encode to get it β provenance, not a measurement of the file itself |
Sets builds the data the front end renders, cached in a transient for a day and cleared on save.
- Fetch.
Fetcherrunsyt-dlp(andffmpegwhen it needs to) on a YouTube URL, playlist, or search and writeswp-content/uploads/callboard/<slug>/: the audio, amanifest.json, and sidecars keyed by video id (levels.json,lyrics.json,notes.json,tempo.json). It keeps the native m4a/AAC stream YouTube already serves rather than re-encoding it β re-encoding a lossy source is a pure loss, and can even inflate the bitrate while making it sound worse β and only asksffmpegto convert when a video truly offers no AAC audio, preferring m4a there too and falling back to mp3 only if the ffmpeg build cannot encode AAC at all. A search (yt-dlp'sytsearch:syntax) is narrowed to one candidate first: an auto-generated "Topic" upload, then a channel YouTube has verified, skipping obvious live versions, covers and remixes where another copy exists. Binary paths are filterable. - Import.
Importerreads that folder into posts. The folder is only an input; posts are the source of truth. Re-importing refreshes order, credits, and lyrics but keeps titles edited in the admin.
Hosts that cannot run binaries: build the folder on a laptop with the same command, upload it, import. Requests is an admin queue of URLs; wp callboard run drains it where the tools exist. Because Requests only accepts a real YouTube URL, a search query is a WP-CLI-only way in for now.
-
The file.
Exporterwrites that same folder as a single.callboardfile, and reads one back. The manifest carries aversion; a reader refuses a file newer than it understands. Unzipped it is an import folder, soImporterreads it without knowing it was ever a file, and somebody with no Callboard still has playable audio in the right order with a cover. Entries are extracted by name, never withextractTo(), since a set is a flat folder and anything carrying a path separator is not ours to write. -
The stick.
--format=carwrites a plain folder instead:01 Title.mp3, in order, each tagged so it names itself on a dash, with the cover embedded and beside them asfolder.jpg.Id3writes ID3v2.3 by hand β WordPress bundles getID3's reading modules but not its writing ones, and five frames are not worth a dependency. Latin-1 where the text fits it, UTF-16 where it does not, and JPEG artwork because head units read it far more reliably than PNG.
The service worker precaches the shell and the home fragment. Saving a set streams each track into the Cache API and fetches the set's fragment. The worker serves cached audio and answers Range requests, so seeking works offline. Saved sets are re-fetched on the home screen after an update because the shell cache is versioned. The Cache API is used instead of IndexedDB or OPFS because it serves whole files with Range support, which is what the media element needs.
Load from files fills the same cache without the network, for a phone on bad signal standing next to somebody who has the audio on a stick. Files are matched to tracks by the track's own file name, then by a leading number, which is how a car-format export is named, then by title; anything unmatched is reported rather than guessed at. A sideloaded copy carries a marker, because it can be a different size from the server's β a car export has ID3 tags the original does not β and without it the next check would call it stale and download it again.
Save set file appears on a set once every track is saved offline. It writes a .callboard file in the browser from those saved copies, in the same format wp-admin exports, and hands it to the share sheet, where Save to Files can put it on a USB drive. Where the share sheet won't take a zip, as on Android, the file downloads instead. Open a set file on home, or a .callboard file picked in Load from files, reads one back into the offline copy of the set it describes, with no network. That's how a set gets from one phone to another on a stick.
Push stores subscriptions through a REST route and sends with web-push-php. Payloads use declarative Web Push so Safari shows them without waking the worker. A pushsubscriptionchange handler re-subscribes when a browser rotates an endpoint. VAPID keys are generated with OpenSSL and stored in an option.
Privacy adds noindex via wp_robots and X-Robots-Tag, disallows everything in robots.txt, sets Referrer-Policy: no-referrer, requires authentication for REST except the push routes, hides the users endpoint, disables XML-RPC and feeds, and redirects author archives and search to home.
Gate is the one decision about who may see the front end, and it is off by default: a link in a group chat is the whole setup, and that is the point. Turn on Require a WordPress sign-in and the answer becomes is_user_logged_in() and nothing else, so whatever sign-in the site already has guards the app too β Apple, Google, a membership plugin, passkeys through Two Factor and its WebAuthn provider. Core still ships no passkeys of its own. Also turn on Only let in users with access to Callboard and a signed-in user needs the view_callboard capability too. callboard_can_view overrides the settings; return null and the filter never has to know what they say.
Roles adds two roles. Director can post calls, edit playlists and their track notes, and send notifications from Notices. Cast member can only open the front end. Each post type has its own capabilities (edit_callboard_calls, edit_callboard_playlists, and so on), and on activation or update every other role gets the ones that match the post capabilities it already has. Administrators, editors, authors and contributors keep what they could do before. Deleting the plugin removes the roles and capabilities.
Count practice is off by default. When it is on, the page counts how many times each track is opened, how many loops are set on it and how many seconds it plays, and sends those totals to the site. They are stored on the track, grouped by hour, and shown on each call in the editor. No user id, name, IP address or cookie is stored with them.
A gated request answers 403 with templates/gate.php rather than redirecting to wp-login.php, which would drop the cast out of an installed app and into WordPress branding mid-session. Nothing about the sets escapes it: the script and its data are not enqueued, link previews are suppressed, and the document title falls back to the site name. Audio files keep their own upload addresses either way, so the gate guards the app, not the media.
Art draws covers and native wide share cards with GD from the set's name, track count, and available curator or performer credit. A deterministic palette and modular layout keep generated art distinct without network assets or a build step.
| Path | Purpose |
|---|---|
callboard.php |
Plugin header, constants, autoload |
uninstall.php |
Removes the roles and capabilities when the plugin is deleted |
includes/ |
One class per concern: Plugin, Router, Frontend, Sets, Calls, Post_Types, Admin, Settings, Importer, Exporter, Id3, Fetcher, Requests, Push, Pwa, Privacy, Gate, Roles, Art, Cli. helpers.php has icons and formatting |
templates/ |
index.php (shell), fragment.php, home.php, board.php, set.php, deck.php (player), gate.php, footer.php |
assets/ |
app.js, app.css |
pwa/sw.js |
Service worker source |
tests/e2e/ |
Playwright suites |
tests/fixtures/ |
demo-set and empty-set, and jazz.js, which builds the public-domain Airmen of Note demo. local-set.js builds a set from your own audio into a gitignored folder, for local listening only |
blueprint.json |
Playground demo. The Pages workflow publishes it with the plugin zip and demo audio, stamped by commit |
site/ |
Landing page (GitHub Pages) |
languages/callboard.pot |
Translation template; npm run pot regenerates it |
scripts/sync-versions.sh |
Writes the release version into the plugin files |
scripts/screenshots.js |
Retakes the landing-page screenshots from a running wp-env site, at the exact sizes the page expects. node scripts/screenshots.js demo retakes only the live demo previews |
scripts/wporg-screenshots.js |
Regenerates the wordpress.org screenshots in .wordpress-org/ from a running wp-env site, using scripts/wporg/template.html. .wordpress-org/screenshots.json sets each screenshot's page, headline, and readme.txt caption. tests/e2e/wporg-screenshots.spec.js checks the captions and image sizes match |
.github/ |
Workflows, Dependabot, PR template, CONTRIBUTING |
AGENTS.md |
Notes for coding agents |
Developer API
Features are extensions: an id, a version, and named contribution points shared between PHP and the page. Callboard's own count-in, quality readout and Home Screen badge are built that way, so a plugin can switch one off, replace it, or add its own beside them. Extending Callboard is the contract: the points, the lifecycle, events, state and commands, escaping, and what version 1 promises.
PHP| Function or hook | Kind | What it does |
|---|---|---|
callboard_register_extension( $id, $args ) |
function | Register an extension on callboard_register_extensions. callboard/* and some other namespaces are reserved |
callboard_unregister_extension( $id ) |
function | Remove one, Callboard's own included |
callboard_get_extension( $id ), callboard_get_extensions() |
functions | Read the registry |
callboard_slot( $slot, ...$context ), callboard_get_slot() |
functions | Print or return a slot, for a replacement template |
callboard_rest_can_view() |
function | Whether this REST request may see what the front end shows |
callboard_get_setting( $key ) |
function | One of Callboard's settings |
CALLBOARD_API_VERSION |
constant | The extension contract's version: 1 |
callboard_register_extensions |
action | Register, unregister or replace extensions here |
callboard_extension_enabled |
filter | Whether an extension runs on this request |
callboard_slot_allowed_html |
filter | The markup an HTML slot accepts |
callboard_can_view |
filter | Whether this visitor may see the front end. Return null for the default, true or false to decide |
callboard_head |
action | Output in the <head> of every front-end page |
callboard_template_path |
filter | Replace any template with your own file |
callboard_app_data |
filter | Data the script receives on load. Extension app_data runs inside it at priority 5 |
callboard_set_data |
filter | One set's data. Extension track_data and set_data run inside it at priority 5 |
callboard_board |
filter | The calls shown on the board |
callboard_push_message |
filter | Title, body, and URL of a notification. Return an empty array to cancel |
callboard_call_published |
action | A call was published |
callboard_imported |
action | A set folder was imported |
callboard_import_page |
action | Add your own controls to the bottom of the admin import screen |
callboard_import_dir, callboard_ytdlp_path, callboard_ffmpeg_path, callboard_max_subscribers |
filters | Import folder, tool paths, subscriber limit |
| Name | Kind | What it does |
|---|---|---|
callboard.apiVersion |
number | The extension contract's version: 1 |
callboard.registerExtension( id, args ) |
function | Register the page half of an extension PHP registered |
callboard.unregisterExtension( id ) |
function | Remove its client contributions |
callboard.state |
object | The view, the playlist and track in the player bar, position, duration, paused, loop, online |
callboard.commands |
object | play, pause, seek, next, prev, goTo, display |
callboard.registerCommand( name, fn ) |
function | Add a command named namespace/name/command for a registered extension |
callboard.run( name, ...args ) |
function | Run a command with arguments and return what it returns |
callboard.data( id ), callboard.emit( name, detail ), callboard.invalidate( ...points ) |
functions | An extension's app data, its own events, re-rendering |
callboard.deprecated( name, { since, alternative, hint } ) |
function | Log a deprecation warning once per name, in the shape of @wordpress/deprecated |
callboard.ready, .view, .viewTeardown, .track, .play, .pause, .ended, .seek, .loop, .save, .unsave, .online, .offline |
wp.hooks actions |
Also fired as callboard:<event> on document, which is how callboard:track and callboard:view have always arrived |
callboard.slot.trackBadges, .slot.trackMeta, .slot.nowPlayingMeta, callboard.badge, callboard.beforePlay |
wp.hooks filters |
The contribution points underneath the registry |
On WordPress 6.9 and later, Callboard registers three abilities with the Abilities API, in the callboard category. Other plugins and AI tools can run them with wp_get_ability( $name )->execute( $input ) or through the wp-abilities/v1 REST routes. Each one checks the same capability as the matching wp-admin screen. On older WordPress versions nothing is registered.
| Ability | Capability | What it does |
|---|---|---|
callboard/list-playlists |
edit_callboard_playlists |
Lists published playlists with each track's id, position and title |
callboard/list-upcoming-calls |
edit_callboard_calls |
Lists the calls shown on the board: upcoming calls soonest first, then notices without a time |
callboard/post-call |
publish_callboard_calls |
Publishes a call from a title and optional note, when (YYYY-MM-DDTHH:MM in the site's time zone), where and numbers (track ids). The cast gets a notification if call notifications are on |
WP-CLI
| Command | Does |
|---|---|
wp callboard fetch <url> --name=<name> [--slug=<slug>] |
Fetch a YouTube video, playlist, or ytsearch: query into a set |
wp callboard run [--interval=<seconds>] |
Process the admin's fetch queue |
wp callboard import [--file=<path>] |
Import every folder under uploads/callboard/, or one .callboard file |
wp callboard export <slug> [--format=<file\|car>] [--out=<path>] |
Write a set out as a .callboard file, or as a folder of tagged mp3s for a car |
wp callboard levels <slug> |
Measure loudness for a set that has none |
wp callboard notify <message> [--title=<title>] [--url=<url>] |
Send a push notification |
wp callboard doctor |
Check for yt-dlp, ffmpeg, and PHP extensions |
Web APIs used
Links go to the specifications.
Web platform- Service Workers with navigation preload, Cache API, Fetch with Range requests
- Web App Manifest, display-mode, beforeinstallprompt, Apple web app meta tags
- Push API with declarative payloads and
pushsubscriptionchange, Notifications, Badging - HTML media element, Media Session, Audio Session, Remote Playback, TextTrack for lyric cues
- Web Audio for the level meter, count-in, and sample-accurate loop
- Web Locks so one tab plays at a time, Screen Wake Lock while a loop is set or the lyrics sheet is open
- Storage estimate and persist, Streams with
tee()for save progress, AbortController, Web Storage - Web Share for the set's link with a Clipboard fallback, and for the track's own file, which reaches AirDrop and "Save to Files", Vibration, WebKit switch control for iPhone haptics
- Canvas 2D for the waveform, View Transitions, History API, Pointer Events, ResizeObserver, requestIdleCallback, online/offline events, back/forward cache via
pageshow
- Safe-area env() on all four sides, Dynamic Type via
font: -apple-system-body - Container queries, anchor positioning, @starting-style, scroll-driven animations, text-box-trim, all behind
@supportswhere needed - backdrop-filter, color-mix(), mask-image, :has(), touch-action, overscroll-behavior, text-wrap: pretty
- User preference media queries: colour scheme, reduced motion, reduced transparency, contrast, forced colours
- Custom post types, meta boxes, Settings API, Transients, Filesystem API, nonces, admin-post actions
- REST API for push subscriptions, WP-CLI, template_redirect, wp_robots
- GD for artwork, proc_open for the fetch tools, OpenSSL with GMP or BCMath for VAPID keys, web-push-php
- yt-dlp, ffmpeg, Playground blueprints
Watchlist
Browser and WordPress features considered for this plugin, with a verdict, so nobody has to evaluate them twice.
Would add- WebAuthn passkeys to gate the front end. Today anyone with the URL can open the site. Passkeys give one-tap sign-in with no third party. OAuth and Sign in with Apple were considered and set aside; the Presence API answers who is present, not who may enter.
- WebRTC data channels for live cues from the director to every phone, with WordPress only as the signaling server.
- AudioWorklet for transposition without tempo change. The most requested feature and the largest.
- Media Capabilities to choose between mp3 and m4a per device.
- Navigation API to replace
pushStaterouting. Safari 18.4+. - light-dark(), scroll snap, contrast-color().
- Background Fetch, Web Share Target, Periodic Background Sync, Storage Buckets: Chromium only.
- Manifest shortcuts are written; iOS ignores them.
- Remote Playback availability for audio in Safari. Without it the picker is always shown there.
- Invoker commands: Chrome 135+, Safari 26.
- Anchor positioning fallback can go when Safari 26 is the minimum.
- Interactivity API: only if the admin grows beyond forms.
- Script Modules: once the service worker precache can handle module URLs.
- HTML API: if the theme is ever allowed to add markup.
- Abilities API: to expose import and notifications to other plugins.
- Presence API: for a sign-in sheet. Needs the gated front end first.
- Popover API for the lyrics sheet: the top layer would cover the player.
- Vibration API on iPhone: not implemented; the switch control is used instead.
- wavesurfer.js and peaks.js: they decode audio in the browser, which iPhone Safari cannot do for long files. Levels are measured at import instead.
- IndexedDB or OPFS for audio: the Cache API already serves files with Range support.
- Web Bluetooth and Web MIDI for foot pedals: Chromium only, and Bluetooth pedals already arrive as media keys.
- Declarative push
app_badgeas an unread count: would need per-subscriber state on the server.
Tests
Two suites, both against wp-env. PHPUnit in tests/php/ covers the PHP, leading with the two boundaries that matter: what a .callboard file is allowed to unpack, and who the gate lets through. After those come the data model, settings sanitizing, and the helpers behind every line of text a cast reads. Playwright in tests/e2e/ covers the browser. The pre-commit hook that npm install sets up runs a fast smoke pass, and GitHub Actions runs the full suite on every pull request. One test saves a set, takes the browser offline, and opens and plays it. Headless Chromium cannot decode mp3, so player tests assert on state, not audio.
The demo fixture is Compositions (2012), ten jazz recordings performed by the Airmen of Note, United States Air Force Band. tests/fixtures/jazz.js downloads the source recordings from Wikimedia Commons, cuts compact 40-second excerpts, encodes them, and writes manifest.json, levels.json, notes.json, and tempo.json (it needs ffmpeg, ffprobe, and a network connection). The Air Force and Commons identify each composition, performance, and recording as a public-domain U.S. Government work; the manifest retains the composer, performer, source page, and rights evidence. The source files are public domain in the United States rather than GPL-licensed; status outside the United States may vary. Two fixture sets remain: demo-set and empty-set. Covers are drawn by the plugin's Art class.
Source and maintainer
Source, issues, and releases live on GitHub. Made by Joe Fusco. Security reports go through private vulnerability reporting.
Releases
Pull request titles follow Conventional Commits. release-please creates the version, changelog, and GitHub Release with callboard.zip attached. See CONTRIBUTING.md for the rest of the automation.
GPL-2.0-or-later.


