Plugin API

QUP Karaoke Engine — Plugin API

For developers. If you are looking for how to turn plugins on and off, see Plugin Manager instead.

Every plugin QUP ships — SongShop, OBS Recording, Discord Party, MIDI Karaoke, AutoFiller and the rest — is built against this same public API. Nothing in the engine is reserved for first-party code.

Package: com.qupkaraoke.plugin, distributed as qup-plugin-api.jar. Current as of v2.18.1.


The shortest possible plugin

package com.example.myplugin;

import com.qupkaraoke.plugin.*;
import javax.swing.JPanel;

public class MyPlugin implements QUPPlugin {
    private PluginContext ctx;

    public String getId()          { return "com.example.myplugin"; }
    public String getName()        { return "My Plugin"; }
    public String getVersion()     { return "1.0.0"; }
    public String getAuthor()      { return "Me"; }
    public String getDescription() { return "Does a thing."; }

    public void init(PluginContext context) {
        this.ctx = context;
        ctx.getLogger().info("hello from My Plugin");
    }

    public JPanel getConfigPanel() { return null; }   // no settings UI

    public void onConfigPanelClosed() { /* persist settings here */ }

    public void shutdown() { /* release resources here */ }
}

Package it as a JAR containing a file at:

META-INF/services/com.qupkaraoke.plugin.QUPPlugin

whose single line is your fully-qualified class name. Drop the JAR in QUP's plugins/ folder and restart.

That service file is always com.qupkaraoke.plugin.QUPPlugin, even when you implement one of the specialized interfaces below. QUP discovers everything as a QUPPlugin and then sorts by type.

Third-party libraries your plugin needs go in plugins/lib/. Resolution is parent-first, so a library the engine already ships always wins over a copy you place there. You can also compile against engine classes — the shipped plugins use the engine's own widgets (Knob, VuMeter, SkinTheme) so their panels match the app.

Starting QUP with --safe-mode skips all plugin loading for that session without changing which plugins are enabled.


Lifecycle

QUPPlugin is the interface every plugin implements. QUP calls, in order:

  1. init(PluginContext) — you receive your API surface
  2. getConfigPanel() — your settings panel is registered in the config tree under Plugins
  3. onConfigPanelClosed() — every time the KJ closes the config window, and again just before shutdown
  4. shutdown() — QUP is exiting
Method Notes
String getId() Unique, reverse-DNS. Also names your data folder.
String getName() Shown in the config tree.
String getVersion()
String getAuthor()
String getDescription() One line.
void init(PluginContext context) The context is the only supported way to talk to QUP.
JPanel getConfigPanel() Return null for no settings UI.
void onConfigPanelClosed() default no-op. v2.5.0
void shutdown()

Save settings in onConfigPanelClosed(), not shutdown()

This is the single most important convention in the API.

A karaoke show runs for hours. If you only persist on shutdown, a crash or a forced quit loses everything the KJ changed since launch. Worse, QUP abandons any plugin that takes longer than 3 seconds to shut down so a stuck plugin cannot freeze the app on exit — which can kill a save queued behind slow teardown.

onConfigPanelClosed() runs on the EDT while the window is closing, so keep it cheap and never block. It is a default no-op, so plugins written before v2.5.0 are unaffected. An exception thrown here is caught and logged and cannot stop other plugins from saving.

Your config panel must be light-themed

QUP's config panel is always light regardless of the app skin, and QUP does not enforce this for you. Use white backgrounds, black text and blue accents, or your panel will look broken next to everything else.


Where your files live

Location
Your JAR APP_HOME/plugins/ — read-only on installed builds
Third-party JARs APP_HOME/plugins/lib/ — read-only
Your data folder DATA_HOME/plugins/data/{yourPluginId}/
Your private database DATA_HOME/plugins/data/{yourPluginId}/plugin.db

DATA_HOME is the per-user data directory — %LOCALAPPDATA%\QUPKaraoke on Windows, ~/Library/Application Support/QUPKaraoke on macOS, $XDG_DATA_HOME/QUPKaraoke on Linux. In dev and portable builds it is the same folder as the install. Never write next to your JAR; on an installed build that location is read-only.

getPluginDatabase() gives you a SQLite connection with WAL enabled. It is yours entirely — create whatever tables you like. QUP never reads from it.


PluginContext

The whole API surface. Every method is safe to call from any thread, and callbacks come back on the Swing EDT.

Library and import

boolean importSong(SongImport song)
boolean isSongInLibrary(String externalId)
String  getSongHashByExternalId(String externalId)   // null if not found
List<SongInfo> queryAllSongs(boolean fillerOnly)     // never null
boolean removeSongByHash(String songHash)
boolean removeFillerTrack(String filePath)
boolean reimportSong(String filePath, String artist, String title,
                     String cdid, int importFormat)
void    refreshFileList()
void    refreshMediaLibrary()

You download the file; QUP handles hashing, metadata extraction and database insertion. Removal takes the database entry only — the file on disk is never deleted.

SongImport.externalId is your store's own id for the song, and QUP stores it (mediadb.TRICERATRACKCODE) — that is what makes isSongInLibrary() and getSongHashByExternalId() able to answer. Namespace it, because the column is shared by every store: write "yt:dQw4w9WgXcQ" or "ptm:PH79809", not a bare id that another store could collide with. Comparison is case-sensitive.

Changed in 2.10.0 — externalId was previously accepted and then dropped, so the two lookup methods above always answered "no" for anything a plugin had imported. Plugins that already set externalId need no change; duplicate detection simply starts working.

importSong() scans the track's duration inline, so it can block for a moment on a large file — call it off the EDT.

Remove and reimport do not refresh the UI, deliberately, so a batch stays fast. Call refreshFileList() once when the batch is done. refreshMediaLibrary() is the one to call after batch metadata edits; it marshals to the EDT itself.

