Skip to content
Back to skills

Adaptive Music

BSecurity

Wire music stems to game state: combat, explore, boss and tension crossfades on a shared bus.

  • 77 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 16, 2026
ai-agentsgoshellnodeapi

Works with

  • claude code
  • cursor
  • cli
  • api
  • mcp

Security analysis

B75/100
  • criticalAccesses sensitive system or user directories

Pro scans all 2 files and shows the line behind each finding

Scanned October 16, 2026

npx -y skills add SummerEngine/summer --skill adaptive-music --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Adaptive Music?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Adaptive Music
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/summerengine-adaptive-music/badge)](https://www.skillsdirectory.com/skills/summerengine-adaptive-music)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: adaptive-music
description: "Wire music stems to game state: combat, explore, boss and tension crossfades on a shared bus."
license: MIT
compatibility: [Cursor, Claude Code, Windsurf, Codex]
category: audio
user-invocable: true
allowed-tools: Read Grep Glob Write Edit summer_assets_generate_audio summer_assets_search summer_import_from_url summer_run_script summer_add_node summer_set_prop summer_connect_signal summer_inspect_node summer_get_scene_tree summer_project_setting summer_call
paths: ["audio/music/**", "scripts/audio/**", "**/*.tscn", "default_bus_layout.tres"]
---

# /adaptive-music — Wire Stems to Game State

## Overview

This skill wires music; it does not generate it. It connects 3–5 music stems (or full tracks) to a game state machine so the music follows the player's situation — exploration → tension → combat → victory → exploration — with crossfades, ducking, and bus routing.

This skill assumes the stems already exist. If they don't, run `/music-track` first for each state, or generate matching stems (same tempo, same key, same bar count) so they layer/crossfade cleanly.

## When to use

- The audio bible specifies a dynamic music model (layered stems / vertical re-orchestration / horizontal sequencing).
- You have at least two music tracks for two different states and want the engine to switch between them.
- Player-state-driven crossfades, not random shuffling.

## When NOT to use

- Generating the stems themselves → `/music-track`.
- Single static loop with no state changes → `/music-track` and an `AudioStreamPlayer` is enough.

## Steps

### 1. Read the audio bible's dynamic music plan

```
Read .summer/audio-bible.md
```

Quote the plan back. Example:

> Bible plan: Layered stems. States CALM / TENSION / COMBAT / VICTORY. CALM → TENSION on enemy detect (2s crossfade). TENSION → COMBAT on first damage (instant drum stem). COMBAT → VICTORY on last enemy dies (drum cuts, brass swell). VICTORY → CALM after 4s. Music ducks `-6 dB` on dialogue.

If the plan is absent, run `/audio-direction` step 6 first.

### 2. Pick the wiring model

| Model | When | Implementation |
|---|---|---|
| **Horizontal sequencing** | 3–5 full tracks, one per state, crossfade | One `AudioStreamPlayer` per track on the `Music` bus, tween volumes on state change |
| **Layered stems** | One base loop + drum/brass/strings stems that turn on with intensity | All stems play simultaneously at silence; mute/unmute via volume tween |
| **Vertical re-orchestration** | Same melody, different orchestration per state, snap on bar | All variants play simultaneously muted; swap on next bar boundary |
| **Procedural / generative** | Rules generate music in real-time | Beyond this skill; needs a custom system |

For most games, **horizontal sequencing** is right. It is cheaper to author and easier to swap.

### 3. Confirm stems exist (or generate them)

```
assets.search({ query: "music", assetType: "music", scope: "all" })
```

You need (typical case):

- `audio/music/calm.mp3` — exploration loop, sparse
- `audio/music/tension.mp3` — same tempo/key, denser, no drums
- `audio/music/combat.mp3` — same tempo/key, full drums, urgent
- (optional) `audio/music/boss.mp3` — distinct from combat
- (optional) `audio/music/victory.mp3` — short linear sting (4–8s)

If any are missing, run `/music-track` for each. Use **the same BPM, key, time signature, and bar length** for all of them so they overlap cleanly.

### 4. Verify / extend the bus layout

The Music bus must exist and route to Master. If `/audio-direction` ran, it does. Verify:

```
Read default_bus_layout.tres   # or wherever the project stores it
```

You want this structure:

```
Master
├── Music         (-8 dB; ducks -6 dB when Voice is active)
├── SFX           (0 dB; routes to Reverb buses)
├── UI            (-3 dB)
├── Voice         (0 dB)
├── Ambient       (-12 dB)
├── Reverb_Small
├── Reverb_Large
└── Reverb_Outdoor
```

If `Music` bus is missing, run `/audio-direction` step 8.

For ducking, add a sidechain compressor on the `Music` bus with the `Voice` bus as the sidechain source. Or implement ducking in code (simpler — see step 7).

### 5. Build the player rig

For horizontal sequencing, instantiate one `AudioStreamPlayer` per state, all on the Music bus, all autoplay, all looping, all starting at `-60 dB` except the active state. One script builds the whole rig (`scenePath` is the exact scene to change; `ctx.save_scene()` writes it):

```
summer_run_script(scenePath="res://main.tscn", source="""
func run(ctx):
    var music = ctx.get_or_create("Node", "Music")
    var states := {"Calm": 0.0, "Tension": -60.0, "Combat": -60.0}   # only the active state is audible
    for state in states:
        ctx.add_node("AudioStreamPlayer", state, music, {
            "stream": load("res://audio/music/%s.mp3" % state.to_lower()),
            "bus": "Music",
            "volume_db": states[state],
            "autoplay": true,
        })
    ctx.save_scene()
""")
```

A fourth state (Boss) is one more entry in `states`, not five more calls. Loop the streams on the imported files (`loop` in the import settings, or `AudioStreamMP3.loop`), not on the player.

All three play simultaneously, in sync, but only one is audible. State transitions tween volumes — the music never restarts, only fades.

This is why same BPM / same key / same bar length matter: all three are at the same playhead at all times.

### 6. The MusicDirector script

```gdscript
# scripts/audio/MusicDirector.gd
class_name MusicDirector
extends Node

enum State { CALM, TENSION, COMBAT, VICTORY, BOSS }

@export var calm: AudioStreamPlayer
@export var tension: AudioStreamPlayer
@export var combat: AudioStreamPlayer
@export var boss: AudioStreamPlayer
@export var victory_sting: AudioStreamPlayer  # one-shot, not looping

@export var crossfade_seconds: float = 2.0
@export var instant_states: Array[State] = [State.COMBAT]  # snap, no fade
@export var active_db: float = 0.0
@export var inactive_db: float = -60.0

var _state: State = State.CALM
var _tween: Tween

func _ready() -> void:
	_apply_state(_state, true)

func set_state(new_state: State) -> void:
	if new_state == _state:
		return
	var instant := new_state in instant_states
	_state = new_state
	_apply_state(new_state, instant)
	if new_state == State.VICTORY:
		victory_sting.play()
		await get_tree().create_timer(4.0).timeout
		set_state(State.CALM)

func _apply_state(state: State, instant: bool) -> void:
	if _tween and _tween.is_valid():
		_tween.kill()
	_tween = create_tween().set_parallel(true)
	var dur := 0.0 if instant else crossfade_seconds
	for pair in [
		[calm,    state == State.CALM],
		[tension, state == State.TENSION],
		[combat,  state == State.COMBAT],
		[boss,    state == State.BOSS],
	]:
		var player: AudioStreamPlayer = pair[0]
		var should_be_active: bool = pair[1]
		if player == null:
			continue
		var target_db := active_db if should_be_active else inactive_db
		_tween.tween_property(player, "volume_db", target_db, dur)
```

Attach it under `/root/Game/Music`, hook the exported player slots to the four players. The director is the single point of truth for music state.

### 7. Drive state from gameplay signals

Where does `set_state()` get called from? The game state machine. Hook signals from combat / detection / encounter logic:

```gdscript
# scripts/audio/MusicHooks.gd
extends Node
@export var director: MusicDirector

func _ready() -> void:
	EventBus.enemy_detected_player.connect(_on_tension)
	EventBus.player_took_damage.connect(_on_combat)
	EventBus.encounter_cleared.connect(_on_victory)
	EventBus.boss_started.connect(_on_boss)
	EventBus.boss_ended.connect(_on_calm)
	EventBus.dialogue_started.connect(_on_duck)
	EventBus.dialogue_ended.connect(_on_unduck)

func _on_tension() -> void: director.set_state(MusicDirector.State.TENSION)
func _on_combat()  -> void: director.set_state(MusicDirector.State.COMBAT)
func _on_victory() -> void: director.set_state(MusicDirector.State.VICTORY)
func _on_boss()    -> void: director.set_state(MusicDirector.State.BOSS)
func _on_calm()    -> void: director.set_state(MusicDirector.State.CALM)

func _on_duck() -> void:
	var bus_idx := AudioServer.get_bus_index(&"Music")
	create_tween().tween_method(
		func(db: float) -> void: AudioServer.set_bus_volume_db(bus_idx, db),
		AudioServer.get_bus_volume_db(bus_idx), -14.0, 0.3
	)

func _on_unduck() -> void:
	var bus_idx := AudioServer.get_bus_index(&"Music")
	create_tween().tween_method(
		func(db: float) -> void: AudioServer.set_bus_volume_db(bus_idx, db),
		AudioServer.get_bus_volume_db(bus_idx), -8.0, 0.5
	)
```

If your project doesn't have an `EventBus` autoload yet, add one — adaptive music depends on event-driven signals, not polling.

Wire the connections via `summer_connect_signal` so they survive a scene reload.

### 8. Layered stems variant (alternative to step 6)

If the bible says **layered stems** instead of horizontal sequencing, the rig is the same (multiple `AudioStreamPlayer`s, all looping in sync, all on Music bus) but the volume map is different:

| State | Base | Drums | Strings | Brass |
|---|---|---|---|---|
| CALM | 0 | -60 | -60 | -60 |
| TENSION | 0 | -60 | -10 | -60 |
| COMBAT | 0 | 0 | -3 | -10 |
| BOSS | -60 | 0 | 0 | 0 |

The director method is the same `_apply_state` pattern — just more players and per-player target_db tables.

### 9. Bar-locked snap (vertical re-orchestration)

For the third model, snapping must happen on a bar boundary (not mid-phrase). All players run in sync; on state change, schedule the snap for the next bar:

```gdscript
const BPM := 128.0
const BEATS_PER_BAR := 4
const SECONDS_PER_BAR := (60.0 / BPM) * BEATS_PER_BAR

func snap_at_next_bar(callable: Callable) -> void:
	var pos := calm.get_playback_position()
	var into_bar := fmod(pos, SECONDS_PER_BAR)
	var wait := SECONDS_PER_BAR - into_bar
	get_tree().create_timer(wait).timeout.connect(callable)
```

Use this for boss reveals, story beats — anywhere a snap-on-bar is more dramatic than a crossfade.

### 10. Test the transitions

Run the game (`summer_play`) and trigger each transition. Listen for:

- Crossfade artifacts at `crossfade_seconds = 2.0`. Too fast: fades are choppy. Too slow: state changes feel disconnected from gameplay.
- Out-of-sync stems. If combat enters and the drums hit on the wrong beat, the stems weren't generated at the same BPM/bar length. Regenerate.
- Ducking that's too aggressive (music disappears) or too subtle (voice still buried). The `-6 dB` is a starting value; tune in step 7.
- Victory sting that loops (it shouldn't). Set `victory_sting` to a non-loop AudioStream.

### 11. Edge case: scene transitions

When the player changes scenes, the `MusicDirector` either:

a) **Persists** as an autoload. New scenes call `MusicDirector.set_state()` to take over.
b) **Per-scene**, fades out the previous before the scene changes (`set_state(CALM)` then crossfade to `-60 dB` on all then change scene).

