# Аудио-шины и эффекты — `$.audio`

`$.audio` дополняет существующий `$.sound` (не заменяет его) и повторяет
идею `AudioServer`/`AudioBusLayout` + `AudioEffect` из Godot 4: звук
маршрутизируется в именованную **шину**, у шины есть громкость, mute, solo,
родитель и эффект. Дерево по умолчанию: `master → sfx`, `master → music` —
любая шина, у которой не указан `parent`, вешается на `master`.

Физически движок не знает про шины: у него есть громкость, панорама и эффект
на **канале**. Слой шин живёт в JS поверх `engine.audio.*`:

* при запуске звука его канал сразу получает громкость шины;
* при смене громкости/mute/solo шины громкость **всех её живых каналов
  пересчитывается немедленно** (через `engine.audio.setChannelVolume`);
* эффект шины накладывается на каналы её живых звуков
  (`engine.audio.setChannelEffect`).

```js
$.ready(() => {
    $.audio.bus('music', { volume: 0.6 });
    $.audio.bus('ui', { parent: 'music', volume: 0.5 });

    // Шина 'music' — обычный маршрут для звуков через $.audio; отдельная
    // музыкальная дорожка движка ($.sound.music) живёт вне дерева шин.
    $.audio.play('assets/audio/music/action.ogg', { bus: 'music', loop: true, volume: 0.7 });

    // Обычный звук в шине ui: слышен с громкостью 0.5 × 0.6.
    const click = $.audio.play('assets/audio/sfx/pickup_01.ogg', { bus: 'ui' });

    // Позиционный звук — панорама и затухание от слушателя (по умолчанию камера).
    $.audio.playAt('assets/audio/sfx/hurt_01.ogg', 640, 300, { bus: 'sfx' });

    click.stop(150);                 // плавно погасить
    $.audio.mute('music', true);     // мгновенно заглушить всю ветку music
    $.audio.fadeBus('ui', 0, 800);   // плавно увести шину в тишину
});
```

---

## 1. Шины

| Метод | Назначение |
|---|---|
| `$.audio.bus(name, opts?)` | создать или получить шину; возвращает живой объект шины |
| `$.audio.buses()` | массив шин со состоянием (копии) |
| `$.audio.remove(name)` | удалить шину, детей переподчинить её родителю |
| `$.audio.clear()` | снять все шины, остановить их звуки, вернуть слушателя камере |

`opts` (все поля необязательны, при повторном вызове обновляют шину):

| Поле | Тип | По умолчанию | Смысл |
|---|---|---|---|
| `volume` | 0..1 | `1` | собственная громкость шины |
| `muted` | bool | `false` | заглушить шину и её потомков |
| `solo` | bool | `false` | оставить слышимыми только solo-ветку |
| `parent` | имя / `'master'` | `'master'` | родитель в дереве; цикл отклоняется |
| `effect` | `'none'`, `'lowpass'`, `'highpass'`, `'echo'`, `'tremolo'`, `'bitcrush'`, `'ringmod'`, `'reverb'` | `'none'` | эффект шины |
| `effectParams` | объект | `{}` | параметры эффекта (см. §3) |

`master` — не отдельная шина, а корень дерева и общая громкость движка.
`$.audio.bus('master')` вернёт `null` с подсказкой в журнале: мастер задаётся
через `$.audio.masterVolume()` / `$.audio.volume(0..1)`.

Создавать `sfx` заранее не нужно: `$.audio.play(...)` без `bus` сам заводит
шину `sfx` и маршрутизирует звук в неё. Шина, названная в `bus`/`playAt`,
тоже создаётся автоматически, если её ещё нет.

**Шины — настоящие группы микшера.** Каждая шина заводит группу SDL_mixer
(`MIX_CreateGroup`), каналы её звуков приписываются к группе
(`MIX_SetTrackGroup`), а `effect` шины накладывается на пост-микс группы
(`MIX_SetGroupPostMixCallback`). Поэтому эффект шины обрабатывает весь микс
шины целиком и действует в том числе на звуки, запущенные позже — раньше он
раскладывался по каналам в момент запуска.

Чего у группы нет: собственного гейна и вложенности — группы SDL_mixer 3.2
плоские. Поэтому `volume`, `mute` и `solo` по-прежнему считает JS и раздаёт
каналам через `engine.audio.setChannelVolume`. Личный эффект звука
(`handle.effect(...)`) живёт на канале и складывается с эффектом шины.
Если групп в сборке нет (звуковая заглушка), всё работает по-старому:
эффект шины раскладывается по каналам живых звуков.