Metadata, genre and BPM

boolean updateSongGenre(String songHash, String genre)
int     updateSongGenres(Map<String, String> hashToGenre)   // one transaction
boolean updateFillerGenre(String filePath, String genre)
boolean updateSongMetadata(String songHash, String artist, String title,
                           String genre, String year)
boolean updateFillerMetadata(String filePath, String artist, String title,
                             String album, String genre)
double  detectBpm(String filePath)      // 0 if undecodable or no tempo found
void    updateSongBpm(String songHash, double bpm)
double  getSongBpm(String songHash)     // 0 if not yet analyzed

Pass null for any metadata field to leave it unchanged. updateSongBpm() will never overwrite a BPM the KJ entered by hand.

Transport — v2.0.57

void karaokePlay()    void fillerPlay()
void karaokePause()   void fillerPause()
void karaokeStop()    void fillerStop()
void karaokeNext()    void fillerNext()
void karaokeSeek(int seconds)   void fillerSeek(int seconds)

boolean isKaraokePlaying()
int     getKaraokePositionSeconds()   // -1 if unknown
int     getKaraokeLengthSeconds()     // -1 if unknown

Every command is a safe no-op when the target player is idle, and all of them go through the same paths as the KJ's own controls, so rotation and queue logic are respected. fillerPause() is best-effort: filler is a continuous radio-style player, so it pauses the countdown rather than hard-muting.

Queues — know which one you want

This is the easiest thing to get wrong. QUP has two separate queues.

// The separate filler music player
boolean addToFillerPlaylist(String filePath)
int     addToFillerPlaylist(List<String> filePaths)   // returns count enqueued
boolean addFillerUrl(String url, String displayName)  // v2.0.57
int     getFillerPlaylistSize()
int     getFillerPlaylistIndex()
void    clearFillerPlaylist()
boolean isFillerPlaying()
List<String>        getFillerDatabasePaths()   // never null
Map<String, String> getFillerGenres()          // never null
int     rescanFillerTags()

// The main karaoke playlist
boolean queueSong(String singerHash, String songHash)
boolean enqueueFileToPlaylist(String filePath)
boolean queueUrlToPlaylist(String url, String displayName)   // v2.0.57
int     getKaraokeUpcomingCount()

addToFillerPlaylist() and addFillerUrl() feed the background music player. enqueueFileToPlaylist() and queueUrlToPlaylist() append to the main karaoke playlist as a filler row — singer name "Filler" — which bypasses rotation entirely.

getFillerGenres() maps absolute path to genre; untagged tracks map to null or empty. rescanFillerTags() reads files off disk, so call it from a background thread.

UI

void registerMediaTab(String tabTitle, JPanel panel)
void registerMediaTab(String tabTitle, JComponent panel, String category)  // v2.1.4
void registerLibraryMenu(LibraryTarget target, LibraryMenuProvider provider)
void registerPlaylistMenu(PlaylistMenuProvider provider)                  // v2.16.0
void registerKioskTab(String tabTitle, JPanel panel)

category is "Audio" (shown first) or "General" (the default). You own your tab's UI completely.

LibraryMenuProvider is a functional interface called fresh every time the right-click menu is about to open, with the current selection — so your entry can be fully dynamic. Return null to contribute nothing. LibraryTarget is KARAOKE, FILLER or BOTH.

Dialogs and notifications

void    showNotification(String message, boolean isError)   // non-blocking
boolean showConfirmDialog(String title, String message)

Native file dialogs — v2.0.54

File       openFile(Component parent, String title)
File       openFile(Component parent, String title, String startDir, FileFilterSpec... filters)
List<File> openFiles(Component parent, String title, FileFilterSpec... filters)
File       openFolder(Component parent, String title, String startDir)
File       saveFile(Component parent, String title, String suggestedName, FileFilterSpec... filters)

Genuinely native — IFileDialog on Windows, NSOpenPanel on macOS, GtkFileChooserNative on Linux — and they follow the OS light/dark theme. parent may be null.

All return null on cancel except openFiles(), which returns an empty list. FileFilterSpec normalises patterns, so "csv", ".csv" and "*.csv" all mean the same thing.

These replaced the older createFileChooser / createFolderChooser methods, which are removed.

Theming

Map<String, Color> getSkinColors()
int  getListFontSize()
void applyStandardListStyle(JTable table)    // v2.0.27
void addListStyleToggle(JPopupMenu popup)    // v2.0.27

Colour keys: panelBg, panelBgDark, panelBorder, textPrimary, textDim, textActive, vfdKaraokeOn, vfdFillerOn, accent.

applyStandardListStyle() gives a table the skin's colours, alternating rows, the user's font size and the right-click style toggle in one call. It is safe to call repeatedly. If your table already has its own popup menu, use addListStyleToggle() to add just the toggle item to it.

getListFontSize() does not apply to config panels.

Singer identity — v2.2.1

boolean verifySingerPin(String singerName, String pin)
boolean singerHasPin(String singerName)
boolean singerExists(String singerName)

The PIN is never exposed to plugins. You may verify one; you must never receive, cache, log or persist it.

verifySingerPin() returns false for an unknown singer, a wrong PIN, and a singer with no PIN on file. The three are deliberately indistinguishable so a plugin cannot leak which singer names exist.

singerHasPin() exists for KJ-facing diagnostics only — "PIN required but none set" is a configuration problem a KJ cannot fix if they cannot see it. Never reflect it back to whoever is authenticating, because it reveals the name exists.

Name matching runs through SingerNames.normalize(), the same rule the rotation uses at showtime, so "verified" and "matched on stage" cannot disagree.

Audio

AudioSink openAudioSink(AudioFormat format)   // v2.1.4; null if audio unavailable
void      setFillerAudioTap(AudioTap tap)     // v2.2.1; null removes
void      setKaraokeAudioTap(AudioTap tap)    // v2.6.0; null removes