Persistent is preferred for open-world; per-scene for level-based games.

## Reference card — bus map

```
Master
├── Music          -8 dB         (ducks to -14 dB on Voice)
│   └── (one AudioStreamPlayer per state, all looping in sync)
├── SFX            0 dB
│   └── routes to Reverb_Small / Reverb_Large / Reverb_Outdoor sends
├── UI             -3 dB
├── Voice          0 dB
├── Ambient        -12 dB
├── Reverb_Small   (return)
├── Reverb_Large   (return)
└── Reverb_Outdoor (return)
```

## Anti-patterns

- **Stems generated at different BPM/key.** They will not crossfade cleanly. Same BPM, same key, same bar count.
- **Restarting tracks on state change.** Players hear the restart. Always crossfade volumes; never `play()` mid-state.
- **No autoplay on the silent stems.** They can't fade in to phase if they never started.
- **Polling state in `_process`.** Use signals. Music director listens to `EventBus`, not to game state every frame.
- **Hardcoding volumes in `set_state`.** Use the table-driven `_apply_state` so adding a new state doesn't fork the function.
- **Ducking music to silence on dialogue.** The bible says `-6 dB` (so music drops from `-8` to `-14`), not muted. Voice should sit on a present bed, not a dead one.
- **Boss music as a louder combat track.** Distinct, named instrument; ideally different tempo or key. The bible's `Boss music is its own track` rule.