---

## 2. Громкость, mute, solo

| Метод | Что делает |
|---|---|
| `$.audio.volume(name)` | эффективная собственная громкость шины |
| `$.audio.volume(name, v)` | задать собственную громкость шины (0..1) |
| `$.audio.volume(v)` | задать общую громкость (прокси к `$.sound.volume`) |
| `$.audio.mute(name)` / `$.audio.mute(name, bool)` | прочитать / задать mute |
| `$.audio.solo(name)` / `$.audio.solo(name, bool)` | прочитать / задать solo |
| `$.audio.gain(name)` | эффективная громкость с учётом родителей, mute и solo |
| `$.audio.masterVolume(v?)`, `$.audio.sfxVolume(v?)`, `$.audio.musicVolume(v?)` | прокси к `$.sound` без дублирования логики |

Эффективная громкость = произведение собственных громкостей по цепочке
родителей. Общая громкость (`master`) применяется движком ко всему миксу и в
громкость канала не входит, иначе она умножалась бы дважды.

**Mute** шины обнуляет её саму и всех потомков, но не трогает соседей.

**Solo** (если хотя бы одна шина помечена solo) оставляет слышимыми только:
саму solo-шину, её **потомков** (дочерние шины продолжают звучать) и её
**предков** (чтобы путь к мастеру не обрывался). Все остальные шины получают
эффективную громкость `0`. Так, при `music.solo = true` и дереве
`master → music → ui`, `master → ambient`: `ui` слышен, `ambient` — нет.

---

## 3. Эффекты

| Метод | Назначение |
|---|---|
| `$.audio.effect(name, kind, params?)` | задать эффект шины; без `kind` — прочитать текущий |
| `$.audio.effects()` | список доступных имён из `engine.audio.effectCount()/effectName()` |
| `handle.effect(kind, params?)` | личный эффект конкретного звука (перебивает шинный) |

Эффект шины применяется к каналам уже играющих звуков шины. Если у конкретного
handle вызван `.effect(...)`, смена эффекта шины его больше не задевает.

| `kind` | `params` | Смысл |
|---|---|---|
| `'none'` | — | без эффекта |
| `'lowpass'` | `{ freq }` или `{ cutoff }`, Гц (по умолчанию `1200`) | срез высоких частот: «глухой» звук за стеной, под водой |
| `'highpass'` | `{ freq }` или `{ cutoff }`, Гц (по умолчанию `200`) | убрать гул и низкий рокот: радио, телефон, «из-за двери» |
| `'echo'` | `{ delay }` мс (по умолчанию `250`), `{ feedback }` 0..0.9 (по умолчанию `0.35`) | эхо; доля повтора обрезается до 0.9 |
| `'tremolo'` | `{ rate }` Гц (по умолчанию `5`), `{ depth }` 0..1 (по умолчанию `0.5`) | качание громкости: вертолёт, сирена, больное сердце |
| `'bitcrush'` | `{ bits }` 1..16 (по умолчанию `6`), `{ downsample }` 1..64 (по умолчанию `1`) | «8-битный» звук: ретро, глитч, помехи в радиоэфире |
| `'ringmod'` | `{ freq }` Гц (по умолчанию `220`), `{ mix }` 0..1 (по умолчанию `1`) | кольцевая модуляция: металл, робот, помехи |
| `'reverb'` | `{ send }` 0..1 (по умолчанию `0.35`), `{ room }` 0..1 (по умолчанию `0.5`), `{ damp }`, `{ width }` | **реверб-шина с посылом**: доля `send` микса шины уходит в собственный хвост, остальные шины его не слышат |

```js
$.audio.bus('underwater', { parent: 'sfx' });
$.audio.effect('underwater', 'lowpass', { freq: 700 });
$.audio.effect('cave', 'echo', { delay: 320, feedback: 0.5 });
$.audio.effect('radio', 'highpass', { freq: 500 });
$.audio.effect('radio', 'bitcrush', { bits: 5, downsample: 2 });
$.audio.bus('hall', { effect: 'reverb', effectParams: { send: 0.5, room: 0.8, damp: 0.3 } });
console.log($.audio.effects());
// ['none', 'lowpass', 'highpass', 'echo', 'tremolo', 'bitcrush', 'ringmod', 'reverb']
```

Чтобы применить эффект к конкретному звуку, есть `handle.effect(kind, params)` —
он живёт на канале и перебивает шинный.