App info and automation

String     getAppVersion()
int        getAppMajorVersion()
Logger     getLogger()
File       getPluginDataDir()
Connection getPluginDatabase()    // null if the SQLite driver is unavailable
Automation getAutomation()        // v2.0.57
boolean    isDjMode()             // v2.8.0

isDjMode() — whether QUP is currently running in DJ mode rather than karaoke mode. DJ mode has no singer rotation, no filler bed, and no kiosk queue — a plugin whose menu entries or behavior assume any of those (an auto-fill queue, a karaoke-only transport) should query this and self-gate rather than have the host hide its items for it.

Shared tools — v2.13.0

PluginComponents getComponents()

For a plugin that runs ffmpeg, yt-dlp, deno or the Python audio environment (demucs and whisper). Declare the components in your catalog entry and installing your plugin from the Plugin Browser installs them — a KJ never visits your settings to press a setup button first. getComponents() is for the two things left to you: finding out where a tool is, and putting one back.

String locate(String componentId)                  // absolute path if usable now, else null; no network
String ensure(String componentId,
              Consumer<String> status,             // short progress lines; may be null
              IntConsumer progress)                // 0-100 for the current download; may be null
       throws Exception                            // message fit to show a KJ
void   cancel()                                    // ensure() then throws InterruptedException

Component ids: ffmpeg-gpl, ytdlp, deno, py-audioml. For py-audioml the path is the Python interpreter; the environment's scripts (demucs, whisper) sit beside it — in Scripts\ on Windows.

"Usable" means it passed a capability check, not that a file exists. An ffmpeg that cannot burn subtitles is reported missing even though it runs. The path may be a copy QUP adopted rather than downloaded — Homebrew's ffmpeg-full, say — so ask locate() instead of looking in a folder yourself; a Finder-launched Mac app has no useful PATH to find such a copy on.

ensure() uses a working copy if there is one, otherwise downloads, verifies, unpacks and checks it — minutes for py-audioml. Both calls start processes: never call them on the EDT. Each getComponents() returns a fresh handle, so cancel() stops only your own install. A plugin that calls this needs "minEngineApi": "2.13.0" in its catalog entry, or an older QUP will load it and fail at the call.

Hotkeys — v2.12.0

void registerHotkeyAction(String id, String displayName, Runnable action)
void unregisterHotkeyAction(String id)
void registerGlobalHotkeyProvider(GlobalHotkeyProvider provider)

registerHotkeyAction() offers an action the KJ can bind to a key in Config → General → Hotkeys — as an in-app key, a global key (one that fires while QUP is not the focused window), or both. It is listed under your plugin's name and exists in karaoke and DJ mode alike. Holding the key down does not repeat it.

The id is plugin.<your getId()>.<action>, for example plugin.com.example.recorder.startRecording: dotted segments of letters and digits, each starting lower-case or with a digit. Anything outside your own namespace, or not a valid id, throws IllegalArgumentException, as does a missing name or action.

Never rename an id. The KJ's keymap stores the id, not your display name, so a renamed id silently unbinds the key for every KJ who bound it. Change the display name as often as you like.

Registering an id that already exists updates it in place — name and action — so you can switch your actions on and off at runtime with these two calls. A KJ's binding to one of your ids survives your plugin being disabled, uninstalled or not loaded yet: the Hotkeys page shows it as (action not found), and it works again the moment you register that id. No restart.

The action runs on the Swing EDT. Keep it short and hand long work to your own thread. An exception it throws is caught and logged with your plugin id; it never reaches the key dispatch.

registerGlobalHotkeyProvider() replaces the engine's built-in backend for global keys — see GlobalHotkeyProvider under Specialized plugin interfaces below. Almost no plugin needs it.

Playlist menu — v2.16.0

void registerPlaylistMenu(PlaylistMenuProvider provider)
JMenuItem PlaylistMenuProvider.buildMenu(List<PlaylistEntryInfo> selected)

Contribute a right-click entry to the karaoke playlist. The provider is called fresh each time the popup opens, with the right-clicked rows as read-only PlaylistEntryInfo snapshots. Return a JMenuItem (or a JMenu), or null to add nothing. Plugin entries appear after QUP's own. A provider that throws is logged and skipped; it never breaks the menu. buildMenu runs on the Swing thread as the menu opens, so keep it quick and do slow work in the item's action.

| SourceSong | One song in a SongSource: sourceId (its identity within the scheme), title, artist, year, length, duo, explicit, styles, languages, dateAdded, plus extra — values only your plugin reads, stored and handed back. 0 / null mean unknown. — v2.17.0 | | SongSource.SyncResult | What one sync() changed: added, updated, removed, restored, total, millis. — v2.17.0 |

PlaylistEntryInfo Meaning
getSong() the SongInfo this row plays (its hash is the library song hash)
getSingerName() the singer's display name; empty for a filler row
getQueuePosition() 1-based position as the KJ sees it
isPlayed() / isLocked() row state
isCurrent() this entry is staged, playing or paused. Do not read or rewrite its file while true. Errs toward true while a song is being staged.

Library health — v2.16.0

boolean        isSongMarkedBad(String songHash)
boolean        setSongMarkedBad(String songHash, boolean bad)
List<SongInfo> queryMarkedBadSongs()
boolean        isPlaybackBusy()
String         getCurrentKaraokeSongHash()
AutoCloseable  holdSongFile(File file)

For a plugin that checks or repairs song files.

isSongMarkedBad() / setSongMarkedBad() read and set the bad-file flag — what Note Bad File sets, and what QUP sets when a song fails to play. A marked song cannot be enqueued. Setting it behaves exactly like the menu command, including the visible library row; it returns false if there is no such song. queryMarkedBadSongs() returns every marked song — a repair tool's worklist, cheaper than queryAllSongs() and a check per song.

