Como criar ferramentas de áudio e trilha da cena
Configure faixas nativas, playlists, fontes espaciais, vozes da cena e trilhas temporizadas com revisões corretas.

O projeto que você vai criar
Crie Harbor audio tools. Prepare duas faixas originais na Library real, configure ambiente do porto, controle uma voz da cena, componha música de chegada, navegue a trilha e posicione um sinal espacial. São camadas diferentes de estado, com revisões e projeções de ouvinte próprias.
Host alvo: Gravewright 0.3.0. As ações rodam somente quando você clica Run action; leituras e escritas usam a conta conectada. O painel de demonstração está em inglês. Cada receita abaixo informa os dados de entrada e o resultado concreto.
1. Crie os arquivos
Crie uma pasta audio. Salve os arquivos mostrados abaixo em UTF-8, ou baixe o projeto completo. manifest.json fica na raiz. Não há dependências npm nem build de JavaScript.
audio/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txtUse campanha descartável, cena e dois áudios curtos originais WAV/Ogg/FLAC/MP3. Como GM, abra Library na mesa e envie por Ambient audio ou Sound effect; a API de domínio não tem uploadTrack, então o pacote começa com faixas existentes. Inspect retorna UUIDs. Clique Enable audio em cada cliente e confira volume local. Toda mutação de áudio exige GM; jogadores recebem só a projeção de escuta autorizada.
2. Empacote, instale e ative
Partindo da pasta do projeto, execute no 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.zipNo Linux/macOS, substitua python por python3 no mesmo comando. A listagem precisa mostrar manifest.json na raiz. Como proprietário, abra Inside → Módulos → Instalar módulo → Instalar de ZIP, selecione o ZIP e clique Instalar. Como GM, abra a campanha, use o botão Gravewright da mesa → Extensions e clique Activate na versão 1.0.0 de Harbor audio tools. Feche o menu e clique no botão Harbor audio tools. Se precisar de detalhes de cada tela, veja seu primeiro módulo.
O projeto fica inativo até a seleção na mesa. Teste uma extensão de demonstração por vez, pois seus botões usam a mesma posição. Para novos bytes, mude version para 1.0.1, gere outro ZIP, instale e ative essa versão; não edite o pacote instalado.
3. Execute cada receita
Inspecione faixas, transporte e revisão da trilha
Prepare áudio original pela Library. Execute sem cena para descobrir UUIDs e depois com a escolhida. GM pode indicar token existente para simular sua posição de escuta. Copie IDs de faixas, listas e fontes do resultado.
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};
}Resultado esperado: scenes, tracks, playlists, spatialSounds, playback, score, version e serverTime. Ambiente usa version da raiz; trilha usa score.version.
Como conferir: Jogadores só recebem faixas relacionadas. EffectiveGain do GM sem ouvinte de preview elegível é zero; isso não significa fonte ausente.
Configure uma faixa real
Cole UUID de faixa enviada pela biblioteca. Defina music/ambience/effect, volume 0–1 e loop. Effect sempre desabilita repetição no servidor.
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});
}Resultado esperado: Metadados mudam com versão atual; não há API de upload ou URL externa inventada.
Como conferir: Releia a biblioteca e toque pela próxima receita. Alterar padrões é diferente de alterar uma voz já tocando na cena.
Controle uma voz da cena
Execute play, pause, seek, play, setVolume e stop separadamente. Clique Enable audio neste dispositivo para ouvir. Seek deve respeitar a duração real da faixa.
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");
}
}Resultado esperado: A voz usa posição do relógio do servidor e revisão atual do transporte. Mute/volume local continuam independentes.
Como conferir: Compare dois clientes na cena. Pause conserva posição; stop zera. Uma versão antiga deve gerar conflito.
Monte playlist de chegada ou preset simultâneo
Cole dois UUIDs nativos. Playlist toca sequência; preset reúne camadas simultâneas em loop. Duração deve ficar entre 1 e 86400 segundos. Execute uma vez e copie 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});
}Resultado esperado: Definição com soundId/duration/gain e fade de dois segundos. A ação não inicia reprodução.
Como conferir: Confira playlists e inicie pela receita de trilha. Repetir criação produz outra definição.
Revise a composição sem perder suas faixas
Use id criado. A receita conserva lista e kind, alterando nome, modo e fade da definição.
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});
}Resultado esperado: Atualização com versão. A trilha já em execução não é reescrita sozinha.
Como conferir: Confira definição e inicie novamente explicitamente para usar a revisão.
Inicie a composição como trilha
Informe cena e UUID da composição. Kind vem da definição real; não é deduzido pelo nome.
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});
}Resultado esperado: Trilha playing com revisão própria e vozes temporizadas. Iniciar outra composição pode fazer transição da atual.
Como conferir: Confira score.state/playbacks em dois clientes. Use expectedVersion da trilha, não version do ambiente.
Pause, retome, navegue e pare a trilha
Inicie playlist primeiro. Execute pause, resume, next, previous e stop separadamente. Next/previous não servem a preset simultâneo. Após stop, use Start em vez de 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");
}
}Resultado esperado: Cada ação conserva a linha temporal do servidor e verifica revisão da trilha.
Como conferir: Confira posição/estado a cada ação; trilha ausente ou kind incompatível falham claramente.
Posicione um sinal sonoro no porto
Informe cena, faixa e centro em pixels lógicos. Raio usa a unidade de distância configurada da cena. Token/ouvinte elegível determina ganho recebido.
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});
}Resultado esperado: Fonte sonora espacial nativa com queda suave e oclusão por paredes, separada de voz ambiente ou composição.
Como conferir: Mova token controlado pelo jogador para perto/longe; GM usa previewTokenId em Inspect. Confira effectiveGain.
Ajuste alcance ou desabilite a fonte
Use UUID da fonte em sua própria cena. A receita preserva faixa/local, alterando raio, enabled e 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});
}Resultado esperado: Edição da fonte com versão, sem reiniciar toda a biblioteca da campanha.
Como conferir: Confira campos e effectiveGain; enabled false resulta em ganho efetivo zero.
Remova a fonte espacial de teste
Use somente a fonte criada neste 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});
}Resultado esperado: A fonte desaparece; a faixa reutilizável continua na biblioteca.
Como conferir: Releia spatialSounds e confira que GM ainda vê a faixa.
Remova a composição de teste
Pare a trilha antes e exclua somente a definição de demonstração.
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});
}Resultado esperado: A definição é removida com sua versão; arquivos das faixas permanecem reutilizáveis.
Como conferir: Atualize playlists e confirme que a biblioteca nativa continua intacta.
Exclua somente o áudio descartável enviado
Use faixa original enviada para o tutorial depois dos testes de reprodução/fontes/composição. A ação exclui seu arquivo e remove referências nativas.
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});
}Resultado esperado: Remoção com versão e limpeza nativa de referências.
Como conferir: Confira tracks, playlists e fontes. A ação não apenas abaixa volume ou esconde recurso.
4. Entenda e adapte os arquivos completos
main.mjs desenha apenas o lançador e o formulário. recipes.mjs contém as ações: para adaptar o comportamento, altere a função run da receita desejada, mantendo sua assinatura. O formulário passa tipos reais (number, boolean e File), bloqueia requisições simultâneas e desenha resultados com textContent. Não há avaliação de código fornecido pelo usuário. api, host, assets e storage pertencem à montagem; signal cancela trabalho ao desativar. O modal não fecha durante uma operação pendente e é liberado ao abortar a montagem. A função de negócio continua responsável por revisões, permissões e sequência transacional.
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. Permissões, falhas e teste final
Teste padrões da faixa separados de vozes ativas. Confira version do ambiente versus score.version, playlist versus preset, limites de tempo/volume e versões antigas. Compare previewTokenId do GM com token controlado por jogador perto/longe da fonte. EffectiveGain zero sem ouvinte é esperado; erro de requisição não é silêncio normal. Upload nativo valida metadados e tamanho; respeite os limites da tela sem inventar endpoint. Cada Create cria definição/fonte nova. Etapas não formam transação única; após falha de rede, inspecione antes de repetir. Pare áudio antes de limpar, exclua apenas exemplos e confira que jogador não executa transporte do GM.
Como usar a IA para estender este projeto
Amplie tutorial-audio para cena original de porto. Confira audio/services.py, soundtrack.py e frontend.json da 0.3.0. Use IDs reais de faixas enviadas, sceneId, preparo nativo, version de ambiente e expectedVersion de trilha corretamente. Preserve privacidade de ouvintes, relógio do servidor e cancelamento. Não invente upload nem exija host novo. Entregue fontes completos, sequência de cliques e testes espaciais/reprodução de GM/jogador.