Skip to content
Documentation

Run locally

This page takes the score from Write a score off the website. You compile it with the command-line tool, then play it from a small browser application with Play, Stop, and a button that changes the music while it plays.

You need Node.js 22.12 or newer and a browser with Web Audio. To play audio, you also need a Gemini API key from a project with access to Lyria RealTime. The site’s shared audio only works in its own demos.

Compile a score

Save the score as song.gas, then run:

bash
npx @luna-estelar/gas@0.2.0 compile song.gas --out song.timeline.json

This writes the timeline as JSON: the score’s events placed in bars, with nothing specific to a model. Timeline format describes its structure. Compiling needs no key and, once the package is downloaded, no network. Leave out --out song.timeline.json to print the JSON instead. If the score has errors, the command prints them and exits with status 1; the language reference explains each kind.

Build the application

Create a TypeScript project with Vite and install GAS:

bash
npm create vite@latest gas-piano -- --template vanilla-ts
cd gas-piano
npm install
npm install --save-exact @luna-estelar/gas@0.2.0

The package bundles every part of GAS, and the application imports each part from its own subpath, such as @luna-estelar/gas/lyria for the Lyria connector. Packages and imports lists them all.

Replace index.html with:

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>GAS piano</title>
  </head>
  <body>
    <label>Gemini API key <input id="key" type="password" autocomplete="off" /></label>
    <button id="play">Play</button>
    <button id="stop" disabled>Stop</button>
    <button id="change" disabled>Make it busier</button>
    <p id="status" role="status">Stopped</p>
    <p id="warnings"></p>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Replace src/main.ts with the module below. It plays the same score, set to loop so the music keeps going while you change it.

ts
import { GasOperationError, sourceText } from '@luna-estelar/gas';
import { createBrowserSession, type BrowserSession } from '@luna-estelar/gas/browser/session';
import { createLyriaConnector } from '@luna-estelar/gas/lyria';

const score = `
tempo 88
key "C major"
time_signature 4/4
length bars 12 loop

track piano "Soft felt piano"
track bass "Round upright bass, short notes"

section intro:
    length bars 4
    flavor "quiet, plenty of space"

    bar 1:
        piano.play

section main:
    length bars 4
    flavor "warmer, more movement"

    bar 1:
        bass.play

section outro:
    length bars 4
    bass.stop

    bar 3:
        piano.stop

intro()
main()
outro()
`;

const key = document.querySelector<HTMLInputElement>('#key')!;
const play = document.querySelector<HTMLButtonElement>('#play')!;
const stop = document.querySelector<HTMLButtonElement>('#stop')!;
const change = document.querySelector<HTMLButtonElement>('#change')!;
const status = document.querySelector<HTMLParagraphElement>('#status')!;
const warnings = document.querySelector<HTMLParagraphElement>('#warnings')!;
let browser: BrowserSession | undefined;
let generation = 0;
let closing: Promise<void> | undefined;

function message(error: unknown): string {
  if (error instanceof GasOperationError) {
    return `Could not continue: ${error.kind}${error.reason ? ` (${error.reason})` : ''}.`;
  }
  return 'Could not continue. Check the console and your provider access.';
}

function finish(text = 'Stopped'): Promise<void> {
  if (closing) return closing;
  generation++;
  const current = browser;
  browser = undefined;
  play.disabled = stop.disabled = change.disabled = true;
  closing = Promise.resolve().then(() => current?.close()).catch(() => {
    text = 'Could not close cleanly. Reload before trying again.';
  }).finally(() => {
    status.textContent = text;
    play.disabled = false;
    closing = undefined;
  });
  return closing;
}

play.addEventListener('click', async () => {
  if (!key.value.trim()) {
    status.textContent = 'Enter a Gemini API key first.';
    return;
  }
  const run = ++generation;
  play.disabled = true;
  stop.disabled = false;
  status.textContent = 'Connecting…';
  warnings.textContent = '';
  // Start before any await, so Web Audio opens inside this click.
  const starting = createBrowserSession({
    connector: createLyriaConnector,
    settings: { apiKey: key.value.trim() }
  });
  try {
    const current = await starting;
    if (run !== generation) {
      await current.close();
      return;
    }
    browser = current;
    current.audio.on('status', (audio) => {
      if (browser === current) status.textContent = `Audio: ${audio.state}`;
    });
    current.session.on('error', (error) => {
      if (browser === current) void finish(message(error));
    });
    current.session.on('warning', (warning) => {
      if (browser === current) warnings.textContent = warning.message;
    });
    const loaded = await current.session.loadSource(sourceText(score));
    if (run !== generation) return;
    warnings.textContent = loaded.warnings.map((warning) => warning.message).join(' ');
    status.textContent = 'Waiting for audio…';
    await current.session.play();
    if (run === generation) change.disabled = false;
  } catch (error) {
    if (run === generation) await finish(message(error));
  }
});

stop.addEventListener('click', () => { void finish(); });

change.addEventListener('click', async () => {
  const current = browser;
  if (!current) return;
  change.disabled = true;
  try {
    const result = await current.session.submitLiveCommands(
      sourceText('piano.flavor "busier, left hand walking"\ntempo 96')
    );
    if (browser === current) {
      status.textContent = result.ok
        ? 'Change accepted; listen as the music responds.'
        : `Applied ${result.applied} statements: ${result.failure.message}`;
    }
  } catch (error) {
    if (browser === current) status.textContent = message(error);
  } finally {
    if (browser === current) change.disabled = false;
  }
});

window.addEventListener('pagehide', () => { void finish(); });

Start the development server, open the local URL that Vite prints, enter your key, and press Play:

bash
npm run dev

The status shows Audio: playing once sound reaches your speakers. There is a short delay after Play while the model generates and buffers its first audio. Any support warnings for the score appear below the status; Supported models explains them.

Press Make it busier to send a live command to the running session, as you did in the playground. The status reports whether it was applied.

What the code does

  • Starts audio inside the click. createBrowserSession is called before the Play handler awaits anything, because some browsers only let audio start inside a user gesture.
  • Takes a connector factory. It passes createLyriaConnector itself, not a connector. A connector can only be opened once, so this lets session.retryRenderer() build a fresh one if your application retries after a failure. This example closes the session on errors instead.
  • Releases everything on Stop. Stop closes the browser session and its audio context, and Play creates a new one. A session that finishes starting after Stop, or after you leave the page, is closed straight away. An application that keeps one session open can call browser.session.stop() and play() instead; the JavaScript API covers both.

The key stays in the page and is never stored. Don’t ship a provider key in your JavaScript: a production application should send requests through its own backend relay. Troubleshooting covers rejected keys, silence, and dropped connections.

Next: Connect with an application — make the music respond to your application’s state.