InícioDocs para devsComo criar ferramentas de áudio e trilha da cena
Tutoriais de implementação 15 min de leitura

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.

Módulo Harbor audio tools instalado após executar a receita inspect. Gravewright 0.3.0, interface em inglês.
Módulo Harbor audio tools instalado após executar a receita inspect. Gravewright 0.3.0, interface em inglês. · Abrir captura completa ↗

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.

text
audio/
  manifest.json
  main.mjs
  recipes.mjs
  styles.css
  LICENSE.txt

Use 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:

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.zip

No 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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.

javascript
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

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

javascript
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

javascript
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

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

text
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

text
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.

Todos os tutoriais · API