## Edge cases

- **Player triggers TENSION → COMBAT in 0.2s.** The `instant_states = [State.COMBAT]` makes COMBAT snap so it lands on the swing connect. The intermediate fade is wrong — combat must feel immediate.
- **Player flees combat back to TENSION → CALM.** Use the regular `crossfade_seconds = 2.0`; the de-escalation can breathe.
- **Multiple enemies aware → one dies → none aware.** The state machine should hysteresis: only fall back to CALM after `no_enemies_aware AND no_recent_damage_for=8s`.
- **Audio service not ready on `_ready`.** If `AudioServer.get_bus_index("Music")` returns `-1`, the bus layout failed to load. Verify `default_bus_layout.tres`.
- **Stems generated at 60s but you want a 5-minute combat.** They loop. Crossfade is volume-only, not time-based, so loops are invisible to the director.

## Fallback (no MCP)

Print the bus layout, node tree, and GDScript. The user constructs it manually
in Summer Engine.

## Handoff

> MusicDirector wired. Calm / Tension / Combat play in sync, only one audible. State driven by `EventBus`. Music ducks `-6 dB` on dialogue. Next:
> - Run `summer_play` and trigger each transition; listen for sync issues.
> - If COMBAT lands on the wrong beat, the stems are off-tempo — regenerate via `/music-track` with identical BPM and bar count.
> - For boss-specific music, generate `boss.mp3` with `/music-track` and the director picks it up automatically.

