Writing OxiGuitar plugins
OxiGuitar is a small core with plugins. The core holds the score, shows it, exports it as a PDF, edits it and plays it; everything else comes from plugins: every file format, every sound, other exports, and tabs in the window’s panels. Our own Guitar Pro readers are plugins like any you write, and the app makes no distinction between ours and yours.
This guide shows how to write one, in Rust, with the SDK (oxiguitar-plugin). The fastest way in is to copy the template plugin: it builds, passes the contract suite and opens in the app as it is, and each part of it is explained in the pages that follow.
What you need
- Rust 1.99 or later, from rustup, for the SDK of v0.9.0.
- OxiGuitar 0.9 or later (0.5 for a plugin on interface 1.2, 0.2 for one on 1.0 or 1.1), to try your plugin in the app (the user manual).
- Nothing else: the SDK and the contract suite come through Cargo, and you write no
unsafecode.
The pages
| Page | What it covers |
|---|---|
| What a plugin is | What a plugin can and can’t do, and when it runs |
| Start from the template | Copy, rename, build, test, try it in the app |
| The plugin file and its manifest | The .oxp file and manifest.json, key by key |
| The SDK | The Plugin trait, export_plugin!, the host, errors and threads |
| Formats | Reading a file into a score, writing a score into a file |
| Tabs | A tab in one of the window’s areas: widgets and a canvas |
| Events and the open score | Following the score, the selection and playback |
| Services | Implementations of an interface, chosen per system |
| Instruments | What comes with the player |
| Settings | Keeping a plugin’s settings |
| Testing | cargo test, the contract suite, trying it in the app |
| Building for every system | Six targets, one file each, from one workflow |
| Installing and sharing | Where users put the file, and what to tell them |
| Interface versions | What 1.0 and 1.1 hold, and what stays compatible |
The design behind all this is in docs/design/plugins.md and ADR 0008. The SDK’s own reference is its rustdoc: cargo doc --open in your plugin’s folder.
What a plugin is
A plugin is one file, NAME.oxp: a native library for one system (a .so on Linux, a .dylib on macOS, a .dll on Windows, renamed), with a manifest inside it that says what the plugin adds. The user drops the file in a plugin folder; OxiGuitar finds it at the next start.
What a plugin can add
| Contribution | What it is | Since |
|---|---|---|
| A format | Reads one file format into a score, writes a score into it, or both | 1.0 |
| A tab | A tab in the window’s top, bottom, left or right area, made of standard widgets and, if it needs one, a canvas it draws | 1.1 |
| Events | Being told when a score is opened, changed or closed, when the selection changes and when playback moves | 1.1 |
| A service | An implementation of an interface, chosen per system: an audio device, a MIDI device, anything another plugin needs | 1.0 |
| An instrument | Sound for the player’s tracks. Declared in 1.0; the interface itself comes with the player | (later) |
One plugin can add any mix of these. Tools for the Tools menu, other exports (PNG, SVG, WAV) and toolbar sections join the interface when the first plugin needs them; if you want to write one, open an issue and say what it needs.
What a plugin is not
- Not sandboxed. A plugin is native code running inside OxiGuitar, with the user’s rights: it can read files, open network connections, start threads. OxiGuitar’s privacy promises (no telemetry, never upload, never download) cover the app and the plugins we publish, not yours. Make your own promises, keep them, and say them plainly where people download your plugin.
- Not a way to change the score behind the core’s back. A plugin reads the score the core gives it. Edits will go through the core as undoable commands, when the editor comes (OxiGuitar 0.5).
- Not a script. It is compiled Rust, built for each system you want to support (Building for every system).
- Not unloaded. Once loaded, it stays in memory until OxiGuitar quits. Disabling it in Tools → Plugins takes effect at the next start.
When it runs
- At start, OxiGuitar reads the manifest of every
.oxpfile in the plugin folders, without loading any code. Your plugin’s tabs, formats and services are known from that alone, and reading 30 manifests takes about a millisecond. - On first use, it loads the plugin and calls its
start: when a file of its format is opened, its tab is first shown, one of its services is first needed, or an event it subscribes to first fires. - Then it calls the plugin whenever one of its contributions is used, from any thread, possibly several at once.
- At the end of the program, the plugin goes with it.
An error a plugin answers with (a damaged file, say) is shown to the user, and the plugin goes on. A plugin that fails (it can’t start, it panics, or it breaks the interface’s contract) is withdrawn for the rest of the session: OxiGuitar carries on as if it weren’t installed, so a file it would have read goes to the next plugin that recognises it and its tabs leave the window. The footer says so once, and Tools → Plugins shows why.
In Rust
A plugin is a library crate that depends on the SDK, oxiguitar-plugin. It implements the trait Plugin and one trait per kind of contribution, writes its manifest as manifest.json beside Cargo.toml, and calls export_plugin! once. The SDK turns that into the C interface OxiGuitar loads, with the only unsafe code inside the SDK.
The SDK and the interface crate are MIT OR Apache-2.0, so your plugin may use any licence compatible with the GPL (OxiGuitar itself is GPL-3.0-or-later, and a plugin runs inside it).
Start from the template
The template plugin is a complete plugin you copy and change. It lives in OxiGuitar’s repository as docs/plugins/template/, and adds one of each common thing:
- a format, “Example tab” (
.extab): a tiny text format of eighth notes on a guitar, read and written; - a tab, “Statistics”, in the right area: how many notes each string of a track plays in the open score, as a small chart you can click, with a setting remembered in settings.json;
- a service,
note-names, which other plugins can call to name a pitch.
Each is one file in src/; you delete what you don’t need.
1. Copy it
Copy the folder, hidden .github/ included, to wherever you keep your projects. With git:
git clone --depth 1 --branch v0.9.0 https://github.com/mateusrdp/oxiguitar
cp -R oxiguitar/docs/plugins/template my-plugin
rm -rf oxiguitar
cd my-plugin
The template’s Cargo.toml takes the SDK and the contract suite from OxiGuitar’s repository on GitHub, at the same tag, v0.9.0 (Cargo downloads the repository the first time). The copy in OxiGuitar 0.9.0 still names v0.5.0: change both to v0.9.0, which has interface 1.3 (Interface versions). The template asks for interface 1.1, so what you build loads in OxiGuitar 0.2 and later; ask for 1.2 when you use its tools or the score version 2, and it needs 0.5; ask for 1.3 for a settings window, a section of the track dialog, footer links, the plugin’s folders, instruments, effects or audio devices, and it needs 0.9. To build for a later OxiGuitar, change both tags.
2. Build and test it
cargo test
The first run downloads and builds the SDK and the contract suite, which takes a minute. Then it runs:
- the template’s own unit tests (
src/*.rs); the_manifest_matches_the_plugin, a testexport_plugin!adds: the manifest is valid, its version is the crate’s, and every format, service and tab it declares is implemented, with contents the core can show;tests/contract.rs, which builds the plugin filetarget/oxp/example.oxpand checks it through OxiGuitar’s own plugin host (Testing).
3. Try it in OxiGuitar
Copy the file into your plugin folder (OxiGuitar creates it the first time it starts):
# Linux
cp target/oxp/example.oxp ~/.local/share/oxiguitar/plugins/
# macOS
cp target/oxp/example.oxp ~/Library/Application\ Support/OxiGuitar/plugins/
On Windows, copy target\oxp\example.oxp to %APPDATA%\OxiGuitar\plugins\.
Start OxiGuitar. Tools → Plugins lists “Example”, not loaded yet. Open samples/open-strings.extab (File → Open): the plugin loads and reads it (oxiguitar-cli info samples/open-strings.extab also prints its import log, one warning). The right area has a Statistics tab, known from the manifest before the plugin loaded; open it, and click a bar of its chart.
4. Make it yours
- Rename it. In
Cargo.toml, the package’sname. Inmanifest.json:id(lowercase letters and digits joined by.or-, starting with your own name:jane.chord-finder),name,description,authors,licenseandhomepage. Inbuild-oxp.sh,NAME(your file’s name) andLIBRARY(the package’s name with_for-); in.github/workflows/build.yml,NAMEagain. Intests/contract.rs, the file’s name. - Delete what you don’t need. Each contribution is a module, a line in
src/lib.rs’simpl Plugin, and an entry inmanifest.json. Remove all three;cargo testsays if the manifest and the code disagree. - Write your own. The next pages explain each part: the manifest, the SDK, formats, tabs, events, services, settings.
- Keep the versions equal. The version in
manifest.jsonmust be the crate’s, and you raise both with each release: when two files of the same plugin are installed, OxiGuitar uses the higher version.
What’s in the folder
| File | What it is |
|---|---|
Cargo.toml | The crate: the oxp feature, the SDK, the contract suite for tests, lints, a release profile for small files |
manifest.json | What the plugin adds, read by OxiGuitar without loading the plugin |
src/lib.rs | The plugin type, which hands out each contribution, and export_plugin! |
src/format.rs | The Example tab format |
src/statistics.rs | The Statistics tab |
src/service.rs | The note-names service |
src/notes.rs | Note names and standard tuning, shared by the last two |
tests/contract.rs | Builds the .oxp file and runs the contract suite on it |
samples/open-strings.extab | A file to open |
build-oxp.sh | Builds the release file for this system, dist/NAME-VERSION-SYSTEM-ARCH.oxp (Building for every system) |
.github/workflows/build.yml | Builds and tests the plugin for every system on GitHub, and attaches the files to a release |
Here is src/lib.rs, the whole plugin in one place:
/// The plugin: built once, when OxiGuitar first uses one of its
/// contributions, and kept until the program ends.
pub struct Example {
statistics: statistics::Statistics,
}
impl Plugin for Example {
fn start(host: Host) -> Result<Example, Error> {
Ok(Example {
statistics: statistics::Statistics::new(host),
})
}
// Each method hands out a contribution the manifest declares, by its id.
fn reader(&self, format: &str) -> Option<&dyn FormatReader> {
(format == format::ID).then_some(&format::ExampleTab as &dyn FormatReader)
}
fn writer(&self, format: &str) -> Option<&dyn FormatWriter> {
(format == format::ID).then_some(&format::ExampleTab as &dyn FormatWriter)
}
fn service(&self, interface: &str, id: &str) -> Option<&dyn Service> {
(interface == service::INTERFACE && id == service::ID)
.then_some(&service::NoteNames as &dyn Service)
}
fn tab(&self, id: &str) -> Option<&dyn Tab> {
(id == statistics::ID).then_some(&self.statistics as &dyn Tab)
}
fn event(&self, event: &oxiguitar_plugin::Event) {
self.statistics.event(event);
}
}
// The plugin's entry point and manifest, and a test (under `cargo test`)
// that the manifest matches the code.
oxiguitar_plugin::export_plugin!(Example);
The plugin file and its manifest
The file
A plugin file is the plugin crate built as a C dynamic library, renamed to NAME.oxp. Inside it are the plugin’s code and its manifest, in a section of its own (oxg_manifest; oxgmanif on Windows, whose section names hold eight characters). OxiGuitar reads that section with a parser of library files, so it learns what the plugin adds without running any of its code. Plugin files carry no signature: OxiGuitar checks that the file is whole (a truncated download is refused before it is loaded), that it is a library for this system and processor, and that it asks for an interface version OxiGuitar has.
You never write the section yourself: you write manifest.json beside Cargo.toml, and export_plugin! puts its bytes in the section when the file is built.
manifest.json
The template’s:
{
"interface": "1.1",
"id": "yourname.example",
"name": "Example",
"version": "0.1.0",
"description": "Opens Example tab files (.extab), counts the notes on each string of the open score, and names notes for other plugins.",
"authors": ["Your Name"],
"license": "MIT OR Apache-2.0",
"homepage": "https://example.org/your-plugin",
"formats": [
{
"id": "extab",
"name": "Example tab",
"extensions": ["extab"],
"read": true,
"write": true,
"signatures": [{ "at": 0, "text": "EXAMPLE TAB" }]
}
],
"services": [
{ "interface": "note-names", "id": "example", "name": "Example note names" }
],
"tabs": [
{ "id": "statistics", "title": "Statistics", "area": "right" }
],
"events": ["score"]
}
| Key | Required | What it is |
|---|---|---|
interface | yes | The interface version the plugin was built for, "MAJOR.MINOR": "1.0", or the version that brought what it uses (Interface versions) |
id | yes | Unique among all plugins: 1 to 64 lowercase letters and digits, in parts joined by single . or -. Start it with your own name (jane.chord-finder); ids starting oxiguitar. are ours. Never change it once released: settings and the disabled list refer to it |
name | yes | What users see, in the plugin manager and in footer messages |
version | yes | MAJOR.MINOR.PATCH, optionally -PRERELEASE (1.2.0-beta.1), equal to the crate’s version. Of two files with the same id, the higher version is used |
description | no | One or two sentences for the plugin manager |
authors | no | A list of names |
license | no | An SPDX expression, such as "MIT OR Apache-2.0" |
homepage | no | Where the plugin comes from |
conditions | no | Where it can run (below); it is never loaded elsewhere |
requires | no | The service interfaces it calls through the host (Services) |
formats | no | The file formats it reads or writes (Formats) |
services | no | The services it implements (Services) |
instruments | no | The instruments it offers the player; used from interface 1.3 (Instruments) |
effects | no | The effects it offers a track, after its instrument; interface 1.3 (Instruments) |
tabs | no | Its tabs; interface 1.1 (Tabs) |
events | no | The kinds of event it wants: "score", "selection", "playback"; interface 1.1 (Events) |
tools | no | Its entries in the Tools menu; interface 1.2 (Tools) |
settings_window | no | true for a settings window, opened from the plugin manager; interface 1.3 (Settings) |
track_sections | no | Its sections of the track dialog; interface 1.3 (Track sections) |
folders | no | The shared folders it reads, "soundfonts" and "nam", which OxiGuitar creates at start; interface 1.3 (Settings) |
Unknown keys are refused when you build and ignored when OxiGuitar reads. The test export_plugin! adds fails on a key the SDK doesn’t know, so a typing mistake ("servces") shows up in cargo test. An older OxiGuitar reading a newer plugin’s manifest skips keys it doesn’t know, so new keys never stop a plugin from loading.
A manifest is at most 1 MiB. Real ones are a few hundred bytes.
Conditions
"conditions": { "os": ["linux"], "arch": ["x86_64", "aarch64"], "display": ["wayland"] }
Every list given must hold the running system’s value; an empty or missing list allows any.
| Key | Values |
|---|---|
os | linux, macos, windows (later android, ios) |
arch | x86_64, aarch64 |
display | wayland, x11, windows, macos: the display system OxiGuitar was built for. On Linux there are two builds of OxiGuitar, Wayland (the default) and X11 |
cpu | Processor features the code needs, all of them, as Rust names them: avx2, fma, avx512f… on x86-64, neon, dotprod, sve… on ARM64. Since interface 1.3: a manifest asking for an older interface can’t use it |
A plugin whose conditions don’t hold is listed in Tools → Plugins with the reason, and never loaded. A plugin file is already built for one system and processor, so you need os and arch only for a file that would load but can’t work (a universal macOS file that works on Apple Silicon only, say). display matters for plugins that talk to the windowing system. Services have conditions of their own, beside the plugin’s.
cpu lets you ship a faster build beside a plain one. Build the plugin twice, with the same id and version: once plainly, and once with -C target-cpu=x86-64-v3 and "cpu": ["avx2", "fma"] in its manifest. On a processor without those features that file can’t be used and the plain one is; where both can be used, the one needing more features is. A feature name OxiGuitar doesn’t know counts as missing, so a build is never loaded on a guess.
Rules the checks enforce
OxiGuitar refuses a manifest that breaks these, and Tools → Plugins names the first problem; cargo test (through export_plugin!) names them all:
- the ids are valid and unique: the plugin’s; each format’s; each instrument’s; each tab’s; each service’s within its interface;
- names and tab titles aren’t empty;
- a format reads or writes, and one that reads has at least one signature;
- extensions are lowercase and without the dot (
"gp5", not".GP5"); - tabs and events need interface 1.1.
cargo test also checks what only the code can tell:
- the manifest asks for an interface this SDK has (1.0 or 1.1), and its version is the crate’s;
- it has no key the SDK doesn’t know;
- every format, service and tab declared is implemented, and every tab’s contents can be shown.
The SDK
The SDK is the crate oxiguitar-plugin. It wraps the plugin interface in safe Rust: you implement traits, and export_plugin! makes the C entry point OxiGuitar loads.
The crate
# An OxiGuitar plugin: a template to copy (docs/plugins/, "Start from the
# template"). Rename the package, the plugin's id and its file, then delete
# the parts you don't need.
[package]
name = "example-plugin"
version = "0.1.0"
edition = "2024"
rust-version = "1.99"
description = "An example OxiGuitar plugin: a small text tab format, a statistics tab and a note-name service."
license = "MIT OR Apache-2.0"
publish = false
[lib]
# An ordinary library for `cargo test`; the `.oxp` file is the same crate
# built with `--features oxp --crate-type cdylib` (oxiguitar-plugin-test
# does that).
crate-type = ["lib"]
[features]
# Turned on only when the `.oxp` file is built: it exports the plugin's
# entry point and manifest. Every plugin needs this feature.
oxp = []
[dependencies]
# The SDK, from OxiGuitar's repository at the tag of the release you build
# for. v0.9.0 has interface 1.3; a plugin asking for 1.1 in manifest.json,
# as this one does, still loads in OxiGuitar 0.2 and later.
oxiguitar-plugin = { git = "https://github.com/mateusrdp/oxiguitar", tag = "v0.9.0" }
# The plugin's settings come and go as JSON objects (Host::settings).
serde_json = "1"
[dev-dependencies]
# The contract suite: tests/contract.rs builds the `.oxp` file and checks it
# through OxiGuitar's own plugin host.
oxiguitar-plugin-test = { git = "https://github.com/mateusrdp/oxiguitar", tag = "v0.9.0" }
[lints.rust]
# Deny `unsafe`, don't forbid it: `export_plugin!` allows it on the two
# attributes it generates (the entry point and the manifest's section).
unsafe_code = "deny"
[profile.release]
# Small files. Keep the default `panic = "unwind"`: a panic is then caught
# and reported as an error instead of closing OxiGuitar.
lto = "thin"
codegen-units = 1
strip = "symbols"
# Its own workspace, wherever the folder is copied.
[workspace]
Four things in it matter to OxiGuitar:
- The
oxpfeature.export_plugin!exports the entry point and the manifest’s section only with it, so the same crate is an ordinary library forcargo testand a plugin file when built with--features oxp --crate-type cdylib. Without the feature inCargo.toml, that build fails with “does not have the featureoxp”. unsafe_code = "deny", not"forbid": the two attributesexport_plugin!generates (#[unsafe(no_mangle)]and#[unsafe(link_section)]) needunsafe, and allow it for themselves only. You write nounsafecode.panic = "unwind", Rust’s default. A panic in a call is then caught, and the plugin is withdrawn for the rest of the session while OxiGuitar carries on; withpanic = "abort"it closes OxiGuitar.- The SDK’s tag:
v0.9.0, the release of OxiGuitar you build for; it has interface 1.3. A plugin loads in every OxiGuitar that has the interface version its manifest asks for, and in every later one: 0.2 and later for 1.0 and 1.1, 0.5 and later for 1.2, 0.9 and later for 1.3 (Interface versions).
The plugin
/// The plugin: built once, when OxiGuitar first uses one of its
/// contributions, and kept until the program ends.
pub struct Example {
statistics: statistics::Statistics,
}
impl Plugin for Example {
fn start(host: Host) -> Result<Example, Error> {
Ok(Example {
statistics: statistics::Statistics::new(host),
})
}
// Each method hands out a contribution the manifest declares, by its id.
fn reader(&self, format: &str) -> Option<&dyn FormatReader> {
(format == format::ID).then_some(&format::ExampleTab as &dyn FormatReader)
}
fn writer(&self, format: &str) -> Option<&dyn FormatWriter> {
(format == format::ID).then_some(&format::ExampleTab as &dyn FormatWriter)
}
fn service(&self, interface: &str, id: &str) -> Option<&dyn Service> {
(interface == service::INTERFACE && id == service::ID)
.then_some(&service::NoteNames as &dyn Service)
}
fn tab(&self, id: &str) -> Option<&dyn Tab> {
(id == statistics::ID).then_some(&self.statistics as &dyn Tab)
}
fn event(&self, event: &oxiguitar_plugin::Event) {
self.statistics.event(event);
}
}
// The plugin's entry point and manifest, and a test (under `cargo test`)
// that the manifest matches the code.
oxiguitar_plugin::export_plugin!(Example);
Plugin has one required method and five optional ones:
| Method | Called when | Returns |
|---|---|---|
start(host) | The plugin is first needed | The plugin, or an error: the plugin is then withdrawn, as if it weren’t installed |
reader(format) | A file of the format format (an id from the manifest) is opened | A FormatReader, or None |
writer(format) | A score is saved in the format | A FormatWriter, or None |
service(interface, id) | The service is probed or called | A Service, or None |
tab(id) | The tab is shown or used | A Tab, or None |
event(event) | Something the manifest’s events subscribe to happened | Nothing |
The ids are the manifest’s. Returning None answers “not found”, which the contract suite checks for a contribution the plugin doesn’t have; cargo test checks that every contribution the manifest declares is found.
Threads
Every call may come from any thread, several at once, so Plugin and every contribution are Send + Sync, and every method takes &self. Keep what changes behind a Mutex or in atomics, and don’t hold a lock while you call the host or do slow work: the template’s tab does
/// The tab. Calls may come from any thread at once, so what changes lives
/// behind a lock.
pub struct Statistics {
host: Host,
state: Mutex<State>,
}
#[derive(Default)]
struct State {
/// A score was opened or changed since it was last counted.
stale: bool,
/// The open score's title, and each track's counts; none when no
/// score is open.
title: String,
tracks: Vec<Counts>,
/// The track shown.
track: usize,
/// The string whose bar was clicked last, from 0 (string 1).
picked: Option<usize>,
/// Count notes tied to the one before; kept in settings.json.
count_tied: bool,
}
/// One track's notes, string by string.
#[derive(Clone, Debug, Default, PartialEq)]
struct Counts {
name: String,
/// Notes on each string, from string 1; empty for a track without
/// strings.
struck: Vec<u32>,
/// Of those, the notes tied to the one before.
tied: Vec<u32>,
}
and takes the lock only to read or change State.
The host
start receives a Host: the core’s services, as the plugin reaches them. Keep a clone wherever you need it (it is cheap to clone).
| Method | What it does | Since |
|---|---|---|
notify(level, text) | Shows a message in the footer, with the plugin’s name: Level::Warning or Error. The footer isn’t a log, so Level::Info messages aren’t shown | 1.0 |
settings() | The plugin’s settings: the JSON object under its id in settings.json (Settings) | 1.0 |
set_settings(map) | Replaces them and saves settings.json | 1.0 |
call_service(interface, method, input) | Calls the service OxiGuitar chose for interface, which the manifest must list under requires (Services) | 1.0 |
redraw(tab) | Asks the core to show one of the plugin’s tabs again (Tabs) | 1.1 |
score() | The open score, or None (Events) | 1.1 |
score2(), apply(revision, name, score) | The open score version 2 with its revision, and a change to it the user can undo (Tools) | 1.2 |
notify_with_link(level, text, link_text, url) | A footer message ending with a link that opens url in the browser; only https: addresses are shown | 1.3 |
folders() | The plugin’s own data and cache folders and the shared folders its manifest lists (Settings) | 1.3 |
Host::detached() answers like a host with no window, no settings and no services: for unit tests.
Errors
A call that fails returns an Error: a kind, one sentence for the user, and where it happened.
Error {
offset: Some(1032), // the byte in the input, if there is one
context: "bar 12, track 2".into(),
..Error::new(ErrorKind::Invalid, "a note is on string 9 of a six-string guitar")
}
| Kind | For |
|---|---|
NotFound | No such contribution, method or widget: Error::not_found(what) |
Unsupported | Input of a kind or version the plugin can’t handle (a newer file version) |
Invalid | Input that is damaged or breaks its rules |
UnexpectedEnd | Input that ends before the data it promises |
Failed | Anything else |
When a file can’t be read, the footer says FILE: message (in context) at byte offset; a crash or a malformed answer is named with your plugin’s name instead. Write the message for a musician, not a programmer: “the file ends in the middle of bar 12”, not “unexpected EOF in read_u32”.
Built in, and in tests
Without the oxp feature the crate is an ordinary library, and oxiguitar_plugin::dispatch(&plugin, request) answers a request the way the plugin file would, messages and all. Our own tests use plugins that way, and so will the mobile apps, which build their plugins in.
Formats: reading and writing files
A format plugin turns a file into a score, a score into a file, or both. OxiGuitar has no format of its own: what it opens is what the installed plugins read.
Declaring a format
"formats": [
{
"id": "extab",
"name": "Example tab",
"extensions": ["extab"],
"media_types": ["text/x-example-tab"],
"read": true,
"write": true,
"signatures": [{ "at": 0, "text": "EXAMPLE TAB" }]
}
]
| Key | What it is |
|---|---|
id | The format’s id in the plugin; requests name it |
name | Shown in dialogs and messages |
extensions | Lowercase, without the dot, the usual one first: for the Open and Save dialogs |
media_types | Optional, such as application/x-gtp |
read, write | What the plugin does with it; at least one |
signatures | How to recognise its files; a format that reads needs at least one |
Signatures
OxiGuitar recognises a file by its contents, from the manifests, before any plugin is loaded. Extensions are never used for that: they lie (four of the Guitar Pro 3 files in our test corpus are Guitar Pro 5). A file is of your format when any of its signatures matches, and a signature matches when every test it gives holds:
| Test | Matches when |
|---|---|
"text": "FICHIER GUITAR PRO v5" | This UTF-8 text is found at byte at (default 0) |
"hex": "42434658" | These bytes are found at byte at |
"zip_member": "Content/score.gpif" | The file is a ZIP archive holding a member of exactly this name |
Make a signature as narrow as the format allows: when two plugins’ signatures match one file, the first plugin in folder order reads it. A plugin that declares an extension but doesn’t recognise a file with it makes OxiGuitar ask the user for a bug report; a file nobody recognises gets a feature request instead. Both point at OxiGuitar’s issue tracker, so tell users in your README where to report your plugin’s bugs.
Reading
pub trait FormatReader: Send + Sync {
fn read(&self, data: &[u8]) -> Result<Read, Error>;
}
pub struct Read {
pub score: score::Score,
pub log: Vec<LogEntry>,
}
data is the whole file. Treat it as untrusted: any file a user has can be damaged or made on purpose to break readers. Every problem is an Error that says where (its offset in bytes, and a context such as “bar 12, track 2”), never a panic, an endless loop or an allocation of whatever size the file claims. The contract suite feeds every reader an empty file, a kilobyte of zeros and its own signature alone; fuzz yours too if it reads a binary format (Testing).
The log is what the file holds that the score can’t carry, or carries only roughly: an effect OxiGuitar doesn’t have, a value out of range that was clamped, a bar that didn’t add up. Report it rather than dropping it silently. Each entry has a level (Info when nothing was lost, Warning when something was), a message, the first place it happened, and a count: one entry per kind of problem, counted, not one per bar. oxiguitar-cli info FILE prints the log; the window doesn’t show it yet.
The template’s reader:
impl FormatReader for ExampleTab {
fn read(&self, data: &[u8]) -> Result<Read, Error> {
// The file is untrusted: anything wrong is an error saying where,
// never a panic.
let text = std::str::from_utf8(data)
.map_err(|e| invalid("the file is not UTF-8 text", e.valid_up_to(), ""))?;
let mut lines = lines(text);
if lines.next().map(|(_, line)| line.trim_end()) != Some(MAGIC) {
return Err(invalid("this is not an Example tab file", 0, ""));
}
let mut info = ScoreInfo::default();
let mut tempo = 120.0;
let mut bars = Vec::new();
let mut log = Vec::new();
for (at, line) in lines {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = header(line) {
match key {
"title" => info.title = value.into(),
"artist" => info.artist = value.into(),
"tempo" => {
tempo = value
.parse::<f64>()
.ok()
.filter(|t| (20.0..=400.0).contains(t))
.ok_or_else(|| invalid("the tempo is not 20 to 400", at, "tempo"))?;
}
_ => add(
&mut log,
Level::Info,
"lines the format doesn't know were ignored",
format!("the line \"{key}: …\""),
),
}
continue;
}
bars.push(bar(line, at, bars.len() + 1, &mut log)?);
}
if bars.is_empty() {
return Err(Error {
offset: Some(data.len() as u64),
..Error::new(
ErrorKind::UnexpectedEnd,
"the file ends before its first bar",
)
});
}
Ok(Read {
score: score(info, tempo, bars),
log,
})
}
}
The score
The score crosses the interface as its own types, oxiguitar_plugin::score (version 1 of the wire score). They mirror OxiGuitar’s model field for field, but belong to the interface: the model can change as the editor grows, and these don’t.
A plugin built for interface 1.2 can read and write version 2 instead (oxiguitar_plugin::score2), which holds what OxiGuitar added since, such as bars in free time, as lists of extensions (Interface versions). Implement FormatReader2 and FormatWriter2 and hand them out from reader2 and writer2; with both versions, the SDK uses version 2. A plugin with only a version-1 writer still gets every score: what version 1 can’t hold is added to its losses.
Score
├── info: ScoreInfo title, subtitle, artist, album, words, music, …
├── initial_tempo quarter notes per minute
├── master_bars: [MasterBar] time and key signature, repeats, endings, markers,
│ tempo changes, directions (segno, coda, D.S. …)
└── tracks: [Track] name, instrument (General MIDI), mix, chord diagrams, lyrics
└── staves: [Staff] tuning, capo, frets, notation and tab shown (two staves: a grand staff)
└── bars: [Bar] the clef; one bar for each master bar
└── voices: [Voice] one to four
└── beats: [Beat] a duration; notes, a rest or nothing; beat effects; a chord diagram
└── notes: [Note] string and fret (or a pitch); tie, dynamic, note effects
The conventions:
- Strings are numbered from 1, the highest.
Staff::tuninglists the open strings’ pitches from string 1 down; standard guitar tuning is[64, 59, 55, 50, 45, 40]. - Pitches are MIDI note numbers, 60 = middle C.
- Time is exact. Positions and lengths are
Ratios of a whole note (num / den,den> 0); durations are a note value, dots and tuplets, outermost first. - The shape must hold: every track has at least one staff; every staff has exactly one bar per master bar; every bar has at least one voice. A voice’s beats should fill its bar’s time signature;
oxiguitar-cli infochecks that (“Bar check”). - Show what the file says. Don’t infer keys, add notation the file didn’t ask for or fix the music; OxiGuitar has tools for that, which the user chooses.
Several types have no default (a master bar’s time signature, a beat’s duration, a note’s place, a bar’s clef), so you fill them in. The template’s score:
/// The score: one guitar track in standard tuning, every bar in 4/4.
fn score(info: ScoreInfo, tempo: f64, bars: Vec<Vec<Beat>>) -> Score {
let master_bar = MasterBar {
time_signature: TimeSignature {
numerator: 4,
denominator: 4,
},
key_signature: KeySignature {
fifths: 0,
mode: KeyMode::Major,
},
repeat_start: false,
repeat_end: None,
alternate_endings: 0,
marker: None,
double_bar: false,
triplet_feel: TripletFeel::None,
tempo_changes: Vec::new(),
directions: Vec::new(),
};
let staff = Staff {
tuning: STANDARD.to_vec(),
fret_count: FRETS,
show_standard: true,
show_tablature: true,
// One bar of the staff for each master bar, each with at least one
// voice: the core refuses a score shaped otherwise.
bars: bars
.iter()
.map(|beats| Bar {
clef: Clef::Treble8vb,
voices: vec![Voice {
beats: beats.clone(),
}],
})
.collect(),
..Staff::default()
};
Score {
info,
initial_tempo: tempo,
tempo_label: String::new(),
master_bars: vec![master_bar; bars.len()],
tracks: vec![Track {
name: "Guitar".into(),
short_name: "Gtr.".into(),
// General MIDI 25: steel-string acoustic guitar (from 0).
instrument: Instrument {
midi_program: 25,
percussion: false,
},
mix: Mix {
volume: 0.8,
pan: 0.0,
mute: false,
solo: false,
},
staves: vec![staff],
..Track::default()
}],
}
}
oxiguitar-cli info FILE and oxiguitar-cli render FILE --png page show what OxiGuitar makes of your score, faster than the window (Testing).
Writing
pub trait FormatWriter: Send + Sync {
fn write(&self, score: &score::Score) -> Result<Written, Error>;
}
pub struct Written {
pub data: Vec<u8>,
pub losses: Vec<LogEntry>,
}
A writer returns the whole file, and in losses everything the format can’t hold and left out, like the reader’s log: the user is told before anything is lost. It must answer any score the core can hold, the empty one included (the contract suite writes one). The template’s:
impl FormatWriter for ExampleTab {
fn write(&self, score: &Score) -> Result<Written, Error> {
let mut out = format!("{MAGIC}\n");
let mut losses = Vec::new();
for (key, value) in [("title", &score.info.title), ("artist", &score.info.artist)] {
if !value.is_empty() {
out.push_str(&format!("{key}: {}\n", value.replace('\n', " ")));
}
}
out.push_str(&format!(
"tempo: {}\n",
score.initial_tempo.clamp(20.0, 400.0)
));
// Whatever the format can't hold is left out and listed as a loss,
// so the user is told before anything is lost.
if score.tracks.len() > 1 {
let n = score.tracks.len() as u32 - 1;
add_n(&mut losses, "only the first track is written", "", n);
}
let Some(staff) = score.tracks.first().and_then(|t| t.staves.first()) else {
return Ok(Written {
data: out.into_bytes(),
losses,
});
};
if staff.tuning != STANDARD {
add(
&mut losses,
Level::Warning,
"the track is written in standard tuning",
String::new(),
);
}
for (i, bar) in staff.bars.iter().enumerate() {
let place = format!("bar {}", i + 1);
let four_four = TimeSignature {
numerator: 4,
denominator: 4,
};
if score
.master_bars
.get(i)
.is_some_and(|m| m.time_signature != four_four)
{
add_n(&mut losses, "every bar is written in 4/4", &place, 1);
}
if bar.voices.len() > 1 {
add_n(&mut losses, "only the first voice is written", &place, 1);
}
let beats = bar.voices.first().map_or(&[][..], |v| &v.beats[..]);
let mut tokens: Vec<String> = beats
.iter()
.map(|b| token(b, &place, &mut losses))
.collect();
if tokens.len() > PER_BAR {
add_n(
&mut losses,
"notes after the eighth of a bar are left out",
&place,
1,
);
tokens.truncate(PER_BAR);
}
if tokens.is_empty() {
tokens.push("-".into());
}
out.push_str(&tokens.join(" "));
out.push('\n');
}
Ok(Written {
data: out.into_bytes(),
losses,
})
}
}
Writers are declared and called through the plugin host today, and the contract suite checks them; the window’s Save and Export menus use them from the editor’s release (OxiGuitar 0.5).
One format per plugin
Our own plugins each read one format (Guitar Pro 3, 4, 5, 6 and 7–8 are five plugins), so a format can be added, improved or retired on its own, and a user loads only the code for files they open. You may put several formats in one plugin; consider whether your users would rather have them apart.
Tabs: widgets and canvases
A tab sits in one of the window’s four areas, beside the core’s own (Player, Tracks, Palette). Its contents are rows of standard widgets that the core draws in the window’s own style, so it looks like the rest of OxiGuitar and follows the dark or light interface by itself, and, if it needs one, a canvas the plugin draws. Tabs need interface 1.1.
Declaring a tab
"tabs": [{ "id": "statistics", "title": "Statistics", "area": "right" }]
| Key | What it is |
|---|---|
id | The tab’s id in the plugin |
title | Its name on the area’s tab strip: a word or two |
area | top (across the window, above the page), bottom (across, below), left or right (beside the page, narrower) |
The window shows the tab’s title from the manifest alone. The plugin is loaded when the user first opens the tab.
Contents
pub trait Tab: Send + Sync {
fn content(&self) -> TabContent;
fn on(&self, widget: &str, event: TabEvent) -> Result<(), Error> { Ok(()) }
}
content says what the tab shows now: TabContent { rows }, rows from top to bottom, each a Row { widgets } from left to right. The core asks for it when the tab is shown, after every on, and after the plugin asks for a redraw. It runs on a thread of the core’s, never the window’s, but keep it quick: the tab shows its old contents until it answers.
| Widget | Shows | The user’s event (TabEvent) |
|---|---|---|
Label { text, muted } | Text; dimmer when muted | None |
Button { id, text, enabled } | A push button | Clicked |
Toggle { id, text, on, enabled } | A tickbox with its text | Toggled(bool) |
Slider { id, value, min, max, enabled } | A slider, min < max | Slid(f32), when it is let go |
Field { id, text, placeholder, enabled } | One line of text to type | Edited(String), when Enter is pressed |
Choice { id, options, selected, enabled } | A drop-down | Chosen(u32), the option’s index |
List { id, items, selected } | Lines, one of which may be selected | Chosen(u32), the line’s index |
Canvas(Canvas { id, width, height, items }) | A drawing of the plugin’s own | Pointer(Pointer) |
Every widget the user can use has an id, unique in the tab (a valid id: lowercase letters and digits joined by . or -). When the user uses one, the core calls on(id, event), then content again. The widgets hold no state of their own: a toggle shows on as your last content said, so on must remember what changed.
An error returned from on is shown in the footer, as a warning, and the tab keeps showing its contents (the core asks for them again). Return one when the plugin refuses the event; for something the user should just know, call host.notify and return Ok(()), as the template does when it can’t save a setting.
The template’s tab:
fn content(&self) -> TabContent {
self.refresh();
let s = self.state();
let label = |text: String, muted: bool| Widget::Label { text, muted };
if s.tracks.is_empty() {
return TabContent {
rows: vec![Row {
widgets: vec![label("Open a score to count its notes".into(), true)],
}],
};
}
let counts = s.per_string();
let total: u32 = counts.iter().sum();
let detail = match s.picked.and_then(|i| Some((i, counts.get(i)?))) {
Some((i, n)) => format!("String {}: {n} notes", i + 1),
None if counts.is_empty() => "This track has no strings".into(),
None => format!("{total} notes; click a bar"),
};
let mut rows = vec![
Row {
widgets: vec![label(s.title.clone(), false)],
},
Row {
widgets: vec![Widget::Choice {
id: "track".into(),
options: s.tracks.iter().map(|t| t.name.clone()).collect(),
selected: s.track as u32,
enabled: s.tracks.len() > 1,
}],
},
Row {
widgets: vec![Widget::Toggle {
id: "tied".into(),
text: "Count tied notes".into(),
on: s.count_tied,
enabled: true,
}],
},
Row {
widgets: vec![label(detail, s.picked.is_none())],
},
];
if !counts.is_empty() {
rows.push(Row {
widgets: vec![Widget::Canvas(Canvas {
id: "chart".into(),
width: WIDTH,
height: HEIGHT,
items: draw(&counts, s.picked),
})],
});
}
TabContent { rows }
}
and what it does with the user’s events:
fn on(&self, widget: &str, event: TabEvent) -> Result<(), Error> {
match (widget, event) {
("chart", TabEvent::Pointer(pointer)) => {
let mut s = self.state();
let strings = s.per_string().len();
if let Some(string) = bar_at(pointer, strings) {
s.picked = Some(string);
}
}
("track", TabEvent::Chosen(i)) => {
let mut s = self.state();
if (i as usize) < s.tracks.len() {
s.track = i as usize;
s.picked = None;
}
}
("tied", TabEvent::Toggled(on)) => {
self.state().count_tied = on;
// Not being able to save it is worth knowing, not worth
// losing the tab over.
if let Err(e) = self.save(on) {
let text = format!("\"Count tied notes\" can't be remembered: {e}");
self.host.notify(Level::Warning, text);
}
}
(widget, event) => {
return Err(Error::new(
ErrorKind::Invalid,
format!("the widget {widget:?} doesn't take {event:?}"),
));
}
}
// The core asks for the contents again after every event.
Ok(())
}
Contents the core refuses
Contents with an id missing or used twice, a slider whose range is backwards, a choice or list selecting what it doesn’t have, or a canvas that is too large or holds something it can’t draw break the interface’s contract: the plugin is withdrawn for the rest of the session, its tabs with it, and the footer names the first problem. TabContent::problems() gives the same sentences, and cargo test runs it on every tab’s first contents; the contract suite also uses each widget once (a click, a tick, the slider’s lowest value, an empty field, the first option, a press, drag and release in the middle of a canvas) and checks the contents after each.
Redrawing
A tab whose contents change on their own (after an event, a timer, a finished download) calls host.redraw("statistics"). The core asks for content when the tab is next on screen; a tab no one is looking at costs nothing. Nothing polls: don’t ask for redraws on a timer to look for changes.
The canvas
A canvas is a drawing in its own units: width × height, with x to the right and y down from the top-left corner. The core scales it to the room the tab has, keeping its proportions, and reports the pointer in the same units, so you never deal with pixels or screen scales.
Its items are drawn in order, later ones over earlier ones (oxiguitar_plugin::display):
| Item | Draws |
|---|---|
Line { from, to, width, paint } | A straight line with square ends |
Rect { at, width, height, paint } | A filled rectangle, at its top-left corner |
Path { cmds, fill, stroke } | A shape of MoveTo, LineTo, CubicTo and Close, filled (non-zero winding), stroked ((width, paint)), or both |
Text { text, at, size, font, anchor, paint } | One line of text on its baseline at at, starting, centred or ending there (Anchor::Start, Middle, End); size is the font’s em in canvas units |
Colours are roles, resolved by the window’s theme, so a canvas follows the dark interface by itself:
Paint | For |
|---|---|
Ink | Lines, shapes and text |
Page | The canvas’s background colour: for knocking shapes out |
Accent | Highlights: the selection, the playing note |
Muted | Secondary marks |
Text crosses as text, and the core draws it with its own fonts: Font::Sans, the window’s text font, or Font::Music, the music font (Bravura), where the text is SMuFL code points and size is a five-line staff’s height, as in engraving ("\u{E050}" is a G clef). A plugin needs no fonts of its own.
The template draws a bar chart, one bar per string:
/// A bar per string, as tall as its share of the busiest string, with the
/// strings' numbers under them. Paints are roles, so the chart follows the
/// window's theme.
fn draw(counts: &[u32], picked: Option<usize>) -> Vec<Item> {
let most = counts.iter().copied().max().unwrap_or(0).max(1) as f32;
let slot = slot(counts.len());
let room = HEIGHT - LABELS;
let mut items = vec![Item::Line {
from: Point::new(0.0, room),
to: Point::new(WIDTH, room),
width: 0.5,
paint: Paint::Muted,
}];
for (i, &n) in counts.iter().enumerate() {
let x = i as f32 * slot;
let height = room * n as f32 / most;
items.push(Item::Rect {
at: Point::new(x + slot * 0.15, room - height),
width: slot * 0.7,
height,
paint: if picked == Some(i) {
Paint::Accent
} else {
Paint::Ink
},
});
items.push(Item::Text {
text: (i + 1).to_string(),
at: Point::new(x + slot / 2.0, HEIGHT - 2.0),
size: 8.0,
font: Font::Sans,
anchor: Anchor::Middle,
paint: Paint::Muted,
});
}
items
}
Limits
A canvas is at most 100,000 units a side and holds at most 100,000 items, and every number in it is finite. Everything stays within reach of the canvas: every point lies within the canvas extended by its own size on every side, and no size, line width or text size is more than three times its longer side. Text running further is cut there. These keep a mistaken plugin from costing the window time; a real drawing never comes near them.
The pointer
pub struct Pointer {
pub kind: PointerKind, // Down, Drag (moved with a button held) or Up
pub at: Point, // in the canvas's units
pub button: PointerButton, // Primary, Secondary or Middle
}
A press gives Down, moving with the button held gives Drags, letting go gives Up (wherever the pointer is). Drags the plugin hasn’t answered yet are merged, so a slow plugin gets the latest position rather than a backlog. There is no hover: the pointer is reported only while a button is held. The template finds the bar under a press:
/// The string whose bar is under the pointer when a button goes down.
fn bar_at(pointer: Pointer, strings: usize) -> Option<usize> {
let Point { x, y } = pointer.at;
let inside = (0.0..WIDTH).contains(&x) && (0.0..HEIGHT).contains(&y);
(pointer.kind == PointerKind::Down && inside && strings > 0)
.then(|| ((x / slot(strings)) as usize).min(strings - 1))
}
Tips
- One tab, one purpose. A left or right area is narrow: a column of widgets fits better there than a wide canvas. The bottom area is as tall as the track list (up to five tracks) until the user drags its splitter, so a tab there should still make sense when short. A canvas shrinks to fit, keeping its proportions, but a tab doesn’t scroll: rows that don’t fit are cut off, so put buttons and toggles above a canvas, as the template does.
- Lay a canvas out in round units of your own (the template’s chart is 120 × 60) and let the core scale it.
- Keep
contentcheap: build it from state you already have. Do slow work (reading the score, a download) when an event or a click asks for it, and callredrawwhen it’s done. - A plugin’s settings belong in its own tab; the core draws no settings forms (Settings).
Events and the open score
A plugin that follows what the user does subscribes to events: OxiGuitar pushes each one to it as it happens. Nothing polls, and a plugin that wants nothing hears nothing. Events need interface 1.1.
Subscribing
"events": ["score"]
| Kind | Events | Sent today |
|---|---|---|
score | ScoreOpened { revision }, ScoreChanged { revision }, ScoreClosed | Yes |
selection | Selection(Some(Selection { track, from, to })), or Selection(None) when nothing is selected; a plugin built for interface 1.2 gets Selection2 instead, with voice, string and several tracks (Tools and changes) | From the editor (OxiGuitar 0.5) |
playback | Playback(Playback { playing, at, seconds }): playback started or stopped, the cursor reached another beat, or it jumped | From the player (OxiGuitar 1.0) |
Positions are a bar and a beat of the bar’s first voice, both from 0; tracks are numbered from 0. A score’s revision is a number that changes with every score opened or changed in the session.
Receiving
fn event(&self, event: &Event) { … }
Plugin::event is called on one thread of the host’s, which delivers every plugin’s events in order. Keep it short: note what happened, call host.redraw for a tab that should show it, and return. While one plugin’s event runs, the next plugin’s events wait.
Events that say where something is now (the selection, the playback position, a score’s revision) replace one of the same kind still waiting for your plugin, so a slow plugin gets the latest rather than a backlog. A plugin that panics on an event is withdrawn, like any plugin that fails (What a plugin is).
The template’s tab:
/// A score event (the manifest subscribes to `score`). It comes on the
/// host's event thread: note it and ask for a redraw, and leave the work
/// to `content`, which runs only while the tab is on screen.
pub fn event(&self, event: &Event) {
match event {
Event::ScoreOpened { .. } | Event::ScoreChanged { .. } => self.state().stale = true,
Event::ScoreClosed => {
let count_tied = self.state().count_tied;
*self.state() = State {
count_tied,
..State::default()
};
}
_ => return,
}
self.host.redraw(ID);
}
The open score
Score events carry no score. A plugin that needs it asks:
let score: Option<oxiguitar_plugin::score::Score> = host.score()?;
None means no score is open. The core converts the score into the interface’s types for each request, which takes milliseconds for a short song and tens of milliseconds for a long one, so ask once per revision, when you need it, rather than in every event or every content. The template asks the next time its tab is drawn, and only if a score event came since:
/// Counts the open score's notes, if a score event came since.
fn refresh(&self) {
if !std::mem::take(&mut self.state().stale) {
return;
}
// Not with the state locked: converting a long score takes a while.
let score = self.host.score();
let mut s = self.state();
match score {
Ok(Some(score)) => {
s.title = score.info.title.clone();
s.tracks = count(&score);
}
Ok(None) | Err(_) => s.tracks.clear(),
}
s.track = s.track.min(s.tracks.len().saturating_sub(1));
s.picked = None;
}
The score you get is a copy: changing it changes nothing in OxiGuitar. Edits will go through the core as undoable commands, from the editor’s release.
Tools and changes to the score
Since interface 1.2, a plugin can add entries to the Tools menu and change the open score. Every change goes through the core, as one step the user can undo and redo: a plugin never changes the score behind the editor’s back.
Tools
List each tool under tools in the manifest, with an id, the menu entry’s name in title case, and a one-sentence description (oxiguitar-cli tools shows it):
"tools": [
{ "id": "octave-up", "name": "Up an Octave", "description": "Moves every note of the score up an octave." }
]
Your Plugin hands out each one from tool(id), as something implementing Tool. When the user picks the entry, the core calls its run, on a thread of the core’s: return a sentence for the footer about what it did (“Moved 312 notes up an octave.”), or an empty one to say nothing, or an error saying why it couldn’t run. A tool runs with no score open too (the contract suite tries it), so check.
Changing the score
A tool, a tab’s button or anything else in your plugin changes the score the same way:
- Ask for it:
host.score2()gives the score, version 2, and its revision, a number that changes with every change to the score (score events carry it too).Nonewhen no score is open. - Change your copy.
- Hand it back:
host.apply(revision, "Up an Octave", score). The name is what the Edit menu shows (“Undo Up an Octave”), a few words.
The core takes the whole score and records the difference as one change; the page shows it a moment later, and plugins that listen hear Event::ScoreChanged. If the score changed since your revision (the user typed a note while your tool worked), the core refuses the change with an error: ask for the score again and redo it, or tell the user. A score the core can’t hold (a staff missing bars, a fraction with a zero denominator) is refused the same way.
The selection
Built for 1.2, a plugin that lists selection under events hears Event::Selection2, the selection as the editor has it, instead of 1.1’s Event::Selection:
Caret { track, staff, voice, bar, beat, string }: nothing selected, the caret on a beat of a voice (beats count in their own voice) and on a string of the tablature (from 1, the highest; 0 on a staff without strings);Beats { track, staff, voice, from, to }: a run of beats of one voice;Bars { first_track, last_track, first_bar, last_bar }: whole bars across tracks;
and None when no score is open. Everything counts from 0 but the string.
The score, version 2
host.score2(), Host::apply, and the readers and writers of reader2 and writer2 (Formats) use oxiguitar_plugin::score2: version 1’s tree, each part ending in a list ext of extensions. Read the ones you know (MasterBar2::free_time()), keep the others as they are when you hand a score back, and add only what your format holds.
Services
A service is one implementation of an interface: a named set of methods that whoever defines it documents. Several plugins may implement the same interface for different systems or environments (an audio device through PipeWire, ALSA, CoreAudio or WASAPI, say), and OxiGuitar chooses one that works here. Plugins call services through the host, so a plugin can build on another without knowing which one is installed.
The core defines its interfaces as it needs them (the audio device comes with the player). A plugin may define its own, for other plugins: the template’s note-names names a pitch.
Implementing one
"services": [
{ "interface": "note-names", "id": "example", "name": "Example note names",
"priority": 0, "conditions": { "os": ["linux"] } }
]
| Key | What it is |
|---|---|
interface | The interface it implements |
id | Its id, unique for that interface in the plugin |
name | Shown to the user |
priority | Higher wins when several can run and settings.json pins none (default 0) |
conditions | Where it can run, beyond the plugin’s own conditions (The manifest) |
pub trait Service: Send + Sync {
fn probe(&self) -> Availability { Availability::Available }
fn call(&self, method: &str, input: &[u8]) -> Result<Vec<u8>, Error>;
}
The template’s:
impl Service for NoteNames {
fn probe(&self) -> Availability {
// Whether it can work here; a service that needs a device or a
// server checks for it. This one always works.
Availability::Available
}
fn call(&self, method: &str, input: &[u8]) -> Result<Vec<u8>, Error> {
match (method, input) {
("name", &[pitch, flats]) if pitch <= 127 && flats <= 1 => {
Ok(notes::name(Pitch(pitch), flats == 1).into_bytes())
}
("name", _) => Err(Error::new(
ErrorKind::Invalid,
"name takes two bytes: a MIDI note number and 0 or 1 for flats",
)),
_ => Err(Error::not_found(format!(
"note-names has no method {method:?}"
))),
}
}
}
probe says whether the service can work here: a sound server is running, a device is present. Answer Availability::Unavailable("PipeWire isn't running".into()) otherwise; the text is shown to the user when nothing else works.
call takes a method’s name and its input as bytes, and answers with its output as bytes; what the bytes hold is the interface’s to define. For anything beyond a few bytes, encode a Rust type with oxiguitar_plugin::api::encode and decode (postcard, as the interface’s own messages use), and publish the type in a small crate both sides depend on.
How OxiGuitar chooses
For an interface, the candidates are the services of every usable plugin whose conditions hold here.
- If settings.json pins one, as
"services": {"note-names": "yourname.example/example"}(PLUGIN-ID/SERVICE-ID), it is tried first. - Otherwise the highest
prioritycomes first, then plugin order. - Each candidate’s
probeis asked in turn (which loads its plugin), and the first that says yes is used for the rest of the session.
The contract suite checks that every service you declare answers its probe.
Calling one
List the interface under requires in the manifest, then call it through the host:
"requires": ["note-names"]
let name = host.call_service("note-names", "name", &[61, 0])?; // "C♯4"
A plugin may call only the interfaces it requires. When no installed plugin implements one, or none works here, the call returns an error saying so: handle it, and tell the user in your README which plugins yours needs.
The audio device (interface 1.3)
audio-device is the core’s own interface: the service that sends OxiGuitar’s sound to the system. The basic plugins implement it for PipeWire and ALSA (Linux), WASAPI (Windows) and Core Audio (macOS), through cpal. Its types are in oxiguitar_plugin::api::audio_device.
| Method | Input | Output |
|---|---|---|
open | Open { stream, rate, frames, device } | Opened { rate, channels, frames, device } |
close | the stream’s number, a u64 | nothing |
devices | nothing | the outputs’ names, a Vec<String> |
open opens a stream on the output named (empty: the system’s default) at the rate asked for (48 000), or at the device’s own when it refuses that one, and starts it. From then on, in the device’s audio callback, the plugin has the core fill each buffer:
let out = host.audio_out().ok_or(...)?; // None before interface 1.3
// In the callback, for every buffer:
out.render(stream, buffer, channels, delay_ns);
buffer is interleaved frames of channels channels, and delay_ns is how long until they reach the speaker (the device’s latency), which the cursor follows. The callback is the audio thread: render never blocks, and your callback must not either. After close returns, never call render for that stream again. When a stream breaks (the device unplugged, the sound server gone), say so with host.notify.
Users pick a device by pinning it in settings.json, as "services": {"audio-device": "oxiguitar.audio-alsa/alsa"}. The test plugin’s fake device (priority -100) renders into nothing, so the contract tests can play a whole stream without a sound card.
Instruments
An instrument turns the player’s timed events into sound for the tracks given to it: a MIDI synthesiser, a modelled guitar amplifier, sampled drums. The core plays the score: it turns it into a timeline of notes with every effect, tempo, the metronome and count-in, runs the transport, and mixes the instruments’ sound (volume, pan, mute, solo) before an audio-device service sends it to the system.
The instrument interface is interface 1.3 (OxiGuitar 0.9 and later). The player ignores instruments declared by a plugin built for an older interface.
How an instrument works
- Prepared, off the audio thread. When a song is opened or its sounds change, the core asks the instrument to get ready for the tracks it plays (
Instrument::prepare, given aPrepare: the sample rate, the largest piece it will be asked for, and each track’s program, tuning, capo and its track section’s settings). Load the sounds now, and touch their memory, so nothing waits later. It hands back aRenderer. - Rendered, on the audio thread. Each device callback is split at every event, and each piece goes to
Renderer::renderas aPiece:events()all happen on the piece’s first frame: a note starts (RtEventKind::NOTE_ON), ends (NOTE_OFF, the same note, byid), or every note stops at once (ALL_OFF: stop, seek, loop). A piece may have no frames, only events.- A note (
RtNote) carries what it means, not MIDI: its track’s place, key, velocity, string, sound (normal, dead, palm mute, harmonic of a kind), attack (picked, legato, tapped, slapped, popped), flags (ghost, let ring, fade in or out, grace, vibrato), and its start and end in the score’s seconds. Its pitch curve (bends, the whammy bar, slides) ispiece.pitch_of(¬e), andpiece.cents_at(¬e, frame)follows it frame by frame, whatever the speed. The curves are runs of one array,piece.pitch(), which stays put while anything sounds, so a renderer that follows a note across calls keeps the note (or itspitch_startandpitch_len) and finds its run again in the next call’s array (in the SDK of 0.9.0 and later). piece.track(slot)is a track’s left and right buffers, zeroed: add into them.time()andstep()give the score’s time at the first frame and its seconds per frame, which change with the speed. The pitch never does: playing slower re-times the notes.
- Released, when the core is done with it, off the audio thread: the renderer is dropped.
Two numbers describe it: latency(), the frames its sound comes late by (the cursor moves back by it), and resolution(), the most frames late a note may start (a synthesiser rendering in blocks of 8 starts notes on the next block: 7). Both default to 0.
The audio thread’s rules: in render, never allocate, lock, wait or make a system call. A device callback lasts a few milliseconds, and a late one is a click the listener hears. The contract suite checks each instrument: it prepares, a note starts within its resolution and not before, nothing is allocated while it renders (the SDK installs a counting allocator in every .oxp, so don’t set a #[global_allocator] of your own), and on Linux no system call is made while it renders. A lock no other thread holds makes no system call, so the suite can’t see that one: keep locks off the audio thread altogether.
Effects
An effect works on one track’s sound after its instrument, in the order the track lists them: NAM is the first. Declare it under effects (the same shape as an instrument, by class), and implement Effect::prepare, which hands back a Processor: process(left, right) changes the track’s buffers in place, under the same rules.
Tracks and classes
-
One instrument per track. Each track chooses its instrument, with one default for the rest.
-
Instrument classes. The manifest says which tracks an instrument suits, from a fixed set of classes the core derives from each track’s General MIDI program and drum flag:
Class Tracks frettedGuitars and basses with frets, banjo, sitar fretlessFretless bass, shamisen, koto bowedViolin, viola, cello, contrabass, string ensembles, fiddle keyboardPianos, organs, chromatic percussion voiceChoirs and voices windsBrass, reeds, pipes, bagpipe, shanai synthSynth basses, leads, pads and effects drumsDrum-kit tracks otherHarp, timpani, orchestra hit, kalimba, percussive and sound effects "instruments": [ { "id": "kit", "name": "Realistic drums", "plays": ["drums"] }, { "id": "synth", "name": "Everything but drums", "does_not_play": ["drums"] } ]playslists what it covers (absent: everything);does_not_playtakes classes out. The player offers an instrument only for the tracks it plays. -
Large data (sound sets, amplifier models) is files the user puts in a folder, such as the
soundfontsfolder besideplugins; OxiGuitar and our plugins download nothing (Mateus, 21 Oct 2026). What a plugin makes from them (a cache) goes in its data folder. The player’s design: docs/design/player.md.
Settings
A plugin keeps its settings as a JSON object under its id in OxiGuitar’s settings.json:
{
"view": { "dark_interface": true },
"yourname.example": { "flats": true }
}
It reads and writes only its own object, through the host:
let count_tied = host
.settings()
.ok()
.and_then(|s| s.get("count_tied").and_then(|v| v.as_bool()))
.unwrap_or(false);
/// Keeps `count_tied` in the plugin's settings, beside whatever else is
/// there.
fn save(&self, count_tied: bool) -> Result<(), Error> {
let mut settings = self.host.settings()?;
settings.insert("count_tied".into(), count_tied.into());
self.host.set_settings(&settings)
}
settings()returns the object (empty when there is none). It is aserde_json::Map, so addserde_json = "1"to your dependencies.set_settings(map)replaces the whole object and saves settings.json at once. Read, change, write: keys you leave out are gone.- The app and every plugin write through one shared copy of settings.json, so none overwrites another’s keys. A settings.json that couldn’t be read when OxiGuitar started is never written, so settings then last for the session only.
set_settingsreturns an error when the file can’t be written (a full disk, a folder the user can’t write to): handle it (the template tells the footer and carries on). - Show them in a settings window (interface 1.3): with
"settings_window": truein the manifest andPlugin::settings_windowreturning aTab, the plugin manager (Tools → Plugins) gives the plugin a Settings… button, which opens a window of the plugin’s widgets: the same widgets as a tab’s, without a canvas. Itsonreads the settings, changes them and writes them back. Before 1.3, put them in a tab of your own (Tabs). - Users can also edit settings.json by hand while OxiGuitar is closed, so read it defensively: a value of the wrong type falls back to your default, as the template’s
and_then(|v| v.as_bool())does.
Settings are for small values, and they are global. What belongs to one track of one score goes in a section of the track dialog instead.
Folders (interface 1.3)
host.folders() hands the plugin its folders, as paths:
| Folder | Where | For |
|---|---|---|
data | $XDG_DATA_HOME/oxiguitar/data/<plugin id>/ on Linux, data/<plugin id>/ in the user folder elsewhere | What the plugin keeps until the user deletes it |
cache | $XDG_CACHE_HOME/oxiguitar/<plugin id>/ on Linux, ~/Library/Caches/OxiGuitar/<plugin id>/ on macOS, %LOCALAPPDATA%\OxiGuitar\cache\<plugin id>\ on Windows | What it can make again (a sound set decoded once); the system or the user may empty it at any time |
shared | Beside plugins in the user folder: soundfonts, nam | Files the user puts there for plugins to read, one entry per folder the manifest lists under "folders" |
The host makes the plugin’s own two folders the first time it asks, and the shared folders a manifest lists when OxiGuitar starts, so the user finds them before any plugin runs. Nothing is ever downloaded into them: OxiGuitar and its plugins download nothing.
Track sections
A plugin can add rows of its own to the track dialog (interface 1.3), under a heading, for the tracks it cares about: the NAM plugin adds its amp model and impulse response for guitar tracks. What the user sets there belongs to that track of that score.
Declare each section in the manifest:
"track_sections": [
{ "id": "amp", "title": "Amp", "plays": ["fretted", "fretless"] }
]
plays and does_not_play pick the tracks by instrument class, as instruments do; without them a section shows for every track. Then return it from Plugin::track_section(id), as a value implementing TrackSection:
content(track, settings)returns the rows to show: the widgets of a tab, without a canvas.trackis aTrackInfo(its index, name and class);settingsis the track’s settings for this plugin, a JSON object, empty the first time.on(track, settings, widget, event)changessettingsto match what the user did; the section is shown again from the new settings.
The plugin keeps nothing itself: OxiGuitar holds each track’s settings and sends them with every call. While the dialog is open they change with every use of a widget; OK applies them with the rest of the dialog as one change the user can undo, and Cancel drops them. An error from on is said in the footer, and the settings stay as they were.
No score format holds a plugin’s settings, so OxiGuitar saves them beside the score: song.gp3 keeps them in song.gp3.oxg, a small JSON file, one entry per track. Saving the score writes it, opening reads it, and oxiguitar-cli convert carries it to the new file. A track whose place changed (because another program moved tracks) finds its entry by name. A plugin gets a track’s settings again when the player prepares that track (with the instrument interface).
The SDK re-exports serde_json as oxiguitar_plugin::serde_json, so a plugin needs no dependency of its own for the settings.
Testing
cargo test
In the template, cargo test runs three kinds of test:
-
Your unit tests, on the plugin as an ordinary Rust library: call your reader on sample files, your tab’s
onandcontent, your service’scall, withHost::detached()where a host is needed. The template’s are in each module ofsrc/. -
the_manifest_matches_the_plugin, whichexport_plugin!adds: the manifest is valid and has no unknown key, its interface is one the SDK has, its version is the crate’s, every declared format, service, tab, tool, settings window and track section is implemented, and the first contents of every tab, settings window and track section can be shown. -
The contract suite, in
tests/contract.rs:#[test]
fn the_plugin_file_keeps_the_contract() { let crate_folder = Path::new(env!(“CARGO_MANIFEST_DIR”)); // Builds target/oxp/example.oxp: the package’s name, then the file’s. let oxp = oxiguitar_plugin_test::build(crate_folder, env!(“CARGO_PKG_NAME”), “example”, false) .expect(“the .oxp file builds”); let report = oxiguitar_plugin_test::check(&oxp); for passed in &report.passed { println!(“ok {passed}”); } assert!(report.ok(), “failed: {:#?}”, report.failed); }
It builds the crate as `target/oxp/example.oxp`, copies it into a plugin folder of its own, and checks it through OxiGuitar's own plugin host, as the app loads it:
- the manifest reads without loading the plugin;
- it loads and starts;
- a contribution it doesn't have is "not found", not a crash;
- every format it reads answers an empty file, a kilobyte of zeros and its own signature alone with a score or an error, never a crash;
- every format it writes answers an empty score;
- every service answers its probe;
- every tab's contents can be shown, before and after a plausible use of each widget;
- every kind of event it subscribes to is taken without an error;
- every tool runs with no score open;
- its settings window and its track sections show contents that can be shown, before and after a plausible use of each widget, and a section hands back its track's settings as a JSON object;
- every instrument and effect prepares; an instrument's note starts within its resolution and not before; neither allocates while rendering, and on Linux neither makes a system call ([Instruments](instruments.md)).
A test that loads a plugin file must build it once per test program and never rebuild it while it is loaded: a system loads a library file once per process (and Windows refuses to replace a loaded file). One contract test per crate, as in the template, keeps to that.
## oxiguitar-plugin-test
The contract suite is also a program, for plugin files you have built:
```sh
cargo install --git https://github.com/mateusrdp/oxiguitar --tag v0.9.0 --locked oxiguitar-plugin-test
oxiguitar-plugin-test target/oxp/example.oxp
oxiguitar-plugin-test --build example-plugin example # build PACKAGE as NAME.oxp, then check it
It prints every check, passed (ok) or not (FAIL, with why), every footer message the plugin sent, and how long its manifest took to read, and exits with an error if any check failed. Run it on each system’s file in CI: the template’s workflow does it through cargo test.
oxiguitar-cli
oxiguitar-cli opens files through plugins exactly as the window does, without a window: quicker than the app for a format plugin.
oxiguitar-cli detect song.extab # which format and plugin recognise it
oxiguitar-cli info song.extab # the score's outline, the bar check and the import log
oxiguitar-cli info --bars song.extab # and every bar of every track
oxiguitar-cli render song.extab --png page # page-1.png, … as the window engraves it
It comes with the Linux packages and the Windows zip; elsewhere, cargo install --git https://github.com/mateusrdp/oxiguitar --tag v0.9.0 --locked oxiguitar-cli. It reads the same plugin folders as the app, so put your file in a plugin folder first (below).
In the app
Try your plugin in a throwaway OxiGuitar home rather than your own, so a mistake can’t touch your settings: OXIGUITAR_HOME puts settings.json and the plugin folder in one folder.
mkdir -p /tmp/og/plugins
cp target/oxp/example.oxp /tmp/og/plugins/
OXIGUITAR_HOME=/tmp/og oxiguitar samples/open-strings.extab
(On Windows: set OXIGUITAR_HOME=C:\temp\og before oxiguitar.exe.) Then:
- Tools → Plugins lists every plugin file found, with its state: loaded, not loaded yet, replaced by another file of the same plugin, or why it can’t be used (a manifest problem, another system’s file, a newer interface, conditions that don’t hold). A plugin is loaded the first time one of its contributions is used.
- The footer (the line at the bottom; click it for the session’s messages) shows your warning and error
notifymessages, errors from your calls, and, if your plugin fails, the line saying it was withdrawn and why (a crash’s panic message, say). It isn’t a log:Level::Infomessages and import logs don’t show there;oxiguitar-cli infoprints a file’s import log. oxiguitar --safe-modestarts with no plugin loaded at all, while Tools → Plugins still lists them and can disable one: the way out when a plugin breaks the app.- Help → About lists the plugin folders in the order they are searched.
The app loads a plugin file once: after building it again, copy the new file over and restart OxiGuitar.
Fuzzing
A reader of a binary format should be fuzzed, so that no file can crash it. With cargo-fuzz (nightly Rust), a target is a few lines:
#![no_main]
use oxiguitar_plugin::FormatReader;
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let _ = example_plugin::format::ExampleTab.read(data);
});
Start it from a few real files of the format, and run it for hours rather than minutes; ours run six hours per reader before a release.
Building for every system
A plugin file holds native code for one system and processor, so you build one file for each system you support. OxiGuitar runs on six targets, which make five files (one macOS file serves both Mac processors):
| File | Rust target | Library cargo builds |
|---|---|---|
| Linux x86_64 | x86_64-unknown-linux-gnu | libLIBRARY.so |
| Linux ARM64 | aarch64-unknown-linux-gnu | libLIBRARY.so |
| Windows x86_64 | x86_64-pc-windows-msvc | LIBRARY.dll |
| Windows ARM64 | aarch64-pc-windows-msvc | LIBRARY.dll |
| macOS, universal | aarch64-apple-darwin and x86_64-apple-darwin, joined | libLIBRARY.dylib |
LIBRARY is your package’s name with _ for - (example_plugin).
One build
The template’s build-oxp.sh builds the release file for the system it runs on (on Windows, from Git Bash):
./build-oxp.sh
# dist/example-0.1.0-linux-x86_64.oxp
Change its NAME (your file’s name) and LIBRARY (above) when you rename the plugin. What it does is one cargo command and a copy:
cargo rustc --release --features oxp --crate-type cdylib
cp target/release/libexample_plugin.so dist/example-0.1.0-linux-x86_64.oxp
with libexample_plugin.dylib on macOS and example_plugin.dll on Windows. On macOS it builds both processors and joins them with lipo into one universal file:
rustup target add aarch64-apple-darwin x86_64-apple-darwin # once
for target in aarch64-apple-darwin x86_64-apple-darwin; do
cargo rustc --release --features oxp --crate-type cdylib --target "$target"
done
lipo -create -output dist/example-0.1.0-macos-universal.oxp \
target/aarch64-apple-darwin/release/libexample_plugin.dylib \
target/x86_64-apple-darwin/release/libexample_plugin.dylib
The template’s release profile (lto = "thin", codegen-units = 1, strip = "symbols") keeps files small: our Guitar Pro plugins are 475 to 796 KB. Stripping keeps the entry point, which is exported. Check each file before you publish it: oxiguitar-plugin-test dist/*.oxp (Testing).
Every system, on GitHub
The template’s .github/workflows/build.yml does all of this on GitHub’s runners, each system on its own kind of machine, so nothing is cross-compiled but the Intel half of the macOS file. For each of the five files it:
- runs
cargo teston that system, the contract suite included; - runs
build-oxp.sh, which names the fileNAME-VERSION-SYSTEM-ARCH.oxp(example-0.1.0-linux-x86_64.oxp,example-0.1.0-macos-universal.oxp), the way OxiGuitar’s own plugins are named; - keeps it as a workflow artifact.
When you push a tag starting with v (git tag v0.1.0 && git push --tags), a last job creates a GitHub release with the five files and a SHA256SUMS file. Users drop the file as it is into a plugin folder; the name doesn’t matter to OxiGuitar.
The Linux files are built on Ubuntu 24.04, so they need glibc 2.39 or newer, like OxiGuitar’s own packages.
Before you release
- Raise the version in both
Cargo.tomlandmanifest.json. - Run the contract suite on each system’s file, not only on yours (the workflow does).
- Try the file in OxiGuitar on at least one system (Testing).
- Say in your README what the plugin does, which OxiGuitar it needs (0.2 or later for interface 1.1), what it downloads or sends anywhere (ideally nothing), and where to report its bugs.
Installing and sharing
Installing a plugin means putting its .oxp file in a plugin folder; removing it means deleting the file. Nothing is unpacked or registered, and OxiGuitar itself never downloads, copies or removes plugins.
Where users put the file
Each user has a plugin folder, which OxiGuitar creates the first time it starts:
| System | The user’s plugin folder |
|---|---|
| Linux | ~/.local/share/oxiguitar/plugins/ ($XDG_DATA_HOME/oxiguitar/plugins/ when that is set) |
| macOS | ~/Library/Application Support/OxiGuitar/plugins/ |
| Windows | %APPDATA%\OxiGuitar\plugins\ |
OxiGuitar also searches the program’s own plugin folder, where packages install plugins for every user:
| System | The program’s plugin folder |
|---|---|
| Linux | /usr/lib/oxiguitar/plugins/ (beside the program: <bin>/../lib/oxiguitar/plugins/) |
| macOS | OxiGuitar.app/Contents/PlugIns/, then /Library/Application Support/OxiGuitar/plugins/ |
| Windows | plugins\ beside oxiguitar.exe |
The user’s folder comes first. Help → About lists every folder in the order searched. A plugin_folders list in settings.json replaces the defaults, and OXIGUITAR_HOME puts settings.json and plugins/ in one folder (for tests and portable copies).
Only .oxp files directly in a folder count, not in subfolders. The file’s name doesn’t matter, but keep it recognisable: example.oxp, or the download’s own name, example-0.1.0-linux-x86_64.oxp.
What OxiGuitar does with it
At the next start, OxiGuitar reads the file’s manifest and lists it in Tools → Plugins, with a tickbox to enable or disable it from the next start (it writes the plugin’s id under disabled in settings.json). The plugin is loaded the first time one of its contributions is used.
When two files carry the same plugin id, the higher version is used and the other is listed as replaced; on a tie, the file in the earlier folder, then the earlier name. So a user who installs your new version beside an old one gets the new one, and a user’s own copy wins over a package’s of the same version.
A file that can’t be used is listed with the reason: another system’s or processor’s file, a damaged file, a manifest problem, an interface newer than this OxiGuitar has, or conditions that don’t hold here.
macOS: the download mark
macOS marks downloaded files, and refuses to load an unsigned library that carries the mark (“cannot be opened because the developer cannot be verified”). OxiGuitar’s own plugins aren’t signed either; the user manual tells users to clear the mark:
xattr -d com.apple.quarantine ~/Library/Application\ Support/OxiGuitar/plugins/example.oxp
Say the same in your README, or sign and notarise your file with an Apple Developer ID.
What to tell your users
- What the plugin adds, and which OxiGuitar it needs: 0.2 or later.
- Which file is for which system, and where to put it (the table above).
- What it does beyond OxiGuitar. A plugin is native code with the user’s rights; OxiGuitar’s promises (no telemetry, never upload, never download) cover the app and our own plugins, not yours. If your plugin never touches the network, say so; if it does, say when and why.
- Its licence. The SDK is MIT OR Apache-2.0, so any GPL-compatible licence works.
- Where to report its bugs. When OxiGuitar refuses a file your plugin’s extension names, it suggests reporting a bug at OxiGuitar’s tracker; we will point people to you, but a line in your README saves them the trip.
Sharing it
Host it wherever you like: a GitHub release (the template’s workflow makes one), your own site, a package for your Linux distribution installing into /usr/lib/oxiguitar/plugins/. OxiGuitar names no plugin source of its own, ours included, beyond its footer’s pointer to our releases page when nothing can be opened. If you’d like your plugin mentioned in OxiGuitar’s documentation, open an issue.
Interface versions
The plugin interface has a major and a minor version, MAJOR.MINOR. A plugin’s manifest says which it was built for ("interface": "1.1"), and the SDK it was built with knows that version.
| Version | Since | Adds |
|---|---|---|
| 1.0 | OxiGuitar 0.2 | Formats (read and write), services, instruments (declared), footer messages, settings |
| 1.1 | OxiGuitar 0.2 | Tabs (widgets and a canvas, version 1 of the display list), pushed events, the open score |
| 1.2 | OxiGuitar 0.5 | The score version 2, which grows by extensions; the editor’s selection (Event::Selection2); tools in the Tools menu and changes to the score, each one step the user can undo (Tools and changes) |
| 1.3 | OxiGuitar 0.9 | A settings window (Settings); sections of the track dialog (Track sections); footer messages with a link; the plugin’s folders; instruments and effects on the audio thread (Instruments); audio devices; the cpu condition (Conditions) |
Interface 1 is frozen from OxiGuitar 0.2 on. What exists stays as it is:
- A newer OxiGuitar loads older plugins. A plugin built for 1.0 or 1.1 loads in every OxiGuitar with interface 1.x; the host sends it only what its minor version knows (a 1.0 plugin is never asked for a tab).
- Minor versions only add: new messages, new variants at the end of an enum, new fields at the end of a C table, new manifest keys. Nothing that exists changes meaning or encoding; tests in the interface crate pin every encoding.
- An older OxiGuitar refuses newer plugins that ask for a minor version it doesn’t have, and says so in Tools → Plugins. Ask for the lowest version that has what you use:
1.0for a format or service plugin,1.1for tabs or events,1.2for tools, changes to the score, the editor’s selection or the score version 2,1.3for a settings window, track sections, footer links or folders. - A new major version (
2.0) would have its own entry point,oxiguitar_plugin_v2; OxiGuitar would refuse plugins built for another major version, naming the version they need.
The score crosses the interface as its own types, which don’t follow the core’s model as it grows. Version 1 (oxiguitar_plugin::score) is frozen. Version 2 (oxiguitar_plugin::score2, interface 1.2) has the same tree, and each of its parts ends in a list of extensions (ext), an enum that later minor versions extend at its end: free time is the first (MasterBarExt::FreeTime). OxiGuitar leaves out the extensions a plugin’s version doesn’t know before sending it a score, and a plugin built for 1.0 or 1.1 gets version 1, with what version 1 can’t hold named as losses when it writes a file. So there is never a version 3: what M5’s notation adds arrives as extensions.
What comes next, each when a plugin needs it: exporters and toolbar sections. Each will be a minor version. If your plugin needs something the interface doesn’t have, open an issue saying what.