isPlaybackBusy() is true while a song is being staged or any audible player (karaoke, filler, either DJ deck) is playing or paused. When unsure, it says busy. Write song files only when it is false. getCurrentKaraokeSongHash() names the entry playing or paused, or null; while the next song is staging it still names the previous one, so gate writes on isPlaybackBusy(), not on this.

holdSongFile() keeps QUP's own readers off a song file while you rewrite it in place: staging waits for it, and background checks treat the file as "try later" instead of marking it bad. Take the hold first, then check isPlaybackBusy(), and close it as soon as the replace is done:

try (AutoCloseable hold = ctx.holdSongFile(zip)) {
    if (ctx.isPlaybackBusy()) return;   // try again later
    // write beside the original, then one atomic replace
}

A plugin that calls any of these needs "minEngineApi": "2.16.0" in its catalog entry.

Song sources — v2.17.0

SongSource openSongSource(String scheme, String displayName)

For a plugin that brings its own catalog of songs with no file on disk — a streaming service, a download-on-demand store, a remote player. QUP lists and searches those songs with the library, badged with displayName, and the KJ queues them like any other song. KaraFun is built on it.

A source's songs are scheme:sourceId, e.g. karafun:12320. The scheme is lower-case letters, digits and -, and belongs to the first plugin that opens it: openSongSource() creates the source on first use and returns the same one after that. It throws IllegalArgumentException for a malformed scheme and IllegalStateException if another plugin owns it or the database is unavailable.

String     getScheme()
String     getDisplayName()
SyncResult sync(Collection<SourceSong> songs)   // replace the whole catalog
int        count()                              // listed songs, removed ones not counted
long       lastSync()                           // epoch millis, 0 if never
SourceSong find(String sourceId)                // null if not listed or removed
String     hashFor(String sourceId)             // the song hash a playlist entry, history and phone requests carry

Fetch your catalog however you like and hand QUP the whole list with sync(); songs are matched by getSourceId(). A song missing from a later sync is flagged removed, not deleted, so a rotation or history entry that points at it still knows what it was; one that comes back is restored. SyncResult reports added, updated, removed, restored, total and millis.

Call sync() off the Swing event thread — it writes to the database. The first sync into an empty source takes a verified database backup first and imports nothing if that backup fails (IllegalStateException). QUP keeps source songs apart from the file library and never uploads them anywhere.

To make the songs playable, the same plugin implements PlaybackSourcePlugin's URI methods for its scheme (below). Not to be confused with registerCatalog(), which is for songs a store sells.

A plugin that calls this needs "minEngineApi": "2.17.0" in its catalog entry.


Specialized plugin interfaces

Each extends QUPPlugin, so implement those eight members too, and register under the same service file.

SongStorePlugin — sell and deliver songs

int                fetchCatalog()          // entry count, or -1 on error
boolean            onPurchaseRequested(CatalogEntry entry, String singerHash)
int                getDownloadProgress()   // 0-100, or -1 when idle
List<CatalogEntry> getCachedCatalog()
boolean            requiresKJApproval()

Both fetchCatalog() and onPurchaseRequested() run on background threads, so network I/O is fine. singerHash is null when the KJ initiated the purchase.

Two models are supported: URL-redirect, where the entry carries a purchaseUrl, you open it in a browser and watch a folder for the file; and server-mediated, where you talk to a vendor API, download, importSong(), then optionally queueSong().

Patron purchases — v2.13.0. When the KJ turns on Config → Misc → Song Purchases, patrons can buy your catalog's songs at the kiosk, and onPurchaseRequested() is called with a non-null singerHash. Nobody is at the KJ's screen then, so: show no dialogs; pay with details the KJ has already saved, or return false; and return only once the song is imported, with SongImport.setExternalId(entry.getSongId()). QUP looks the song up by that id, takes the patron's credits only after it is in the library, and has the kiosk queue it — do not call queueSong() yourself. Report progress through getDownloadProgress() while you work; the kiosk shows it.

If you only import local files, implement QUPPlugin directly instead.

PlaybackListenerPlugin — react to what is playing

void onSongStarted(PlaybackEvent event)
void onSongEnded()
default void onNextSongPrefetch(PlaybackEvent event) {}
default void onQueuePrefetch(List<PlaybackEvent> upcoming) {}
default File getVideoForSong(String songHash) { return null; }
default boolean isVideoProvider() { return false; }
default boolean startVideoWithAudio() { return false; }   // v2.6.0
default float   getVideoDimLevel()    { return 0.5f;  }   // v2.6.0

Callbacks run on background threads — except getVideoForSong(), which runs on the playback thread and must return immediately. If the video is not cached, return null and fetch it in the background from onSongStarted or onNextSongPrefetch.

onQueuePrefetch() may be called repeatedly as the queue changes, so dedupe and skip what you have already cached.

startVideoWithAudio() holds the background on its first frame until karaoke audio is actually heard, then starts it and offsets the clock by the measured delay. Karaoke tracks often open with a second or more of silence, and because the background is slaved to the song clock — which starts at zero regardless — the picture otherwise runs ahead of the music for the whole song. getVideoDimLevel() is read live while compositing rather than latched at song start, so a brightness control responds while the KJ drags it. Both are ignored unless isVideoProvider() is also true.

Filler entries also fire onSongStarted, carrying the placeholder singer name "Filler". If your plugin acts on the singer — announcing, promoting, paging them — check event.isFiller() and skip those.

FillerPlugin — drive the background music

void onFillerPlaylistLow(int remainingSongs)
void onFillerSongEnded(SongInfo song)          // song may be null for streams
default int     getMinQueueDepth()     { return 3; }
default boolean isActiveFillerSource() { return true; }

Respond to onFillerPlaylistLow() by calling addToFillerPlaylist(). Only one filler plugin is active at a time; if several are installed the KJ picks.

