How to build scene audio and soundtrack tools
Configure native tracks, playlists, spatial sources, scene voices and timed scores with the correct revisions.

The project you will build
Build Harbor audio tools. Prepare two original tracks through the real Library, then configure a harbor ambience, operate one scene voice, compose arrival music, navigate its score and position a spatial beacon. These are separate layers of audio state, with different revisions and listener projections.
Target host: Gravewright 0.3.0. Actions run only when you click Run action, using the signed-in account. The demonstration panel is English. Each recipe specifies inputs and an observable result.
1. Create the files
Create a folder named audio. Save the complete files below as UTF-8, or download the project. manifest.json belongs at the root. No npm dependencies or JavaScript build is required.
audio/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txtUse a disposable campaign, a scene and two short original WAV/Ogg/FLAC/MP3 files. As GM open the native table Library and use Ambient audio or Sound effect to upload them; this browser domain API has no uploadTrack method, so the package starts from existing tracks. Inspect returns UUIDs. Click Enable audio on each client and check local volume. All audio mutations require GM; players receive only their authorized listening projection.
2. Package, install and activate
From the project folder, run in PowerShell:
python -m json.tool manifest.json
python -m zipfile -c ../audio-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../audio-1.0.0.zipOn Linux/macOS, replace python with python3 in the same commands. The archive listing must show manifest.json at its root. As installation owner, open Inside → Modules → Install module → Install from ZIP, select the ZIP and click Install. As GM, open the campaign, select its Gravewright button → Extensions and Activate version 1.0.0 of Harbor audio tools. Close that menu and click Harbor audio tools. See your first module for screen-by-screen instructions.
The package stays inactive until selected for the table. Test one demonstration extension at a time because their launchers share a position. For changed bytes, increment version to 1.0.1, build another ZIP, install and activate that version; do not edit installed files.
3. Run each recipe
Inspect tracks, scene transport and score revisions
Prepare original audio through the native Library. Run without a scene to discover scene UUIDs, then run again with the chosen one. A GM may specify an existing token UUID to preview its spatial listening position. Copy track, playlist and source IDs from this result.
async ({api,signal},values) => {
const maps = await api.maps.state({}, {signal});
const audio = await api.audio.state({...(values.sceneId ? {sceneId:values.sceneId} : {}),...(values.previewTokenId ? {previewTokenId:values.previewTokenId} : {})}, {signal});
return {scenes:maps.maps.map(row=>({id:row.id,name:row.name})),...audio};
}Expected result: scenes, tracks, playlists, spatialSounds, playback, score, version and serverTime. Ambient commands use top-level version; score commands use score.version.
How to verify: Players only receive related tracks. GM spatial effectiveGain is zero without an eligible preview listener; that is not a missing sound source.
Configure a real track
Paste a native-uploaded track UUID. Set music/ambience/effect, gain 0–1 and loop. effect always disables looping on the server.
async ({api,signal},values) => {
const track=(await api.audio.state({}, {signal})).tracks.find(row=>row.id===values.trackId);
if (!track) throw new Error("Upload an original track through the native Library first");
return api.audio.updateTrack({id:track.id,version:track.version,name:"Harbor atmosphere",kind:values.kind,volume:values.volume,loop:values.loop}, {signal});
}Expected result: The track metadata changes with its current version; no upload API or external audio URL is invented.
How to verify: Inspect the library again and play it with the next recipe. Changing defaults is different from changing an existing scene voice.
Control one scene voice
Run play, pause, seek, play, setVolume and stop as separate explicit actions. Click Enable audio on this device before expecting sound. Seek must stay within the actual track duration.
async ({api,signal},values) => {
const state=await api.audio.state({sceneId:values.sceneId}, {signal});
const payload={sceneId:values.sceneId,trackId:values.trackId,version:state.version};
switch(values.action) {
case "play": return api.audio.play({...payload,volume:values.volume,loop:true}, {signal});
case "pause": return api.audio.pause(payload, {signal});
case "stop": return api.audio.stop(payload, {signal});
case "seek": return api.audio.seek({...payload,position:values.position}, {signal});
case "setVolume": return api.audio.setVolume({...payload,volume:values.volume}, {signal});
default: throw new Error("Choose a documented scene transport action");
}
}Expected result: The scene voice uses authoritative server-clock position and a fresh transport version. Local device mute/volume remains independent.
How to verify: Compare two clients on the scene. A paused voice retains position; stop returns it to zero. A stale version must conflict.
Compose an arrival playlist or simultaneous preset
Paste two native track UUIDs and choose playlist for sequential music or preset for simultaneous looping layers. Duration must be 1–86400 seconds. Run once and copy the returned id.
async ({api,signal},values) => {
return api.audio.createPlaylist({name:"Harbor arrival",kind:values.kind,mode:"sequential",fade:2,tracks:[
{soundId:values.trackId,duration:values.duration,gain:0.5},
{soundId:values.secondTrackId,duration:values.duration,gain:0.35},
]}, {signal});
}Expected result: A composition definition with two explicit soundId/duration/gain entries and a two-second transition fade. It does not start playback.
How to verify: Inspect playlists; then start it with the score recipe. Each repeated create is a separate definition.
Revise a composition without discarding its tracks
Use the created composition id. This edits the definition, retaining its track list and kind while changing name, mode and fade.
async ({api,signal},values) => {
const current=(await api.audio.state({}, {signal})).playlists.find(row=>row.id===values.id);
if (!current) throw new Error("Composition unavailable");
return api.audio.updatePlaylist({id:current.id,version:current.version,name:"Harbor arrival revised",kind:current.kind,mode:"shuffle",fade:1,tracks:current.tracks}, {signal});
}Expected result: A versioned definition update. Already running score snapshots do not rewrite themselves.
How to verify: Inspect the definition, then explicitly start it again to use the revised definition.
Start the composition as a score
Choose the scene and the composition UUID. The kind comes from the actual definition; it is not guessed from its name.
async ({api,signal},values) => {
const state=await api.audio.state({sceneId:values.sceneId}, {signal});
const definition=state.playlists.find(row=>row.id===values.id);
if (!definition) throw new Error("Composition unavailable");
return api.audio.startScore({sceneId:values.sceneId,id:definition.id,kind:definition.kind,repeat:true,expectedVersion:state.score.version}, {signal});
}Expected result: A playing score with its own revision and timed playbacks. Starting another composition can crossfade from the current one.
How to verify: Inspect score.state/playbacks on two clients. Use expectedVersion from score, not the ambient transport version.
Pause, resume, navigate and stop the score
Start a playlist first. Run pause, resume, next, previous, then stop one at a time. next/previous apply to playlist, not a simultaneous preset. A stopped composition needs Start rather than Resume.
async ({api,signal},values) => {
const state=await api.audio.state({sceneId:values.sceneId}, {signal});
const payload={sceneId:values.sceneId,expectedVersion:state.score.version};
switch(values.action) {
case "pause": return api.audio.pauseScore(payload, {signal});
case "resume": return api.audio.resumeScore(payload, {signal});
case "next": return api.audio.nextScore(payload, {signal});
case "previous": return api.audio.previousScore(payload, {signal});
case "stop": return api.audio.stopScore(payload, {signal});
default: throw new Error("Choose a documented score action");
}
}Expected result: Each action preserves the server timeline and checks the score revision.
How to verify: Inspect position/state after each action; a missing score or wrong kind fails clearly.
Place an audible harbor beacon
Enter scene, track and logical scene-pixel center. Radius is in the scene's configured distance units. The eligible token/listener determines received gain.
async ({api,signal},values) => {
return api.audio.createSpatialSound({sceneId:values.sceneId,trackId:values.trackId,x:values.x,y:values.y,radius:values.radius,gain:0.7,falloff:"smooth",loop:true,enabled:true,occlusion:true}, {signal});
}Expected result: A native spatial sound source, with smooth falloff and wall occlusion. This is separate from an ambient voice or composition.
How to verify: Use a player-controlled token near/far; as GM set previewTokenId in Inspect. Confirm effectiveGain changes and hidden data remains filtered.
Adjust the source range or disable it
Use the created source UUID in its own scene. This preserves the source track/location while changing radius, enabled and falloff.
async ({api,signal},values) => {
const state=await api.audio.state({sceneId:values.sceneId}, {signal});
const source=state.spatialSounds.find(row=>row.id===values.id);
if (!source) throw new Error("Source unavailable in this scene");
return api.audio.updateSpatialSound({sceneId:values.sceneId,id:source.id,version:source.version,radius:values.radius,enabled:values.enabled,falloff:"linear"}, {signal});
}Expected result: Versioned native source edit, without restarting the entire campaign audio library.
How to verify: Inspect source fields and effectiveGain. With enabled false the effective gain is zero.
Remove the test spatial source
Use only the source created for this tutorial.
async ({api,signal},values) => {
const source=(await api.audio.state({sceneId:values.sceneId}, {signal})).spatialSounds.find(row=>row.id===values.id);
if (!source) throw new Error("Source unavailable");
return api.audio.deleteSpatialSound({sceneId:values.sceneId,id:source.id,version:source.version}, {signal});
}Expected result: The source disappears; its reusable track remains in the library.
How to verify: Refresh spatialSounds and confirm that track metadata is still available to GM.
Remove the test composition
Stop its score first, then delete only the demonstration definition.
async ({api,signal},values) => {
const current=(await api.audio.state({}, {signal})).playlists.find(row=>row.id===values.id);
if (!current) throw new Error("Composition unavailable");
return api.audio.deletePlaylist({id:current.id,version:current.version}, {signal});
}Expected result: The composition definition is removed with its version. Track files remain reusable.
How to verify: Refresh playlists and confirm the native track library remains intact.
Delete only the disposable uploaded audio
Use an original track uploaded for this tutorial after finishing playback/source/composition tests. This deletes its file and detaches native references.
async ({api,signal},values) => {
const track=(await api.audio.state({}, {signal})).tracks.find(row=>row.id===values.trackId);
if (!track) throw new Error("Track unavailable");
return api.audio.deleteTrack({id:track.id,version:track.version}, {signal});
}Expected result: Versioned track removal with native reference cleanup.
How to verify: Refresh tracks, playlists and scene sources. This action is not a volume control or merely hiding an asset.
4. Understand and adapt the complete files
main.mjs renders the launcher and form. recipes.mjs owns behavior: change the chosen recipe’s run function while keeping its signature. The form passes real number, boolean and File values, blocks overlapping requests and renders using textContent. It does not evaluate user-provided code. api, host, assets and storage belong to the mount; signal cancels work on deactivation. The modal stays open during pending work and is released when the mount aborts. Each business function remains responsible for revisions, permissions and transaction sequencing.
manifest.json
{
"id": "tutorial.audio",
"name": "Harbor audio tools",
"version": "1.0.0",
"sdk": {
"requires": ">=1.0.0 <2.0.0",
"tested": "1.0.0"
},
"entry": "main.mjs",
"description": "Configure native tracks, playlists, spatial sources, scene voices and timed scores with the correct revisions.",
"author": "Gravewright documentation",
"license": "MIT"
}main.mjs
import {recipes} from './recipes.mjs';
export default {
start({styles}) { styles.use('styles.css'); },
register({register}) {
register("table.interface", ({root,api,host,assets,storage,context,events,viewport,signal,onDispose}) => {
const doc=root.ownerDocument;
const button=doc.createElement('button');
button.type='button';button.className='tutorial-launcher';
button.textContent="Harbor audio tools";
root.append(button);
for(const type of ['pointerdown','mousedown'])button.addEventListener(type,event=>event.stopPropagation(),{signal});
let modal,pending=false;
button.addEventListener('click',event=>{
event.stopPropagation();
if(modal?.isConnected) { modal.focus();return; }
const panel=doc.createElement('section');
panel.className="tutorial-workbench-audio";
panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','true');
panel.setAttribute('aria-label',button.textContent);panel.tabIndex=-1;
const title=doc.createElement('h2');title.textContent=button.textContent;panel.append(title);
const choose=doc.createElement('select');choose.setAttribute('aria-label','Action');
for(const recipe of recipes) {
const option=doc.createElement('option');option.value=recipe.id;option.textContent=recipe.label;choose.append(option);
}
const form=doc.createElement('form'),fields=doc.createElement('div');
const description=doc.createElement('p');
const run=doc.createElement('button');run.type='submit';run.textContent='Run action';
const close=doc.createElement('button');close.type='button';close.textContent='Close';
const output=doc.createElement('pre');output.setAttribute('role','status');output.setAttribute('aria-live','polite');
form.append(description,fields,run,close,output);panel.append(choose,form);
function renderFields(){
const recipe=recipes.find(item=>item.id===choose.value);fields.replaceChildren();
description.textContent=recipe.description;
for(const field of recipe.fields||[]) {
const label=doc.createElement('label');label.textContent=field.label;
const input=doc.createElement('input');input.name=field.name;input.type=field.type||'text';
if(field.type==='number')input.step=String(field.step??'any');
if(field.type==='checkbox')input.checked=!!field.value;
else if(field.type!=='file')input.value=String(field.value??'');
input.required=!!field.required;
if(field.accept)input.accept=field.accept;
label.append(input);fields.append(label);
}
output.textContent='';
}
choose.addEventListener('change',renderFields,{signal});renderFields();
form.addEventListener('submit',async event=>{
event.preventDefault();if(pending)return;
const recipe=recipes.find(item=>item.id===choose.value),values={};
for(const input of fields.querySelectorAll('input')) {
values[input.name]=input.type==='file'?input.files[0]:input.type==='checkbox'?input.checked:input.type==='number'?Number(input.value):input.value;
}
pending=true;choose.disabled=true;run.disabled=true;close.disabled=true;
for(const input of fields.querySelectorAll('input'))input.disabled=true;
output.textContent='Running…';
try {
const value=await recipe.run({api,host,assets,storage,context,events,viewport,signal,onDispose},values);
if(!signal.aborted)output.textContent=typeof value==='string'?value:JSON.stringify(value??null,null,2);
} catch(error) {
if(!signal.aborted)output.textContent=String(error.code||error.message||'unavailable');
} finally {
pending=false;
if(!signal.aborted){choose.disabled=false;run.disabled=false;close.disabled=false;for(const input of fields.querySelectorAll('input'))input.disabled=false;}
}
},{signal});
close.addEventListener('click',()=>void modal.close(),{signal});
modal=api.ui.openModal(panel,{canClose:()=>!pending||signal.aborted});
},{signal});
onDispose(()=>{button.remove();if(modal)void modal.close();});
});
},
stop() {}
};recipes.mjs
export const recipes=[
{
id:"inspect",
label:"Inspect tracks, scene transport and score revisions",
description:"Prepare original audio through the native Library. Run without a scene to discover scene UUIDs, then run again with the chosen one. A GM may specify an existing token UUID to preview its spatial listening position. Copy track, playlist and source IDs from this result.",
fields:[
{
"name": "sceneId",
"label": "Scene UUID, optional",
"value": "",
"type": "text",
"required": false
},
{
"name": "previewTokenId",
"label": "GM listener token UUID, optional",
"value": "",
"type": "text",
"required": false
}
],
run:async ({api,signal},values) => {
const maps = await api.maps.state({}, {signal});
const audio = await api.audio.state({...(values.sceneId ? {sceneId:values.sceneId} : {}),...(values.previewTokenId ? {previewTokenId:values.previewTokenId} : {})}, {signal});
return {scenes:maps.maps.map(row=>({id:row.id,name:row.name})),...audio};
}
},
{
id:"track",
label:"Configure a real track",
description:"Paste a native-uploaded track UUID. Set music/ambience/effect, gain 0–1 and loop. effect always disables looping on the server.",
fields:[
{
"name": "trackId",
"label": "Track UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "kind",
"label": "Kind: music, ambience or effect",
"value": "ambience",
"type": "text",
"required": true
},
{
"name": "volume",
"label": "Default volume",
"value": 0.5,
"type": "number",
"required": true
},
{
"name": "loop",
"label": "Loop",
"value": true,
"type": "checkbox",
"required": true
}
],
run:async ({api,signal},values) => {
const track=(await api.audio.state({}, {signal})).tracks.find(row=>row.id===values.trackId);
if (!track) throw new Error("Upload an original track through the native Library first");
return api.audio.updateTrack({id:track.id,version:track.version,name:"Harbor atmosphere",kind:values.kind,volume:values.volume,loop:values.loop}, {signal});
}
},
{
id:"playback",
label:"Control one scene voice",
description:"Run play, pause, seek, play, setVolume and stop as separate explicit actions. Click Enable audio on this device before expecting sound. Seek must stay within the actual track duration.",
fields:[
{
"name": "sceneId",
"label": "Scene UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "trackId",
"label": "Track UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "action",
"label": "Action: play, pause, stop, seek or setVolume",
"value": "play",
"type": "text",
"required": true
},
{
"name": "volume",
"label": "Voice volume",
"value": 0.4,
"type": "number",
"required": true
},
{
"name": "position",
"label": "Seek seconds",
"value": 1,
"type": "number",
"required": true
}
],
run:async ({api,signal},values) => {
const state=await api.audio.state({sceneId:values.sceneId}, {signal});
const payload={sceneId:values.sceneId,trackId:values.trackId,version:state.version};
switch(values.action) {
case "play": return api.audio.play({...payload,volume:values.volume,loop:true}, {signal});
case "pause": return api.audio.pause(payload, {signal});
case "stop": return api.audio.stop(payload, {signal});
case "seek": return api.audio.seek({...payload,position:values.position}, {signal});
case "setVolume": return api.audio.setVolume({...payload,volume:values.volume}, {signal});
default: throw new Error("Choose a documented scene transport action");
}
}
},
{
id:"playlist-create",
label:"Compose an arrival playlist or simultaneous preset",
description:"Paste two native track UUIDs and choose playlist for sequential music or preset for simultaneous looping layers. Duration must be 1–86400 seconds. Run once and copy the returned id.",
fields:[
{
"name": "trackId",
"label": "First track UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "secondTrackId",
"label": "Second track UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "kind",
"label": "Composition kind: playlist or preset",
"value": "playlist",
"type": "text",
"required": true
},
{
"name": "duration",
"label": "Each entry duration in seconds",
"value": 30,
"type": "number",
"required": true
}
],
run:async ({api,signal},values) => {
return api.audio.createPlaylist({name:"Harbor arrival",kind:values.kind,mode:"sequential",fade:2,tracks:[
{soundId:values.trackId,duration:values.duration,gain:0.5},
{soundId:values.secondTrackId,duration:values.duration,gain:0.35},
]}, {signal});
}
},
{
id:"playlist-update",
label:"Revise a composition without discarding its tracks",
description:"Use the created composition id. This edits the definition, retaining its track list and kind while changing name, mode and fade.",
fields:[
{
"name": "id",
"label": "Composition UUID",
"value": "",
"type": "text",
"required": true
}
],
run:async ({api,signal},values) => {
const current=(await api.audio.state({}, {signal})).playlists.find(row=>row.id===values.id);
if (!current) throw new Error("Composition unavailable");
return api.audio.updatePlaylist({id:current.id,version:current.version,name:"Harbor arrival revised",kind:current.kind,mode:"shuffle",fade:1,tracks:current.tracks}, {signal});
}
},
{
id:"score-start",
label:"Start the composition as a score",
description:"Choose the scene and the composition UUID. The kind comes from the actual definition; it is not guessed from its name.",
fields:[
{
"name": "sceneId",
"label": "Scene UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Composition UUID",
"value": "",
"type": "text",
"required": true
}
],
run:async ({api,signal},values) => {
const state=await api.audio.state({sceneId:values.sceneId}, {signal});
const definition=state.playlists.find(row=>row.id===values.id);
if (!definition) throw new Error("Composition unavailable");
return api.audio.startScore({sceneId:values.sceneId,id:definition.id,kind:definition.kind,repeat:true,expectedVersion:state.score.version}, {signal});
}
},
{
id:"score-control",
label:"Pause, resume, navigate and stop the score",
description:"Start a playlist first. Run pause, resume, next, previous, then stop one at a time. next/previous apply to playlist, not a simultaneous preset. A stopped composition needs Start rather than Resume.",
fields:[
{
"name": "sceneId",
"label": "Scene UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "action",
"label": "Action: pause, resume, next, previous or stop",
"value": "pause",
"type": "text",
"required": true
}
],
run:async ({api,signal},values) => {
const state=await api.audio.state({sceneId:values.sceneId}, {signal});
const payload={sceneId:values.sceneId,expectedVersion:state.score.version};
switch(values.action) {
case "pause": return api.audio.pauseScore(payload, {signal});
case "resume": return api.audio.resumeScore(payload, {signal});
case "next": return api.audio.nextScore(payload, {signal});
case "previous": return api.audio.previousScore(payload, {signal});
case "stop": return api.audio.stopScore(payload, {signal});
default: throw new Error("Choose a documented score action");
}
}
},
{
id:"spatial-create",
label:"Place an audible harbor beacon",
description:"Enter scene, track and logical scene-pixel center. Radius is in the scene's configured distance units. The eligible token/listener determines received gain.",
fields:[
{
"name": "sceneId",
"label": "Scene UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "trackId",
"label": "Track UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "x",
"label": "Scene x pixels",
"value": 140,
"type": "number",
"required": true
},
{
"name": "y",
"label": "Scene y pixels",
"value": 140,
"type": "number",
"required": true
},
{
"name": "radius",
"label": "Radius in scene distance units",
"value": 10,
"type": "number",
"required": true
}
],
run:async ({api,signal},values) => {
return api.audio.createSpatialSound({sceneId:values.sceneId,trackId:values.trackId,x:values.x,y:values.y,radius:values.radius,gain:0.7,falloff:"smooth",loop:true,enabled:true,occlusion:true}, {signal});
}
},
{
id:"spatial-update",
label:"Adjust the source range or disable it",
description:"Use the created source UUID in its own scene. This preserves the source track/location while changing radius, enabled and falloff.",
fields:[
{
"name": "sceneId",
"label": "Scene UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Spatial source UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "radius",
"label": "New radius",
"value": 15,
"type": "number",
"required": true
},
{
"name": "enabled",
"label": "Enabled",
"value": true,
"type": "checkbox",
"required": true
}
],
run:async ({api,signal},values) => {
const state=await api.audio.state({sceneId:values.sceneId}, {signal});
const source=state.spatialSounds.find(row=>row.id===values.id);
if (!source) throw new Error("Source unavailable in this scene");
return api.audio.updateSpatialSound({sceneId:values.sceneId,id:source.id,version:source.version,radius:values.radius,enabled:values.enabled,falloff:"linear"}, {signal});
}
},
{
id:"spatial-delete",
label:"Remove the test spatial source",
description:"Use only the source created for this tutorial.",
fields:[
{
"name": "sceneId",
"label": "Scene UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Disposable spatial source UUID",
"value": "",
"type": "text",
"required": true
}
],
run:async ({api,signal},values) => {
const source=(await api.audio.state({sceneId:values.sceneId}, {signal})).spatialSounds.find(row=>row.id===values.id);
if (!source) throw new Error("Source unavailable");
return api.audio.deleteSpatialSound({sceneId:values.sceneId,id:source.id,version:source.version}, {signal});
}
},
{
id:"playlist-delete",
label:"Remove the test composition",
description:"Stop its score first, then delete only the demonstration definition.",
fields:[
{
"name": "id",
"label": "Disposable composition UUID",
"value": "",
"type": "text",
"required": true
}
],
run:async ({api,signal},values) => {
const current=(await api.audio.state({}, {signal})).playlists.find(row=>row.id===values.id);
if (!current) throw new Error("Composition unavailable");
return api.audio.deletePlaylist({id:current.id,version:current.version}, {signal});
}
},
{
id:"track-delete",
label:"Delete only the disposable uploaded audio",
description:"Use an original track uploaded for this tutorial after finishing playback/source/composition tests. This deletes its file and detaches native references.",
fields:[
{
"name": "trackId",
"label": "Disposable original track UUID",
"value": "",
"type": "text",
"required": true
}
],
run:async ({api,signal},values) => {
const track=(await api.audio.state({}, {signal})).tracks.find(row=>row.id===values.trackId);
if (!track) throw new Error("Track unavailable");
return api.audio.deleteTrack({id:track.id,version:track.version}, {signal});
}
}
];styles.css
.gw-module-root[data-module-id="tutorial.audio"] .tutorial-launcher { position: fixed; left: 16px; bottom: 112px; z-index: 10; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); font-size: 14px; pointer-events: auto; max-width: min(440px, calc(100vw - 32px)); white-space: normal; }
.tutorial-workbench-audio { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-audio label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-audio input, .tutorial-workbench-audio select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-audio pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-audio button { margin-right: var(--space-3); }LICENSE.txt
MIT License
Copyright (c) 2026 Gravewright contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.5. Permissions, failures and final test
Test track defaults separately from active voices. Verify ambient version versus score.version, playlist versus preset, time/volume ranges and stale versions. Compare GM previewTokenId with an owned player token near/far from the spatial source. Silent effectiveGain without a listener is expected; a failed request is not. Native upload validates actual file metadata and size; follow its displayed limits rather than inventing an upload endpoint. Each Create makes another definition/source. Multi-step actions are not one transaction; after a network failure inspect state before repeating. Stop audio before cleanup, delete only sample resources and confirm players cannot invoke GM transport operations.
How to use AI to extend this project
Extend tutorial-audio for an original harbor scene. Inspect audio/services.py, soundtrack.py and frontend.json on 0.3.0. Use real uploaded track IDs, sceneId, native file preparation, ambient version and score expectedVersion correctly. Preserve listener privacy, server clock and cancellations. Do not invent audio upload APIs or require a newer host. Return complete source, sequential clicks and GM/player spatial/playback checks.