**Реверб-шина и комната — разные вещи.** Комната (§8) считается по зоне
слушателя и звучит для всего микса сразу: это «где я нахожусь». Реверб-шина —
это конкретная шина со своим хвостом: посыл `send` уходит в него, а мастер и
другие шины остаются сухими. Так делают «тоннель», «церковь» или отдельный
хвост для голоса.

```js
$.audio.bus('tunnel', { parent: 'sfx', effect: 'reverb',
                        effectParams: { send: 0.6, room: 0.9, damp: 0.5 } });
```

**Честные ограничения эффектов:**

* эффекты — это PostMix-колбэк трека или группы: они обрабатывают буфер
  целиком, поэтому порядок «сначала ФНЧ, потом эхо» на одной шине не задать —
  включён ровно один эффект на шину (или на канал);
* реверб-шина держит свой хвост: на 48 кГц это ~200 КБ на включённую шину,
  поэтому буферы выделяются лениво — при первом включении эффекта;
* эффект шины живёт на её группе микшера, а личный эффект звука — на канале;
  звуки, запущенные напрямую через `$.sound.play` (мимо `$.audio`), в шину не
  попадают и эффекта шины не получат;
* смена эффекта не перезапускает звук — слышно на уже играющих каналах;
* `lowpass`/`echo` могут быть недоступны в конкретной сборке микшера: тогда
  `engine.audio.setChannelEffect` вернёт `false`, `$.audio` молча продолжит
  работу.

---

## 4. Запуск звуков и handle

| Метод | Назначение |
|---|---|
| `$.audio.play(pathOrId, opts)` | обёртка над `$.sound.play` с маршрутизацией в шину |
| `$.audio.playAt(pathOrId, x, y, opts)` | позиционный звук от слушателя/камеры |
| `$.audio.listener(x, y)` / `$.audio.listener()` | задать / прочитать слушателя (по умолчанию — камера) |
| `$.audio.handles()` | массив активных handle'ов |
| `$.audio.stopBus(name, fadeMs)` | остановить звуки шины и её потомков |
| `$.audio.stopAll(fadeMs)` | остановить всё (прокси к `$.sound.stopAll`) |

`opts`: `{ bus, volume, pan, loop, at: [x,y], falloff }`.

* `bus` — имя шины (по умолчанию `sfx`);
* `volume` — 0..1, умножается на эффективную громкость шины;
* `pan` — -1..1, перебивается расчётом при `at`;
* `loop` — зацикливать ли звук;
* `at` — `[x, y]` (или `{x,y}`) в мировых координатах: включает позиционный
  расчёт, как у `playAt`;
* `falloff` — число (радиус слышимости, px) или `{ max }` (по умолчанию `700`).

`$.audio.play(...)` возвращает **handle**:

```js
const h = $.audio.play('assets/audio/sfx/hurt_01.ogg', { bus: 'sfx', volume: 0.8 });

h.channel;          // номер канала движка или -1, если звук не поднялся
h.bus;              // имя шины
h.path;             // путь/ид звука
h.stop(fadeMs);     // остановить (fadeMs > 0 — плавно)
h.volume();         // текущая личная громкость
h.volume(0.3);      // задать; канал пересчитается сразу
h.pan(); h.pan(p);  // панорама
h.effect(kind, params?);   // личный эффект поверх шинного
h.playing();        // играет ли канал сейчас
```

Handle'ы автоматически чистятся: после `h.stop(...)`, а также когда движок сам
освободил канал (звук доиграл), запись исчезает из `$.audio.handles()` в
ближайшем `tickAudiobus`. Поэтому долгоживущая игра не накапливает «мёртвые»
handle'ы.

### Позиционное звучание

`playAt` (и `play` с `opts.at`) считает панораму и затухание относительно
слушателя:

* расстояние `dist` от слушателя до источника;
* `gain = 1 - dist / max` (0 за границей слышимости);
* `pan = clamp(dx / (max/2), -1, 1)` — источник справа звучит в правом ухе.

```js
$.audio.listener($('#hero').pos().x, $('#hero').pos().y);   // ручной слушатель
$.audio.playAt('boom.ogg', 900, 200, { bus: 'sfx', falloff: { max: 900 } });
```

Без вызова `$.audio.listener(...)` слушателем считается центр камеры, то есть
поведение совпадает с `$.sound.playAt`.

### Режим позиционирования: `$.audio.spatial(mode)`