PlaybackSourcePlugin — add a new playable format — v2.1.4

Set<String>  supportedExtensions()                  // lower-case, dot-prefixed
boolean      canPlay(File file)
SourcePlayer open(File file, PlaybackParams params) // null declines

// v2.17.0: songs from a SongSource, carried as scheme:id instead of a file
default Set<String>  supportedSchemes()                      // e.g. {"karafun"}; empty = files only
default boolean      canPlay(URI song)                       // e.g. still in the catalog?
default SourcePlayer open(URI song, PlaybackParams params)   // null declines

This is the seam that makes a new format a plugin instead of a core rewrite — MIDI karaoke is built entirely on it. When the KJ plays a file the engine cannot decode natively, the first source plugin that claims it supplies a SourcePlayer the engine drives exactly like a CDG or MP4 song.

Sniff the file header in canPlay() rather than trusting the extension. open() should parse and prepare but not start playing.

v2.17.0: a plugin with a song source (see Song sources above) lists its scheme in supportedSchemes() and gets canPlay(URI) / open(URI, …) for those songs — the same contract as the file versions: prepare, do not start. canPlay(URI) is only asked for a scheme you listed.

VisualizationPlugin — draw behind the lyrics — v2.6.0

default void onVisualizationStart(int width, int height) {}
BufferedImage renderFrame(int width, int height, double elapsedSeconds);
default void onVisualizationStop() {}
default boolean hasContentFor(String songHash) { return true; }

Whatever is drawn under the lyrics on the projector: a music video, an audio-reactive visualizer, a slideshow, a solid colour — anything that renders into a BufferedImage. Music Video Background is itself one of these.

Exactly one is active at a time, chosen by the KJ in Config → Plugins. Unlike FillerPlugin, plugins do not self-report being active: the engine owns the choice and persists it, so two installed visualizations can never fight over the one projector.

renderFrame() runs on the compositing thread at roughly 30fps. Return null to keep the previous frame, so a slow generator degrades to a lower frame rate rather than flickering. It must return promptly — that thread also composites the lyrics, so a slow frame stalls the whole projector image. An exception is caught and the visualization is dropped for the rest of the song rather than being re-entered every frame.

A negative elapsedSeconds means hold: the engine wants the background parked rather than advancing, used to sit out a track's leading silence. A generated visualization can treat it as zero; anything playing pre-recorded material should stay on its first frame until it goes positive.

To react to audio, register an AudioTap via setKaraokeAudioTap() in init() — a visualizer needs the song the room is hearing, not the filler bed underneath it. Buffer in the tap, do the analysis in renderFrame(); never run an FFT inside the tap itself.

Readability comes first. The KJ's brightness control is applied after renderFrame() returns, but a visualization that stays busy and high-contrast where the words sit will be unreadable no matter how far it is dimmed.

VisualizationPlugin — direct GL rendering — v2.8.0

default boolean supportsDirectGl() { return false; }
default boolean glAdopt(int width, int height) { return false; }
default void    glRenderFrame(int width, int height, double elapsedSeconds) {}
default void    glRelease() {}
default void    publishHostFrame(BufferedImage frame) {}

The methods above (onVisualizationStart/renderFrame/onVisualizationStop) are the readback contract: the plugin renders somewhere of its own and hands back a BufferedImage, which the compositor draws with Java2D. That costs a full glReadPixels every frame, forever, for a visualization that was already OpenGL to begin with.

These methods let a host that owns a GL context invite the plugin to draw straight into it instead. The host makes its context current on its own render thread and calls glRenderFrame there; the plugin renders into whatever framebuffer is bound and does not create a context of its own. All five are defaulted, so every existing plugin compiles and loads unchanged — a host that gets false from supportsDirectGl() simply falls back to renderFrame().

  • supportsDirectGl() — answering true is a promise about threading as much as about drawing: the plugin must be able to run its whole render on the host's thread, and must not create, destroy, or make-current any context of its own while the host's is adopted.
  • glAdopt(width, height) — called once per context generation (startup, and again after a context loss and rebuild), on the thread that owns the context for its lifetime and is the only thread that will ever call the other gl* methods. Return true if ready to render into this context.
  • glRenderFrame(width, height, elapsedSeconds) — render one frame into the currently bound framebuffer, on the thread that called glAdopt. GL state may be left dirty; the host re-establishes everything it depends on each frame. Must not swap buffers or destroy the context.
  • glRelease() — release GL resources, on the adopting thread, while that context is still current, before the host tears its own context down.
  • publishHostFrame(frame) — a small copy of what the host just drew, so renderFrame() keeps answering for any other consumer (a config pane preview, for instance) while the host owns the context — without this they'd see nothing, or start a second renderer beside the host's.

VisualizationPresets — let the KJ pick a look — v2.7.0

List<String> getPresetNames();
int  getCurrentPresetIndex();
void selectPreset(int index);
void nextPreset();
void randomPreset();
boolean isCycling();      void setCycling(boolean cycling);
int  getCycleSeconds();   void setCycleSeconds(int seconds);
boolean isShuffle();      void setShuffle(boolean shuffle);
default void rescanPresets() {}

Optional companion to VisualizationPlugin, for visualizations that have named looks. Implement it and QUP renders preset controls beside the live preview under Media Library → Plugins → Backgrounds: a searchable list, Next and Random, a cycle toggle with interval, and shuffle. A visualization with one fixed look simply does not implement it and no controls appear.

Every method is called on the Swing EDT, and selection is a request, not a switch. If your visualization holds a GPU context, that context belongs to one thread for its lifetime — loading a preset from a button handler is undefined behaviour that typically takes the whole JVM down rather than throwing. Record what was asked for and act on it from whatever thread owns your rendering. The bundled MilkDrop plugin stores a pending index in a volatile field and picks it up on its next render pass.

