Astro plugin

Narrate your Markdown and MDX content, with word-level highlighting where you want it — and a player designed to disappear into your site.

What it does

@vocasync/astro reads your content collection, turns each post into narrated audio, and records the result in an audio-map.json committed alongside your source. A build-time plugin then wraps each word so the shipped player can follow along, and a CLI keeps the audio in step as you edit.

Everything happens at build time. Your site stays static, and the only thing your readers download is the audio.

Install

npm install @vocasync/astro

Works with Astro 4, 5, 6 and 7. Every release is built against all four.

On Astro 7, add one more package

Astro 7 no longer bundles @astrojs/markdown-remark, and the build-time plugin that produces word highlighting cannot run without it. Install it alongside: npm install @astrojs/markdown-remark. Astro 4, 5 and 6 need nothing extra.

Quick start

Point the plugin at your collection:

// vocasync.config.mjs
export default {
  collection: { name: "blog", path: "./src/content/blog" },
};

Register the integration and the word-wrapping plugin:

// astro.config.mjs
import { defineConfig } from "astro/config";
import vocasync from "@vocasync/astro";
import { rehypeAudioWords } from "@vocasync/astro/rehype";

export default defineConfig({
  markdown: {
    rehypePlugins: [
      [rehypeAudioWords, {
        collectionName: "blog",
        audioMapPath: "src/data/audio-map.json",
      }],
    ],
  },
  integrations: [vocasync()],
});

Add your API key to .env as VOCASYNC_API_KEY, then generate the audio:

npx vocasync sync

Finally, drop the player into your layout:

---
import AudioPlayer from "@vocasync/astro/components/AudioPlayer.astro";
import audioMap from "../data/audio-map.json";
---

<AudioPlayer slug={post.slug} audioEntry={audioMap.entries[post.slug]} />

<div data-article-body>
  <slot />
</div>

The data-article-body wrapper is how the player finds the words to highlight.

Highlighting, or narration alone

Word highlighting and click-to-seek need forced alignment, which produces a timestamp for every word. Alignment covers fewer languages than narration does, so the plugin lets you choose per site or per post.

  • With highlighting — available in the 13 languages that support forced alignment.
  • Narration only — available in all 57 languages the voices speak, and cheaper, because alignment is a second job on top of synthesis.
// vocasync.config.mjs — narration only, site-wide
export default {
  collection: { name: "blog", path: "./src/content/blog" },
  language: "th",
  align: false,
};

Or per post, in frontmatter, which also lets that post use a language outside the aligned set:

---
title: "ประกาศ"
language: th
align: false
---

You cannot ask for the impossible by accident

Requesting a language that cannot be aligned while highlighting is on is rejected before any audio is generated, with a message naming the language and what to change. You are never billed for a synthesis that was always going to fail at the next step.

See forced alignment for which languages are covered and why.

Making the player yours

The player is built to disappear into your site rather than announce itself. Four CSS custom properties carry a whole theme — borders, muted text, hover states and the contrast colour on the play button all derive from them:

:root {
  --vocasync-accent: #7c3aed;
  --vocasync-surface: #ffffff;
  --vocasync-text: #1e1b2e;
  --vocasync-highlight: #f59e0b;
}

Better still, point them at tokens your site already defines. The player then follows your palette and your dark mode, because your tokens already flip:

:root {
  --vocasync-accent: var(--color-primary);
  --vocasync-surface: var(--color-card);
  --vocasync-text: var(--color-text);
}

Beyond colour, the player offers:

  • Shapes — a bar, a card, or a minimal form that borrows the page with no background or border at all.
  • Control order — list the controls you want, in the order you want them; anything you leave out is not rendered.
  • Your own icons and markup — every icon, the placeholder and the error state can be replaced without forking the component.
  • Your own words — every visible string and every accessible label is overridable, so the player can speak the language of the site around it.
  • Density and type — one multiplier rescales padding, gaps and controls together; the font inherits from your page by default.

Your CSS always wins

Every rule the player ships sits in a cascade layer and carries zero specificity, so any style you write beats it — whatever order your imports land in. You should never need !important. If you do, that is a bug worth reporting.

The themes gallery shows the same component under a dozen different treatments, with the CSS for each.

Bringing your own player

If you would rather build the interface yourself, the playback and highlighting engine is published separately from the component and covered by semantic versioning:

import { createPlayer } from "@vocasync/astro/player-core";

const player = createPlayer(document.querySelector(".my-player"));

Lower-level pieces are exported too — the timing maths, the highlighter, and the helpers that resolve a stream URL — so you can build something quite different and still let VocaSync handle the synchronisation.

Equations and the CLI

LaTeX equations are read aloud as sentences rather than skipped or spelled out, and highlight as a single unit while they are spoken.

The bundled CLI keeps audio in step with your writing. It hashes each post, so vocasync sync only regenerates what actually changed:

npx vocasync sync              # generate what is missing or stale
npx vocasync sync --only my-post
npx vocasync sync --dry-run    # show what would happen, spend nothing
npx vocasync check             # validate config and API key

Commit your audio map

audio-map.json records which audio belongs to which post. Deleting it means re-generating every post from scratch, which is billable. Keep it in version control.

Full reference

Every prop, every theming token, the complete language table and the upgrade notes live in the plugin README. For the underlying services, see speech synthesis and forced alignment.