| Режим | Кто считает затухание и панораму | Особенности |
|---|---|---|
| `'js'` (по умолчанию) | JS: `panAndGain` из этого модуля | звук остаётся стерео, работает `pan` вручную |
| `'sdl'` | SDL_mixer: `MIX_SetTrack3DPosition` | затухание и раскладка по колонкам от движка; трек микшируется в моно |

У SDL_mixer слушатель **всегда** в `(0,0,0)` и его нельзя двигать, поэтому в
режиме `'sdl'` передаются координаты относительно слушателя: мир `(dx, dy)`
отображается как `(x = dx, y = 0, z = dy)`. Окклюзия (`$.audio.occlusion`)
продолжает работать, а ручные панорама и затухание в этом режиме отключаются —
иначе звук ослаблялся бы дважды.

```js
$.audio.spatial('sdl');      // позиционирование отдаём SDL_mixer
$.audio.spatial();           // → 'sdl'
$.audio.spatial('js');       // обратно на панораму в JS
```

---

## 5. Затухания (fade)

| Метод | Назначение |
|---|---|
| `$.audio.fadeBus(name, value, ms)` | плавно перевести громкость шины к `value` за `ms` |
| `$.audio.fadeHandle(handle, value, ms)` | плавно перевести личную громкость звука |

Затухания ведутся существующим движком твинов (`tweenProps`) и применяются в
`tickAudiobus(dt)` — кадр не блокируется. `ms = 0` применяет значение сразу.
Повторный fade по той же цели заменяет предыдущий, чтобы два затухания не
спорили за одну громкость.

---

## 6. Деградация без звука

Если движок собран с `R2D_ENABLE_AUDIO=OFF` (или звуковое устройство
недоступно), `engine.audio.play` вернёт `-1`. Тогда:

* `$.audio.play` / `playAt` не бросают исключение, а возвращают handle с
  `channel === -1`;
* `h.playing()` возвращает `false`, `h.stop(...)` ничего не делает;
* шины, громкость, mute, solo и эффекты продолжают работать как чистая логика
  (их состояние видно через `$.audio.buses()` и `$.audio.gain(name)`).

---

## 7. Чистые функции

Экспортируются из `src/highlevel/audiobus.js` и тестируются qjs без движка:

```js
import { effectiveGain, panAndGain } from '../../src/highlevel/audiobus.js';

effectiveGain(buses, 'ui');      // громкость с родителями, mute и solo
panAndGain(listener, source, { max: 700 });  // { pan, gain, dist }
```

`buses` — `Map` или словарь `{ имя: { volume, muted, solo, parent } }`.

---

## 8. Комната и зоны акустики

Реверберация помещения — не эффект шины, а свойство места: её слышно для всего
микса сразу. Поэтому сам DSP живёт в C (Freeverb, `src/audio_reverb.c`, public
domain) и висит на последнем шаге микшера, а высокоуровневая модель — в
`src/highlevel/acoustics.js`.

| Метод | Назначение |
|---|---|
| `$.audio.room()` | текущие параметры комнаты `{ wet, room, damp, width }` |
| `$.audio.room({...})` | задать вручную и выключить авто-режим (`auto: false`) |
| `$.audio.zone(name, { rect, height, material, wet, smooth })` | завести/переопределить зону |
| `$.audio.obstacles(list)` | препятствия для звука (деревья, колонны): список точек/узлов/селекторов |
| `$.audio.damping(opts)` | настройка глушения: `radius`, `strength`, `max`, `cutoff_clear`, `cutoff_dense` |
| `$.audio.densityAt(x, y, r)` | сколько препятствий вокруг точки (для HUD и отладки) |
| `$.audio.zone()` / `$.audio.removeZone(name)` | список зон / удалить |
| `$.audio.acoustics(flag)` | авто-режим: комната считается по зоне слушателя |
| `$.audio.occlusion(flag)` | глушить ли источники за стенами |
| `$.audio.acousticsState()` | снимок: активная зона, слушатель, параметры |

Что происходит в кадре при включённом авто-режиме:

1. берётся точка слушателя (`$.audio.listener()` — точка, узел или селектор;
   без него — центр камеры);
2. ищется зона, накрывающая эту точку (позже добавленные важнее);
3. по `rect` и высоте потолка считаются объём `V` и площадь `S`; в 2D-мире
   потолка нет, поэтому `height` задаётся явно (по умолчанию 3 м);
4. по формуле Сабина `RT60 = 0.161·V / (S·α)` получается время реверберации,
   где `α` — поглощение материала;
5. `RT60` превращается в `room` (длина хвоста) и `wet` (доля), `damp` берётся из
   материала. Параметры едут к цели за `smooth` секунд (по умолчанию 0.25) —
   иначе на границе зон слышен щелчок.