## See also

- `audio-direction` — defines the dynamic music plan and bus layout
- `music-track` — generates the stems
- `voice-line` — the voice bus that triggers ducking
- `summer_docs_read` id `reference/godot-version` — Summer compatibility for AudioServer
  and Tween APIs

<!-- shell-commands: generated from the operation catalog by npm run generate:registry; do not edit by hand -->
## From a shell

No Summer MCP tools in this session? Run each one as a command; when `summer --version` is missing or below 4.0, use `npx -y summer-engine@latest` in place of `summer`. Fields are flags in kebab-case (`--scene-path res://main.tscn`) or one `--input '<json>'`, and `summer ops describe <op>` prints the exact input. The `summer-cli` skill has the rest.

| In this skill | Command |
|---|---|
| `summer_assets_generate_audio` | `summer assets generate-audio` |
| `summer_assets_search` | `summer assets search` |
| `summer_docs_read` | `summer docs read` |
| `summer_add_node` | `summer engine add-node` |
| `summer_connect_signal` | `summer engine connect-signal` |
| `summer_get_scene_tree` | `summer engine get-scene-tree` |
| `summer_import_from_url` | `summer engine import-from-url` |
| `summer_inspect_node` | `summer engine inspect-node` |
| `summer_play` | `summer engine play` |
| `summer_project_setting` | `summer engine project-setting` |
| `summer_run_script` | `summer engine run-script` |
| `summer_set_prop` | `summer engine set-prop` |
<!-- /shell-commands -->

Files in this skill

  • SKILL.md15.1 KB
  • resource.yaml650 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.