Two behaviours worth matching, because they are the difference between the control working and appearing not to: a manual pick should restart the cycle interval, so a deliberate choice is not replaced a second later by a change that was already due; and cycling off should pin the current preset indefinitely.

Persist the current preset by name, not by index. Preset folders gain and lose files between runs, and an index saved yesterday points somewhere else today.

VisualizationPresetPlaylists — named subsets of those presets — v2.7.0

List<String> getPlaylistNames();
boolean isPlaylistEnabled(String playlist);
void    setPlaylistEnabled(String playlist, boolean enabled);
String  createPlaylist(String playlist);      // returns the stored name, or null
void    deletePlaylist(String playlist);
void    addToPlaylist(String playlist, int presetIndex);
void    removeFromPlaylist(String playlist, int presetIndex);
List<String> getPlaylistContents(String playlist);
int     getCyclePoolSize();

A real MilkDrop pack is around ten thousand presets. Left alone the shuffle wanders all of them, and a KJ who has found the forty that suit a Friday night has no way to say so. A playlist is a named list of presets; ticking one or more restricts cycling to their union, and with none ticked everything is in play.

Also EDT-only, and the same threading warning applies with more force: your render thread is reading the pool while these calls mutate it. Publish the pool as an immutable snapshot — build a new array and swap it in — rather than letting the render thread walk a collection the EDT is editing.

getPlaylistContents() returns names, not indices, for the same reason preset selection is persisted by name: an index list is stale the moment a rescan lands between the call and its use.

Two behaviours to match: an empty ticked playlist should fall back to the whole set rather than leaving the screen with nothing to cycle through, and a pool change should affect the next switch only — deleting the playlist that put the current preset on screen must not yank the picture out from under the room.

Every mutating method is expected to persist immediately. There is no commit or close hook: the KJ ticking a playlist mid-set is the decision.

CdgLyricPlugin — redraw CD+G lyrics — v2.11.0 (experimental)

// PluginContext
void registerCdgLyricPlugin(CdgLyricPlugin plugin);

// CdgLyricPlugin
Surface open(Song song);

interface Song {
    byte[] cdgBytes();
    String audioPath();
    DoubleSupplier positionMs();
}

interface Surface {
    void paint(Graphics2D g, int width, int height);
    void close();
}

Experimental. This interface exists for the CDG Enhancement plugin, which ships switched off and is not yet production quality. It may change in a later release without the usual notice; don't build anything you depend on for a show on it yet.

A .cdg file is a timed drawing program, not a video. A lyric plugin is handed the disc's raw packets and redraws the words at the projector's resolution instead of stretching the disc's 300×216 bitmap. The engine keeps the transport: it still opens the audio, drives the CDG clock and owns seek and pause. The plugin only takes the paint slot.

Register from init(). Only one lyric plugin is active at a time, and the last one to register wins.

  • open(song) — called once per song, off the EDT, before playback starts. Returning null is a normal answer: it means "this disc is not for me" (a scrolling disc, an unreadable one, or a plugin that is switched off), and that song plays on the standard renderer.
  • positionMs() is already corrected for the engine's CDG offset. Read it rather than keeping a clock of your own, or your text will drift from the audio by exactly the offset the KJ has dialled in.
  • Surface.paint() runs on the compositing thread at the projector's frame rate, and on preview repaints. Paint only the text over a transparent background, because the engine owns what is behind it (a music video, a visualizer or black). It must not block.
  • Surface.close() — release cached artwork when the song ends or the KJ moves on.

GlobalHotkeyProvider — a backend for global keys — v2.12.0

For a plugin that supplies global hotkeys where the built-in backend cannot (another platform, another mechanism). The engine owns everything else — the actions, the KJ's bindings and keymap file, the Hotkeys page, mode gating. A provider is handed a key and a number, asks the operating system for the key, and reports the number back when it is pressed. That is the whole contract, which is also what lets one be faked in a test with no native code.

String name()                              // shown to the KJ, e.g. "Windows (RegisterHotKey)"
String unavailableReason()                 // why it can't work here, in words for the KJ; null if it can
void   setListener(IntConsumer onFired)    // set once, before start()
void   start()                             // once, before the first register()
Result register(int id, KeyStroke key)     // blocks until the OS answers; never throws
void   unregister(int id)                  // unknown ids ignored; never throws
void   shutdown()                          // release every key, stop every thread; idempotent

Result is sealed: Registered, TakenByAnotherApp, NotSupportedOnThisPlatform, or Failed(code, what). A registration failing is normal, not exceptional — another program already holding a key is the common case, and the Hotkeys page shows it to the KJ. Only the key code and modifiers of the KeyStroke matter. Registering an id that is already registered replaces it.

Only one provider is live. Registering yours replaces the current one: the engine unregisters every key from the outgoing provider and shuts it down before starting yours, then registers the KJ's global bindings against it — two providers never hold OS registrations at once. The engine calls you from one thread at a time, usually the EDT; your listener may call back on any thread.

AudioDspPlugin — process audio in the path

void onAudioReady()
void onAudioStopping()

For plugins that pull captured input, run DSP, and write the result as a summed input on the mains bus — the shipped mic reverb/delay strip is the reference implementation.

An audio-DSP plugin is an async writer. Never do per-sample DSP inside the real-time callback. Release your input taps and bus inputs in onAudioStopping().


Audio interfaces

AudioSink — emit audio — v2.1.4

Obtained from openAudioSink(). On ASIO it is a summed input on the ReaRoute mains bus alongside karaoke and filler; otherwise a normal output device.

void   write(byte[] pcm, int off, int len)   // blocks with the output
void   setVolume(float volume)               // 0..1
double getSampleRate()
int    getPlaybackLatencyFrames()            // 0 if unknown
void   flush()
void   close()