Материалы: `concrete`, `tile`, `metal`, `glass`, `wood`, `carpet`, `curtain`.
Чистая математика (`roomVolume`, `rt60`, `reverbForZone`, `zoneAt`) вынесена из
модуля и тестируется qjs — см. `tests/js/acoustics_test.mjs`.

```js
$.audio.zone('hall',   { rect: [0, 0, 640, 640], height: 6,   material: 'concrete' });
$.audio.zone('closet', { rect: [700, 0, 160, 160], height: 2.4, material: 'tile' });

$.audio.listener('#hero');            // слушатель едет за игроком
$.sound.playAt('shot.ogg', '#hero');  // хвост зависит от комнаты
```

**Окклюзия.** Если она включена и между слушателем и источником нет прямой
видимости (`$.world.lineOfSight`), канал глушится фильтром 700 Гц и слегка
придавливается по громкости. Реверберация комнаты при этом остаётся — так это и
слышится: звук из-за стены глухой, но помещение угадывается.

`$.sound.playAt` запоминает источник (канал и узел) и дальше сам ведёт
панораму и громкость, пока звук играет.

---

### 8.1. Чаща и открытое поле

Плотный лес глушит звук иначе, чем поляна: высокие частоты вязнут в листве.
Это считается честно — по числу препятствий, лежащих рядом с линией
«слушатель → источник»:

```js
$.audio.obstacles(trees.map((t) => ({ x: t.x, y: t.y })));   // деревья леса
$.audio.damping({ radius: 34, strength: 0.2, max: 0.85,
                  cutoff_clear: 18000, cutoff_dense: 620 });
```

Каждое препятствие в пределах `radius` от луча добавляет `strength` глухости
(не больше `max`). По глухости выбирается срез фильтра между `cutoff_clear`
и `cutoff_dense` и теряется до половины громкости. Препятствия работают
вместе с окклюзией по `lineOfSight`: стена глушит сильнее и в первую очередь,
чаща — мягко и всегда.

`$.audio.densityAt(x, y, r)` нужен игре, чтобы показать игроку, где он: в
демо `shooter_witch` в HUD видно «деревьев рядом N · глухо X%».

## 9. Ограничения

* Реверберация — общая на микс: отдельной реверб-шины с посылом из конкретных
  звуков нет (в Godot это `AudioEffectReverb` на шине).
* Шины плоские: группы SDL_mixer 3.2 не вкладываются друг в друга и не имеют
  своего гейна — дерево и громкость считает JS, движку достаётся только DSP.
* В режиме `$.audio.spatial('sdl')` трек микшируется в моно, а громкость
  канала остаётся за JS: относительное затухание считает SDL.
* Слоёв и масок коллизий для звука нет, окклюзия считается одним лучом.
* Нет поканального «отправления» в несколько шин: один звук живёт в одной шине.
* Эффект и громкость применяются к каналу, поэтому уже доигранные или
  остановленные каналы пересчитывать нечего; «мёртвые» handle'ы вычищаются.
* Микрофон/захват, задержки на шине и sidechain-сжатие не поддерживаются.
* `$.sound` продолжает работать как раньше; звуки, запущенные им напрямую, не
  маршрутизируются в шины и не видны в `$.audio.handles()` (но позиционные —
  `$.sound.playAt` — попадают в модель акустики).
* Музыкальная дорожка движка (`$.sound.music`) — отдельный трек микшера, она
  не входит в дерево шин; для неё есть только `$.audio.musicVolume(v)`,
  `$.sound.musicPitch(v)` и `{ pitch }` в `$.sound.music(...)`.

## 10. Почему штатный SDL_mixer, а не SoLoud

Перенос звука на SoLoud рассматривался (2026-10-05): спайк собрался и работал.
Но всё, за чем туда шли, нашлось в пришпиленном SDL_mixer 3.2.4: pitch
(`MIX_SetTrackFrequencyRatio`), шины (`MIX_CreateGroup` + пост-микс группы),
позиция (`MIX_SetTrack3DPosition`), DSP на дорожке. Поэтому из SoLoud взят
только алгоритм реверберации (Freeverb, `src/audio_reverb.c`), а публичный API
остался один — `$.sound` и `$.audio`; возможности бэкенда проверяются через
`$.audio.supports(...)`, а не отдельным пространством имён. SoLoud — запасной
вариант, если понадобится граф шин с send/return или свёртка с импульсными
характеристиками.
