Publish
Ship a piece anywhere files go
Publishing in OpenVoicing is not a service you rent. You export one portable file, host it on any static server, and embed a sandboxed player on your page. This is the full reference for creators and developers: the format, hosting, the embed and its JavaScript SDK, deep links, YouTube bundles, security, self-hosting, and the CLI.
How it fits together
The three jobs, and why there is no backend.
OpenVoicing has no backend. A finished piece is a single file, and the player that renders it is a set of static assets. Publishing here is not uploading to someone's cloud and hoping it stays online. It is exporting a file you own and putting it wherever you already put files.
There are three distinct jobs in this area, and you rarely need all three:
- Export the piece. Inside the app you turn your work (a score, an optional recording, and the sync map that ties them together) into one portable
.ovbfile. This is the only step every creator does. - Host the file. You upload that
.ovbanywhere that serves static files over HTTPS: GitHub Pages, Netlify, Cloudflare Pages, an S3 bucket, or your own Nginx or Apache server. There is nothing to install and no process to keep running. - Show it to people. You can hand someone the file directly, or embed the interactive player on any web page so visitors press play, slow the music down, and loop passages right there on your site.
A useful way to think about the moving parts: the bundle (.ovb) is the content, the player (a small script plus a sandboxed iframe page) is the viewer, and they are decoupled on purpose. One player can render thousands of bundles, and one bundle plays in anyone's player. You can host the bundle and the player on the same site or on entirely separate hosts.
Most people never run their own player at all: they point the embed script at a shared copy of the player and only host their own .ovb files. Self-hosters who want full control, offline capability, or their own branding can also deploy the whole app and player, which is covered further down.
Export a single .ovb bundle
The one step every creator does.
Everything you publish starts with an export. A bundle captures the piece exactly as it plays in the app, so what you hand out behaves the same for everyone.
Before you export, get the piece into the state you want to ship. That usually means the score is loaded or composed, any recording is attached, and the sync is good enough that playback follows the notation. Your work autosaves to the browser as you go (the header reads "All changes saved."), but autosave lives only in that one browser on that one machine. Exporting is how you get a durable, movable copy.
To export
- Open the File menu, or press
Cmd/Ctrl+Kand search for it in the command palette. - Choose Export bundle.
- The app writes a single
.ovbfile and your browser downloads it. Name it something URL-friendly and stable, for examplechopin-prelude-4.ovb, because that filename can end up in a public URL.
That one file contains the score, any audio recording you added, and the sync map. Anyone with the file, or with the embeddable player pointed at it, sees the identical interactive piece: same notation, same recording, same bar-by-bar alignment, no account required.
Self-contained versus external bundles
If your recording is an audio file (OpenVoicing uses MP3, which plays in Safari, Chrome, and Firefox), the bundle is fully self-contained: the audio is packed inside the file and it plays offline. If instead the piece follows a YouTube video, the bundle only references that video by id and is marked external. External bundles are smaller but need a network connection to play, and they break if the video is later removed from YouTube. Pick audio when you want a piece that will keep working forever with nothing else online.
What is inside an .ovb file
A ZIP, a manifest, and sync points.
You never have to open a bundle by hand, but understanding its shape makes hosting, embedding, and debugging far less mysterious. The full normative spec lives in the bundle format document; this is the working summary.
An .ovb is an ordinary ZIP archive
The recommended extension is .ovb and the recommended MIME type is application/zip. If you rename a bundle to .zip you can open it with any unzip tool and look inside. At the root of the archive there is always a manifest.json, and any reader that cannot find that manifest, or that does not recognize the format, refuses the file rather than guessing.
The manifest describes the piece
format: always"openvoicing-bundle".formatVersion: the spec version (currently1). Readers reject major versions they do not implement, so a bundle either loads correctly or fails loudly. It never renders wrong.title: the display title.attribution(optional): free-text metadata such ascomposer,artist,copyright,license, andsource. Filling this in is good citizenship when you publish other people's material.score: the score file's path inside the archive plus itstype(musicxml,guitarpro, oralphatex).recordings: a list (possibly empty) of takes tied to the score.external:truewhen any recording points at media not packed inside the file (a YouTube video). Set automatically at export.
Each recording
Every recording has a stable id, a display name, a media source, and an optional list of syncPoints. The media source is one of two kinds:
audio: a decoded-audio take packed in the archive at somepath. This is the self-contained case.youtube: an external video referenced by its 11-charactervideoId, played through YouTube's official IFrame Player API. No video is ever downloaded. It can optionally carry anaudioPath(a packed audio copy of the same performance) used only to draw a waveform and auto-sync in the editor; playback is still the video.
Sync points make notation follow a recording
Each sync point anchors a musical position (tick, in MIDI ticks at 960 pulses per quarter note) to a moment in the recording (timeSeconds). Playback between anchors is interpolated linearly, so a handful of well-placed anchors (typically one per bar, denser for rubato) is enough to keep the cursor glued to a real performance. This is exactly what Auto sync and tap sync produce for you in the app.
Host a bundle on any static host
Three headers, and a snippet per platform.
Because a bundle is just a file, hosting it is just uploading a file. The only thing to get right is that your host serves it with the correct headers so browsers treat it as the archive it is.
The short version
- Export your
.ovbfrom the app. - Upload it to any host that serves static files over HTTPS: GitHub Pages, Netlify, Cloudflare Pages, S3 behind CloudFront, or your own Nginx or Apache box.
- Note the public URL. That URL is what you will hand to the player.
HTTPS matters because the player and its assets run over HTTPS, and browsers block mixed content. A bundle served over plain HTTP will fail to load inside a secure page.
Three headers to set on .ovb files
- Content-Type:
application/zip. A bundle is a ZIP archive. Do not let the host sniff it astext/htmlor the player cannot read it.application/octet-streamis an acceptable fallback on hosts that will not emitapplication/zip. - CORS, only if cross-origin. If the bundle lives on a different origin than the page embedding it, the file host must send
Access-Control-Allow-Originfor that origin (or*for public bundles). If the bundle and the page share an origin, you need no CORS at all. This single missing header is the most common cause of a "Failed to fetch" error on an otherwise correct embed. - Cache-Control for immutability. Bundles do not change once published, so serve them with
Cache-Control: public, max-age=31536000, immutableand change the URL (content-hashed filenames work well) whenever the content changes. This gives you instant repeat loads without stale-cache headaches.
Server snippets
Nginx:
location ~ \.ovb$ {
types { application/zip ovb; }
add_header Access-Control-Allow-Origin *;
add_header Cache-Control "public, max-age=31536000, immutable";
}
Apache (.htaccess):
AddType application/zip .ovb <FilesMatch "\.ovb$"> Header set Access-Control-Allow-Origin "*" Header set Cache-Control "public, max-age=31536000, immutable" </FilesMatch>
Netlify (_headers):
/*.ovb Content-Type: application/zip Access-Control-Allow-Origin: * Cache-Control: public, max-age=31536000, immutable
For S3, set the object's Content-Type metadata to application/zip and enable CORS on the bucket. Most managed static hosts (GitHub Pages, Netlify, Cloudflare Pages) already send permissive CORS or let you configure it, so in practice you often only need to confirm the MIME type.
Embed the interactive player
Two lines, no build tooling.
The embeddable player is a small, dependency-free script. It finds a placeholder element on your page and upgrades it into a sandboxed iframe that plays your bundle: press play, drop the speed to 50 percent with the pitch preserved, loop a passage, go fullscreen. It is the same player you meet in the app's Listen mode.
The whole integration is two lines
Add the script once, then add a div that points at your hosted bundle:
<script src="https://YOUR-PLAYER-HOST/openvoicing-embed.js"></script> <div data-openvoicing-bundle="https://YOUR-FILES/your-piece.ovb"></div>
Every matching div on the page is upgraded automatically when the script loads, so you can drop several pieces on one page and they all just work.
Let the app write the snippet for you
In the app, run Copy embed code (in the File/Share actions and in the command palette under Cmd/Ctrl+K). It copies a ready-made snippet with the script tag and the div, using a YOUR_BUNDLE_URL placeholder. Paste it into your page and replace the placeholder with the public URL of your hosted .ovb. The script src in that snippet points at the origin you are running the app from, so change it if your player lives elsewhere.
Declarative attributes
These let you tune the embed without any JavaScript:
| Attribute | Purpose |
|---|---|
data-openvoicing-bundle | The bundle URL (required unless you pass it in code). |
data-openvoicing-player | Override which player page loads (see the SDK section for why). |
data-openvoicing-height | Override the iframe height. |
The default height is 480 pixels. Give a synced, scrolling piece more room (the site's own demo uses 720) so the notation is comfortable to read.
Where the player script comes from
You have two choices, both fine:
- Point the script at a shared player host (the simplest path, no deployment of your own).
- Host
openvoicing-embed.jsyourself alongside the player page, covered in the deploy section.
Either way, keep openvoicing-embed.js and its companion player page together and pin a version, which the SDK and security sections explain.
Drive the player from JavaScript
The SDK: create, options, methods, events, types.
When you need more than a static embed (custom controls, syncing the player to other content on the page, analytics on how far people listen), use the JavaScript SDK. Loading openvoicing-embed.js exposes a global OpenVoicing object.
Create a player programmatically
const player = OpenVoicing.create("#slot", {
bundle: "https://example.com/tune.ovb",
});
player.on("ready", (e) => console.log("loaded:", e.title));
player.play();
player.setSpeed(0.5);
player.seek(30);
OpenVoicing.create(target, options) takes a CSS selector string or an HTMLElement and returns a player object.
Options
| Option | Meaning |
|---|---|
bundle | String. The bundle URL. Falls back to the element's data-openvoicing-bundle. |
player | String. The player page URL. Defaults to embed.html resolved next to the script. |
height | Number or string. Iframe height in pixels (number) or any CSS length (string). Default 480. |
title | String. The iframe title for assistive technology. Set this for accessibility. |
lazy | Boolean. When true, the player loads only when scrolled near, instead of immediately. Good for long pages with many embeds. |
params | Object. Extra query params passed through to the player, for example { speed: 0.75, loop: "2-6" }. See deep links. |
The player object
interface OpenVoicingPlayer {
readonly element: HTMLIFrameElement;
play(): void;
pause(): void;
toggle(): void;
seek(seconds: number): void; // seek to a time in seconds
setSpeed(value: number): void; // 0.25 to 1.5; pitch is preserved
on(type, handler): () => void; // returns an unsubscribe function
destroy(): void; // remove the iframe and its listeners
}
setSpeed accepts 0.25 to 1.5 and preserves pitch, the same time-stretch you get in the app. For YouTube-backed bundles, speed snaps to YouTube's own steps (0.25, 0.5, and so on).
Events
Subscribe with player.on(type, handler); the return value unsubscribes.
ready:{ type: "ready", title: string, hasRecording: boolean, duration: number }, fired when the bundle has loaded.state:{ type: "state", playing: boolean }, fired on play or pause.position:{ type: "position", current: number, total: number }, fired as playback advances.
const off = player.on("position", (e) => updateBar(e.current / e.total));
// later, to stop listening:
off();
Call player.destroy() when you tear down the surrounding UI (for example in a single-page app route change) to remove the iframe and detach listeners cleanly.
TypeScript
Type definitions ship next to the script as openvoicing-embed.d.ts, and the global is typed as window.OpenVoicing: OpenVoicingSDK. Keep the .d.ts alongside the .js so editors pick it up.
embed.html relative to its own <script src>. If you load openvoicing-embed.js from https://host/v1/openvoicing-embed.js, the player loads from https://host/v1/embed.html. Keep the two files together, or set player (or data-openvoicing-player) explicitly to point elsewhere.
Deep-link into a passage
Preset the speed, start time, or loop.
You can preset how the player opens, which is invaluable for teaching: link a student straight to bars 5 through 8 at 70 percent speed, and it starts there.
Pass these as query params on the player page (embed.html?...) or, from the SDK, through the params option:
| Param | Effect |
|---|---|
speed=0.75 | Initial playback speed (pitch preserved). |
t=12 | Start position, in seconds. |
loop=8-14 | Loop a range in seconds. |
loop=b3-6 | Loop by bar numbers, with a b prefix. Works once the piece is synced, since bar numbers only mean something after the score is aligned to time. |
Declarative example
Params ride on the player URL:
<div data-openvoicing-bundle="https://YOUR-FILES/etude.ovb" data-openvoicing-player="https://YOUR-PLAYER-HOST/embed.html?speed=0.7&loop=b5-8" ></div>
SDK example
Params passed in code:
OpenVoicing.create("#slot", {
bundle: "https://YOUR-FILES/etude.ovb",
params: { speed: 0.7, loop: "b5-8", t: 12 },
});
t and loop to open at the top of a hard passage already looping, so a student can drill it immediately without touching a single control.
Hosting YouTube-backed bundles
External bundles, the network, and CSP origins.
A piece can follow a real audio recording or a YouTube video (a lesson or a performance), with the notation scrolling along as the video plays. Publishing a YouTube-backed bundle is almost the same as any other, with two things to know.
It is marked external and needs the network
When a bundle references a YouTube video, the video is embedded through YouTube's official player and never downloaded, so the bundle only stores the video id. The manifest sets external: true. That means the file is small, but playback requires an internet connection and will break if the video is removed from YouTube. If you want a piece that keeps working forever with nothing else online, use an audio recording instead (MP3 audio is packed inside the bundle and plays fully offline).
Allow YouTube's origins in your Content-Security-Policy
If the page embedding the player sends a CSP, the video cannot play unless you allow YouTube to load and frame. Add:
frame-src https://www.youtube-nocookie.com; script-src https://www.youtube.com;
The player loads the official YouTube IFrame API and embeds https://www.youtube-nocookie.com, the privacy-friendly domain. If you do not send a CSP at all, there is nothing to configure; the video just plays.
Audio-only bundles need none of this. They are fully self-contained, so they require no YouTube origins and no network. Reserve the CSP additions above for pages that actually host video-backed pieces.
Security, sandboxing, and CSP
Hardening an embed you control.
The player is designed to be safe to drop onto a page you control, and it gives you the levers to keep it that way.
The player runs in a sandboxed iframe
Your page and the player communicate only through postMessage, so the player cannot reach into your page's DOM or scripts, and vice versa. The SDK methods and events you saw earlier are that message channel, wrapped in a friendly API.
If your site sends a CSP, allow the player to load and frame
Two directives matter:
script-srcmust allow the origin servingopenvoicing-embed.js.frame-src(orchild-src) must allow the origin serving the player page (embed.html).
If you host video-backed bundles, add the YouTube origins from the previous section on top of these.
Control who is allowed to embed your instance
If you run your own player and do not want other sites framing it, set Content-Security-Policy: frame-ancestors (and/or the older X-Frame-Options) on embed.html to restrict which origins may embed it.
.ovb the way you would treat any untrusted media: host and embed material you or people you trust produced.
Pin a version so an update cannot break live embeds
The script resolves its player page relative to its own URL, so point <script src> at an immutable, versioned path such as /v1/openvoicing-embed.js rather than a "latest" URL. That way a future player change cannot silently alter or break pieces that are already published on your site. When you do want the newer player, move to /v2/... deliberately and test.
Deploy your own instance
Build the whole app and player to static files.
Most people only ever host .ovb files and point the embed at a shared player. But you can also run the entire thing yourself: the authoring app, the embeddable player, and all their assets. There is no server to operate; it builds down to static files.
Build it
You need Node 22 or newer and pnpm.
pnpm install pnpm build
The static site lands in apps/web/dist/:
index.html, the authoring app (Listen, Practice, and Edit modes).embed.html, the embeddable player page.openvoicing-embed.jsandopenvoicing-embed.d.ts, the embed SDK and its types.alphatab/, the notation renderer's worker and font assets.soundfont/, the General MIDI soundfont used for "Notes" playback (roughly 24 MB).
Upload the contents of dist/ to any static host: Netlify, Cloudflare Pages, GitHub Pages, S3 behind CloudFront, Nginx, and so on. There is no backend process.
Serving from a sub-path
By default the app assumes it lives at the domain root (/). To serve it from something like https://example.com/music/, build with a matching base so asset and service-worker URLs resolve:
OPENVOICING_BASE=/music/ pnpm build
Then confirm the PWA manifest's start_url and icon paths also point under the sub-path. If a deploy shows a blank page with 404s for /assets/*.js, a wrong base is almost always the cause.
Platform notes
- GitHub Pages, Netlify, Cloudflare Pages, and S3: upload
distas-is. - Serve
.ovbfiles withContent-Type: application/zip(orapplication/octet-stream), per the hosting section. - The service worker precaches the app for offline use with no configuration. After you push an upgrade, an old cached version can linger for one load; a hard reload updates it, and the worker cache-busts on the next visit.
Offline capability
Because of that service worker, a deployed instance plus a self-contained (audio-only) bundle can run with no network. YouTube-backed bundles still need connectivity to stream their video.
Build bundles with the ovb CLI
Create, validate, and inspect from the terminal.
The ovb command (from @openvoicing/bundle) creates, validates, and inspects .ovb bundles without opening the app. It is built for scripting, CI pipelines, and batch-preparing pieces to host. If you have one score and one recording, the app is faster; if you have fifty, the CLI is the tool.
Getting it (from the repo)
pnpm --filter @openvoicing/bundle build node packages/bundle/bin/ovb.mjs <command>
ovb create
Packages a score, and optionally a recording, into a bundle.
ovb create --score <file> --out <file.ovb> [--title <T>] [--recording <audio>] [--youtube <url>]
Flags:
--score <file>(required): source score, a.musicxml,.xml,.mxl, or Guitar Pro file.--out <file.ovb>(required): where to write the bundle.--title <T>: the title stored in the manifest.--recording <audio>: an audio file to pack in, for example.mp3,.ogg, or.wav. MP3 is the safest for cross-browser playback.--youtube <url>: a YouTube URL or video id to attach as a video recording. This marks the bundle external.
Examples:
# Audio recording packed into the bundle (self-contained): ovb create --score song.musicxml --recording take.mp3 --title "My Tune" --out song.ovb # YouTube video recording (referenced, not downloaded; marks the bundle external): ovb create --score song.musicxml --youtube https://youtu.be/VIDEO_ID --out song.ovb # YouTube video plus a paired audio file for the waveform and auto-sync: ovb create --score song.musicxml --youtube VIDEO_ID --recording take.mp3 --out song.ovb
Note that the CLI packages the score and media; the fine-grained bar-by-bar sync map is what the app's Auto sync and tap sync produce, so pieces that need precise alignment are usually finished in the app.
ovb validate
Checks that a bundle is well-formed and that its formatVersion is supported. It exits non-zero on failure, so it drops straight into CI as a gate.
ovb validate song.ovb
ovb inspect
Prints the manifest and the list of files inside a bundle, which is the quickest way to confirm what actually got packed.
ovb inspect song.ovb
ovb help
Lists the commands.
ovb help
ovb validate on every bundle you generate before publishing, and fail the build if it exits non-zero. Combined with content-hashed filenames and long-lived caching, this gives you a reliable, reproducible publish pipeline with no server in the loop.
Publish the reusable packages
The npm libraries and the release workflow.
The player, the score model, and the bundle format are meant to be reused by others, so the reusable packages are published to npm under the permissive MIT license, the same as the rest of the project.
Packages intended for npm
@openvoicing/score-model: the document format and its converters.@openvoicing/bundle: the.ovbreader/writer and theovbCLI.@openvoicing/audio-engine: time-stretch and analysis.@openvoicing/player: the rendering and playback wrapper.
Each is currently consumed as TypeScript source inside the monorepo. Before a first npm release they need a build step that emits .js plus .d.ts (the bundle package already does this via tsconfig.build.json; the others follow the same pattern) and a publishConfig with "access": "public".
Release checklist
pnpm test && pnpm typecheck && pnpm build.- Bump versions (keep the four packages in lockstep for now).
pnpm --filter <pkg> buildto emitdist.npm publish --access publicfrom each package (requires an npm token with publish rights to the@openvoicingscope).- Tag the release:
git tag vX.Y.Z && git push --tags.
Automating steps 3 through 5 in CI on tag push is the natural next step. It needs an NPM_TOKEN secret, which is why it is not enabled in the repo by default.
The embed script on a CDN
openvoicing-embed.js is a dependency-free file, and its companion openvoicing-embed.d.ts sits next to it. Publish the script to any CDN and point <script src> at it. Because the script resolves its player page (embed.html) relative to its own URL, keep those files together on the CDN. And, as covered in the security section, pin a versioned, immutable path (for example /v1/openvoicing-embed.js) rather than a "latest" URL, so a player update can never silently break embeds that are already live on other people's pages.
Troubleshooting
Symptom, cause, and fix.
Most problems in this area come down to a wrong header, a wrong path, or a browser policy behaving exactly as designed. This table maps the symptom you see to the fix.
| Symptom | Likely cause | Fix |
|---|---|---|
Blank page, 404s for /assets/*.js | Wrong base path for a sub-path deploy | Rebuild with OPENVOICING_BASE set to your sub-path |
| Embed iframe is blank | Bundle 404, or served as the wrong MIME type | Check the bundle URL and that it is served as application/zip |
| "Failed to fetch" on a cross-origin bundle | Missing CORS header | Add Access-Control-Allow-Origin on the bundle host |
| Video-backed embed will not play | CSP blocks YouTube | Allow https://www.youtube-nocookie.com in frame-src and https://www.youtube.com in script-src |
| No audio until the user clicks | Browser autoplay policy | Expected. Audio starts on the first user gesture |
| Old version keeps loading after an upgrade | Service-worker cache | Hard-reload once; the worker updates on the next load and cache-busts |
| Notes playback silent or slow on the first note | The roughly 24 MB soundfont is still downloading | Wait for it, or host the soundfont/ directory on a fast CDN |
| A live embed changed behavior after an update | Script pinned to a moving "latest" URL | Pin an immutable, versioned script path and upgrade deliberately |
application/zip?), then check the browser console for a CORS or CSP message, then confirm that openvoicing-embed.js and embed.html are the same version and sit together. Those three checks catch the large majority of failures.
Keep going
Practice a piece →
Slow it down, loop a passage, and sync notation to a recording before you export.
The ideaWhy open →
A file you own, not a service you rent, and what the open format means for you.
Go deeperReference →
Keyboard shortcuts, the full bundle spec, and the rest of the technical details.