Write signed 16-bit little-endian, interleaved stereo at getSampleRate() — set your render format to that so nothing has to resample. Use getPlaybackLatencyFrames() to slave a lyric cursor exactly. Close the sink when your source stops.

AudioTap — observe audio — v2.2.1

void onAudio(byte[] pcm, int offset, int length, AudioFormat format)

The counterpart to AudioSink. The tap sits at the very end of the filler player's chain — post-EQ, post-crossfade, immediately before the buffer goes to the device — so it hears exactly what the room hears, including filler ducking as a karaoke song starts. It is the same code point on both output paths, so it behaves identically with or without ASIO.

Read this before implementing one. onAudio is called on the player's audio thread, inline in the decode/write loop. It must return promptly and must not block, allocate heavily, take contended locks, or touch Swing. Copy what you need into a bounded structure you drain elsewhere, and drop rather than wait when that structure is full — a slow tap will backpressure and glitch the venue's audio. A tap that throws repeatedly may be dropped.

The pcm array is reused on the next iteration, so copy anything you intend to keep. Sample rate, channel count and sample size vary per track: read them from format rather than assuming 44.1 kHz stereo.

Each plugin has at most one filler tap; setting a new one replaces the old.

SourcePlayer — play a format you added — v2.1.4

Returned from PlaybackSourcePlugin.open(). Your source owns the master clock; the engine slaves lyrics and graphics to it.

void    play()          void   stop()      void pause()   void resume()
boolean isPlaying()     boolean isPaused()
void    seekTo(double seconds)
double  getCurrentTimeMs()          // note: milliseconds
double  getSongLengthSeconds()
void    setVolume(int percent)      // 0..100
default void setCrossfadeGain(float gain) {}   // 0..1, on top of setVolume
void    setTranspose(int semitones)
void    setTempoRatio(double ratio)            // 1.0 = original
void    setEndListener(Runnable onEnd)
default void setErrorListener(Consumer<String> onError) {}   // v2.17.0
default boolean usesOwnDisplay() { return false; }          // v2.17.0
boolean hasVisual()
void    paint(Graphics2D g, int width, int height)
void    close()

You must fire the end listener when the song finishes — it is how the engine advances the playlist. A source that never calls it stalls the rotation.

paint() is called on both the projector and preview surfaces at their own pixel sizes; read your own clock for the current position. seekTo() should replay prior controller state so timbres stay correct.

