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:
npx @luna-estelar/gas@0.2.0 compile song.gas --out song.timeline.jsonThis 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:
npm create vite@latest gas-piano -- --template vanilla-ts
cd gas-piano
npm install
npm install --save-exact @luna-estelar/gas@0.2.0The 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:
<!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.
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:
npm run devThe 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.
createBrowserSessionis called before the Play handler awaits anything, because some browsers only let audio start inside a user gesture. - Takes a connector factory. It passes
createLyriaConnectoritself, not a connector. A connector can only be opened once, so this letssession.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()andplay()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.