OpenVoicing Launch the app →

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.

03.1

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:

  1. 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 .ovb file. This is the only step every creator does.
  2. Host the file. You upload that .ovb anywhere 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.
  3. 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.

What you never need: an account, a database, a server-side runtime, or a paid plan. If your host can serve an image, it can serve OpenVoicing.
03.2

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

  1. Open the File menu, or press Cmd/Ctrl+K and search for it in the command palette.
  2. Choose Export bundle.
  3. The app writes a single .ovb file and your browser downloads it. Name it something URL-friendly and stable, for example chopin-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.

Tip: also keep a MusicXML copy. File → Export MusicXML writes a plain notation file you can open in other notation software. It does not carry the recording or the sync, so it is a portability backup for the notes themselves, not a substitute for the bundle.
Tip: bundles are immutable in practice. Treat each exported file as a fixed snapshot. When you change the piece, export again and publish the new file under a new name (or a content-hashed filename). This plays nicely with the long-lived caching covered in the hosting section.

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.

03.3

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 (currently 1). 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 as composer, artist, copyright, license, and source. Filling this in is good citizenship when you publish other people's material.
  • score: the score file's path inside the archive plus its type (musicxml, guitarpro, or alphatex).
  • recordings: a list (possibly empty) of takes tied to the score.
  • external: true when 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 some path. This is the self-contained case.
  • youtube: an external video referenced by its 11-character videoId, played through YouTube's official IFrame Player API. No video is ever downloaded. It can optionally carry an audioPath (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.

Forward compatibility is built in. Readers ignore manifest fields and archive entries they do not recognize, so newer bundles keep working in older players within the same major version. Planned additions (cover images, precomputed waveform peaks, more video providers) will slot in without breaking today's files.
03.4

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

  1. Export your .ovb from the app.
  2. 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.
  3. 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 as text/html or the player cannot read it. application/octet-stream is an acceptable fallback on hosts that will not emit application/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-Origin for 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, immutable and 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.

03.5

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:

AttributePurpose
data-openvoicing-bundleThe bundle URL (required unless you pass it in code).
data-openvoicing-playerOverride which player page loads (see the SDK section for why).
data-openvoicing-heightOverride 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.js yourself 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.

03.6

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

OptionMeaning
bundleString. The bundle URL. Falls back to the element's data-openvoicing-bundle.
playerString. The player page URL. Defaults to embed.html resolved next to the script.
heightNumber or string. Iframe height in pixels (number) or any CSS length (string). Default 480.
titleString. The iframe title for assistive technology. Set this for accessibility.
lazyBoolean. When true, the player loads only when scrolled near, instead of immediately. Good for long pages with many embeds.
paramsObject. 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.

How the iframe finds its player page. The script derives the default 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.
03.8

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.

A practical middle path. In the editor you can pair a YouTube video with an audio file of the same performance purely to draw a waveform and enable Auto sync (and finer speed control). Playback is still the video, and the bundle stays external. This is worth doing when a video is your intended playback source but tapping every bar by hand is tedious.
03.9

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-src must allow the origin serving openvoicing-embed.js.
  • frame-src (or child-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.

Only embed bundles you trust. A bundle is arbitrary content rendered inside the player iframe. The sandbox limits its blast radius, but you should still treat an untrusted .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.

03.10

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.js and openvoicing-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 dist as-is.
  • Serve .ovb files with Content-Type: application/zip (or application/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.

Licensing for deployments. OpenVoicing is MIT licensed, so you can deploy it, modify it, and run it publicly with no share-back obligation. Just keep the copyright notice. A few bundled components (alphaTab, the fonts, the soundfont) keep their own permissive notices; preserve those in your build. See the project's licensing document for the full picture.
03.11

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
Tip for CI. Run 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.
03.12

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 .ovb reader/writer and the ovb CLI.
  • @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

  1. pnpm test && pnpm typecheck && pnpm build.
  2. Bump versions (keep the four packages in lockstep for now).
  3. pnpm --filter <pkg> build to emit dist.
  4. npm publish --access public from each package (requires an npm token with publish rights to the @openvoicing scope).
  5. 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.

03.13

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.

SymptomLikely causeFix
Blank page, 404s for /assets/*.jsWrong base path for a sub-path deployRebuild with OPENVOICING_BASE set to your sub-path
Embed iframe is blankBundle 404, or served as the wrong MIME typeCheck the bundle URL and that it is served as application/zip
"Failed to fetch" on a cross-origin bundleMissing CORS headerAdd Access-Control-Allow-Origin on the bundle host
Video-backed embed will not playCSP blocks YouTubeAllow https://www.youtube-nocookie.com in frame-src and https://www.youtube.com in script-src
No audio until the user clicksBrowser autoplay policyExpected. Audio starts on the first user gesture
Old version keeps loading after an upgradeService-worker cacheHard-reload once; the worker updates on the next load and cache-busts
Notes playback silent or slow on the first noteThe roughly 24 MB soundfont is still downloadingWait for it, or host the soundfont/ directory on a fast CDN
A live embed changed behavior after an updateScript pinned to a moving "latest" URLPin an immutable, versioned script path and upgrade deliberately
A quick diagnostic order when an embed is blank. First open the bundle URL directly in a browser tab (does the file download, and does the response header say 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