v2.17.0. setErrorListener() is for a song that turns out not to be playable after open() returned — a remote player refused it, say. Call it with a short sentence for the KJ; QUP marks the playlist entry failed, as it does for a local file that will not play, and moves on. A source that reports an error must not also fire the end listener for that song; sources that never call it keep reporting failures as an end. usesOwnDisplay() returns true if your player shows its picture in a window of its own on the projector (KaraFun's Dual-Screen window): QUP's projector window is hidden while your song plays and comes back, without taking focus, when it closes. hasVisual() still decides what the operator's preview shows.


Data types

Type Notes
SongInfo Immutable song metadata. Note getCDID() capitalisation. getFullPath() joins path + filename; filename already includes the extension.
SongImport Describes a song to import. You download the file; QUP hashes and inserts it. pluginId is set for you.
CatalogEntry A purchasable song. Maps to the OpenKJ apigetsongs_v2 shape. purchaseUrl is null for server-mediated stores. pluginId is set for you.
PlaybackEvent Song + singer info for playback callbacks. Check isFiller().
PlaybackParams Public final fields pitchSemitones, tempoPercent. PlaybackParams.defaults() is (0, 0).
FileFilterSpec new FileFilterSpec("Audio Files (*.mp3)", "mp3"). Accessors are description() and extensions().
LibraryTarget KARAOKE, FILLER, BOTH.
PlaylistEntryInfo Immutable snapshot of a karaoke playlist row, passed to PlaylistMenuProvider. See Playlist menu above. — v2.16.0
SingerNames normalize(String), sameSinger(String, String).
SkinnedDialog Undecorated dialog with a skin-painted title bar. Add to getContentPanel(), show with showCentered(w, h).

SingerNames is deliberately not fuzzy

It folds Unicode to NFKC, reduces every Unicode space separator — including U+00A0, which \s does not match and phone keyboards insert freely — to plain blanks, collapses runs, trims and lower-cases.

It never computes edit distance, never drops punctuation and never matches a prefix. "Bob" and "Bobby" stay different people. Route your own singer matching through it so your plugin and the rotation always agree.


Automation — v2.0.57

getAutomation() returns a QA surface for driving and observing the engine with nobody watching or listening.

File         captureScreen(File pngOut)
File         captureWindow(String window, File pngOut)   // "main", "cdg", "config"
List<String> configPaneNames()
void         showConfigPane(String name)
void         captureConfigPanes(File dir)
String       diagnostics()                               // JSON
void         playTestTone(double freqHz, double seconds)
void         playTestSweep(double startHz, double endHz, double seconds)
void         playTestNoise(double seconds)
void         stopTestSignal()

Test signals route through the real karaoke DSP chain — SoundTouch pitch/tempo and EQ — so a loopback recording can be FFT-analysed to verify key change, EQ and "is there sound at all" objectively.

The whole surface is best-effort: methods return null or do nothing rather than throwing when a target is unavailable, such as in a headless run.


Threading, in one table

Where Thread Rule
PluginContext, all methods any Safe from anywhere.
Automation, all methods any GUI actions marshal to the EDT.
QUPPlugin.onConfigPanelClosed() EDT Cheap, non-blocking.
PlaybackListenerPlugin callbacks background Network I/O fine.
PlaybackListenerPlugin.getVideoForSong() playback Return immediately.
FillerPlugin callbacks background Network I/O fine.
SongStorePlugin catalog/purchase background Network I/O fine.
AudioTap.onAudio() audio No blocking, no allocation, no Swing.
AudioDspPlugin DSP your own Never inside the RT callback.
PluginContext.rescanFillerTags() caller's Call it off the EDT; it reads files.
VisualizationPlugin.renderFrame() compositing Return promptly; it also draws the lyrics.

DJ mode (v2.9.0)

DJ mode's two decks are addressable from a plugin. Every call is a no-op that returns false, null or -1 when DJ mode is not running, so a plugin can degrade rather than fail — check isDjMode() first if you want to hide UI.

Decks are named by DeckId.A / DeckId.B. That enum is deliberately part of the plugin package rather than an engine type: a plugin compiles against com.qupkaraoke.plugin and nothing else, and two named values beat bare integers nobody validates.

Transport

Method Does
djPlay(DeckId) / djPause(DeckId) / djStop(DeckId) Start, pause, stop a deck.
djIsPlaying(DeckId) true if that deck is playing.
djPositionSeconds(DeckId) Position in seconds, or -1.
djTempoRatio(DeckId) Live tempo ratio; 1.0 is as recorded.
djLiveBpm(DeckId) Grid BPM scaled by tempo, or -1 with no beatgrid.
djBeatJump(DeckId, int beats) Displace by a signed number of beats.

Cues and loops

Method Does
djSetCue(DeckId, int slot) Store the deck's position at slot 1..8.
djCue(DeckId, int slot, boolean quantized) Jump to a stored cue. false if the slot is unset.
djLoop(DeckId, double beats, boolean quantized) Engage a beat loop — a power of two from 1/16 to 32.
djUnloop(DeckId) Release it.
djLoopBeats(DeckId) Running loop length in beats, or -1.

Loops engage on the call. quantized selects alignment, not delay: on, the loop anchors to the grid cell the playhead is already in; off, it engages raw under the head.

Turntable moves

djBrake(DeckId, double factor), djSpinback(DeckId, double factor), djSoftStart(DeckId, double factor). Pass 1.0 for the default ramp.

Mixer and effects

Method Does
djSetCrossfader(double pos) 0 hard A, 1 hard B. Transport-class — this yields Auto DJ.
djCrossfader() Its position, or -1.
djFxLoad(DeckId, String slot, String type) slot is "fx1", "fx2" or "send". type is an effect name, or "--" to clear.
djFxSet(DeckId, String slot, double v) -1..+1 for a filter (0 is off), 0..1 for the others.

Effect names come from the engine's own list — Filter, Echo, Reverb, Roll, Gater, Flanger, Phaser as of this release. "send" is post-fader, which is what lets an echo or reverb tail keep ringing after the channel is cut.

Auto DJ

djAutoDjArm(boolean) arms or disarms it. djAutoDjState() returns OFF / ARMED / TRANSITIONING. djLastTransitionRecipe() returns the name of the transition that ran last — bass-swap-blend, filter-loop-build, echo-out, cut-on-the-one, filter-fade or plain-crossfade — or null.

A note on yielding

Auto DJ hands control to a human who touches something. Calls above are classified the same way the pane's own controls are: moving the crossfader or a deck's transport yields Auto DJ, while loading an effect or firing a loop does not, on the reasoning that garnish over a running mix is not a request to take the mix over.


Version history

Version Added
v2.17.0 Song sources: openSongSource(), SongSource, SourceSong; PlaybackSourcePlugin.supportedSchemes(), canPlay(URI), open(URI, PlaybackParams); SourcePlayer.setErrorListener(), SourcePlayer.usesOwnDisplay()
v2.16.0 registerPlaylistMenu(), PlaylistMenuProvider, PlaylistEntryInfo; library health: isSongMarkedBad(), setSongMarkedBad(), queryMarkedBadSongs(), isPlaybackBusy(), getCurrentKaraokeSongHash(), holdSongFile()
v2.13.0 PluginContext.getComponents(), PluginComponents; SongStorePlugin.onPurchaseRequested() patron-purchase contract
v2.12.0 Hotkeys: registerHotkeyAction(), unregisterHotkeyAction(), registerGlobalHotkeyProvider(), GlobalHotkeyProvider
v2.11.0 CdgLyricPlugin (open(), Song, Surface) and PluginContext.registerCdgLyricPlugin(), both experimental
v2.9.0 DJ mode: DeckId, deck transport (djPlay/djPause/djStop/djIsPlaying/djPositionSeconds/djTempoRatio/djLiveBpm), djBeatJump, cues (djSetCue/djCue), loops (djLoop/djUnloop/djLoopBeats), djBrake/djSpinback/djSoftStart, djSetCrossfader/djCrossfader, djFxLoad/djFxSet, djAutoDjArm/djAutoDjState/djLastTransitionRecipe
v2.8.0 PluginContext.isDjMode(), VisualizationPlugin direct GL rendering (supportsDirectGl(), glAdopt(), glRenderFrame(), glRelease(), publishHostFrame())
v2.7.0 VisualizationPresets, VisualizationPresetPlaylists
v2.6.0 VisualizationPlugin, setKaraokeAudioTap(), PlaybackListenerPlugin.startVideoWithAudio(), PlaybackListenerPlugin.getVideoDimLevel()
v2.5.0 QUPPlugin.onConfigPanelClosed()
v2.2.1 setFillerAudioTap(), AudioTap, verifySingerPin(), singerHasPin(), singerExists(), SingerNames, PlaybackEvent.isFiller()
v2.1.4 registerMediaTab() with category, openAudioSink(), AudioSink, PlaybackSourcePlugin, SourcePlayer, PlaybackParams, SourcePlayer.setCrossfadeGain()
v2.0.57 Transport control, addFillerUrl(), queueUrlToPlaylist(), getAutomation(), Automation
v2.0.54 Native file dialogs, FileFilterSpec. Removed createFileChooser() / createFolderChooser().
v2.0.27 applyStandardListStyle(), addListStyleToggle()