studio/src/editor/engine/AudioManager.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

386 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AudioManager — фоновый звук сцены через Web Audio API.
*
* Все звуки генерируются ПРОЦЕДУРНО — никаких mp3-файлов не нужно.
*
* Каналы:
* ambient — длительный фон (ветер/лес/вода/пещера) через шум + фильтры
* music — короткие мелодии-петли через oscillator (минорный/мажорный лад)
*
* Воспроизводится только в Play-режиме.
*/
export const AMBIENT_PRESETS = [
{ id: 'none', name: 'Без звука' },
{ id: 'forest', name: 'Лес' },
{ id: 'wind', name: 'Ветер' },
{ id: 'water', name: 'Вода' },
{ id: 'cave', name: 'Пещера' },
];
export const MUSIC_PRESETS = [
{ id: 'none', name: 'Без музыки' },
{ id: 'calm', name: 'Спокойная' },
{ id: 'happy', name: 'Весёлая' },
{ id: 'epic', name: 'Эпическая' },
{ id: 'mystery', name: 'Загадочная' },
];
// Ноты в Hz (4-я октава для калькуляций)
const NOTES = {
C: 261.63, 'C#': 277.18, D: 293.66, 'D#': 311.13,
E: 329.63, F: 349.23, 'F#': 369.99, G: 392.00,
'G#': 415.30, A: 440.00, 'A#': 466.16, B: 493.88,
};
function n(note, octave = 4) {
return NOTES[note] * Math.pow(2, octave - 4);
}
// Простые мелодии — массив [нота, октава, длительность в "тиках" по 0.25с]
const MELODIES = {
calm: {
bpm: 60,
notes: [
['C',5,2],['E',5,2],['G',5,2],['E',5,2],
['F',5,2],['A',5,2],['G',5,4],
['D',5,2],['F',5,2],['A',5,2],['F',5,2],
['E',5,2],['G',5,2],['C',5,4],
],
},
happy: {
bpm: 120,
notes: [
['C',5,1],['E',5,1],['G',5,1],['C',6,1],
['G',5,1],['E',5,1],['C',5,2],
['F',5,1],['A',5,1],['C',6,1],['F',6,1],
['C',6,1],['A',5,1],['F',5,2],
],
},
epic: {
bpm: 80,
notes: [
['A',3,2],['E',4,2],['A',4,2],['C',5,2],
['B',4,2],['G',4,2],['E',4,4],
['F',4,2],['A',4,2],['C',5,2],['E',5,2],
['D',5,2],['B',4,2],['A',4,4],
],
},
mystery: {
bpm: 70,
notes: [
['A',4,3],['C',5,1],['E',5,2],['F',5,2],
['E',5,2],['D',5,2],['C',5,4],
['G',4,3],['B',4,1],['D',5,2],['F',5,2],
['E',5,2],['C',5,2],['A',4,4],
],
},
};
export class AudioManager {
constructor() {
this.ambientId = 'none';
this.ambientVolume = 0.3;
this.musicId = 'none';
this.musicVolume = 0.25;
this._ctx = null;
this._ambientNodes = [];
this._musicTimer = null;
this._musicNodes = [];
this._playing = false;
}
setAmbient({ preset, volume } = {}) {
if (preset !== undefined) this.ambientId = preset;
if (volume !== undefined) this.ambientVolume = Math.max(0, Math.min(1, volume));
if (this._playing) this._restartAmbient();
}
setMusic({ preset, volume } = {}) {
if (preset !== undefined) this.musicId = preset;
if (volume !== undefined) this.musicVolume = Math.max(0, Math.min(1, volume));
if (this._playing) this._restartMusic();
}
start() {
this._ensureContext();
if (!this._ctx) return;
// Браузеры требуют user-gesture перед воспроизведением.
// Кнопка Play даёт его — resume() работает.
if (this._ctx.state === 'suspended') {
this._ctx.resume().catch(() => {});
}
this._playing = true;
this._restartAmbient();
this._restartMusic();
}
stop() {
this._playing = false;
this._stopAmbient();
this._stopMusic();
}
_ensureContext() {
if (this._ctx) return;
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
this._ctx = new Ctx();
} catch (e) { /* ignore */ }
}
// ============ AMBIENT ============
_restartAmbient() {
this._stopAmbient();
if (this.ambientId === 'none' || !this._ctx) return;
const ctx = this._ctx;
const dest = ctx.destination;
// Главный gain — общая громкость канала
const masterGain = ctx.createGain();
masterGain.gain.value = this.ambientVolume * 0.5;
masterGain.connect(dest);
if (this.ambientId === 'wind') {
// Розовый шум через белый шум + фильтр + медленная LFO для амплитуды
const noise = this._createWhiteNoise();
const filt = ctx.createBiquadFilter();
filt.type = 'lowpass';
filt.frequency.value = 800;
filt.Q.value = 0.8;
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfo.frequency.value = 0.15;
lfoGain.gain.value = 0.3;
lfo.connect(lfoGain);
const winGain = ctx.createGain();
winGain.gain.value = 0.5;
lfoGain.connect(winGain.gain);
noise.connect(filt);
filt.connect(winGain);
winGain.connect(masterGain);
noise.start();
lfo.start();
this._ambientNodes.push(noise, lfo, filt, lfoGain, winGain);
} else if (this.ambientId === 'forest') {
// Шум листьев + случайные «птицы» (короткие чирикания)
const noise = this._createWhiteNoise();
const filt = ctx.createBiquadFilter();
filt.type = 'highpass';
filt.frequency.value = 1500;
const ng = ctx.createGain();
ng.gain.value = 0.15;
noise.connect(filt);
filt.connect(ng);
ng.connect(masterGain);
noise.start();
this._ambientNodes.push(noise, filt, ng);
// Птицы каждые 3-7 секунд
this._scheduleBirds(masterGain);
} else if (this.ambientId === 'water') {
// Журчание — фильтрованный шум с медленным сдвигом частоты
const noise = this._createWhiteNoise();
const filt = ctx.createBiquadFilter();
filt.type = 'bandpass';
filt.frequency.value = 1200;
filt.Q.value = 1.2;
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfo.frequency.value = 0.5;
lfoGain.gain.value = 400;
lfo.connect(lfoGain);
lfoGain.connect(filt.frequency);
const wg = ctx.createGain();
wg.gain.value = 0.6;
noise.connect(filt);
filt.connect(wg);
wg.connect(masterGain);
noise.start();
lfo.start();
this._ambientNodes.push(noise, lfo, filt, lfoGain, wg);
} else if (this.ambientId === 'cave') {
// Низкий гул + редкие «капли»
const osc1 = ctx.createOscillator();
osc1.type = 'sine';
osc1.frequency.value = 60;
const osc2 = ctx.createOscillator();
osc2.type = 'sine';
osc2.frequency.value = 90;
const g = ctx.createGain();
g.gain.value = 0.4;
osc1.connect(g);
osc2.connect(g);
g.connect(masterGain);
osc1.start();
osc2.start();
this._ambientNodes.push(osc1, osc2, g);
this._scheduleDrips(masterGain);
}
this._ambientMaster = masterGain;
}
_stopAmbient() {
for (const n of this._ambientNodes) {
try { n.stop?.(); } catch (e) {}
try { n.disconnect?.(); } catch (e) {}
}
this._ambientNodes = [];
if (this._birdsTimer) { clearTimeout(this._birdsTimer); this._birdsTimer = null; }
if (this._dripsTimer) { clearTimeout(this._dripsTimer); this._dripsTimer = null; }
if (this._ambientMaster) { try { this._ambientMaster.disconnect(); } catch (e) {} this._ambientMaster = null; }
}
_createWhiteNoise() {
const ctx = this._ctx;
const bufSize = 2 * ctx.sampleRate;
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource();
src.buffer = buf;
src.loop = true;
return src;
}
_scheduleBirds(dest) {
if (!this._playing) return;
const ctx = this._ctx;
const delay = 2 + Math.random() * 5;
this._birdsTimer = setTimeout(() => {
if (!this._playing) return;
const t = ctx.currentTime;
const baseFreq = 1500 + Math.random() * 1500;
for (let i = 0; i < 3; i++) {
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(baseFreq * (1 + i * 0.1), t + i * 0.08);
const g = ctx.createGain();
g.gain.setValueAtTime(0, t + i * 0.08);
g.gain.linearRampToValueAtTime(0.15, t + i * 0.08 + 0.02);
g.gain.linearRampToValueAtTime(0, t + i * 0.08 + 0.1);
osc.connect(g);
g.connect(dest);
osc.start(t + i * 0.08);
osc.stop(t + i * 0.08 + 0.12);
}
this._scheduleBirds(dest);
}, delay * 1000);
}
_scheduleDrips(dest) {
if (!this._playing) return;
const ctx = this._ctx;
const delay = 1.5 + Math.random() * 4;
this._dripsTimer = setTimeout(() => {
if (!this._playing) return;
const t = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(800 + Math.random() * 400, t);
osc.frequency.exponentialRampToValueAtTime(200, t + 0.3);
const g = ctx.createGain();
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(0.2, t + 0.01);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
osc.connect(g);
g.connect(dest);
osc.start(t);
osc.stop(t + 0.5);
this._scheduleDrips(dest);
}, delay * 1000);
}
// ============ MUSIC ============
_restartMusic() {
this._stopMusic();
if (this.musicId === 'none' || !MELODIES[this.musicId] || !this._ctx) return;
const ctx = this._ctx;
const masterGain = ctx.createGain();
masterGain.gain.value = this.musicVolume * 0.4;
masterGain.connect(ctx.destination);
this._musicMaster = masterGain;
this._playMelodyLoop();
}
_stopMusic() {
if (this._musicTimer) { clearTimeout(this._musicTimer); this._musicTimer = null; }
for (const n of this._musicNodes) {
try { n.stop?.(); } catch (e) {}
try { n.disconnect?.(); } catch (e) {}
}
this._musicNodes = [];
if (this._musicMaster) { try { this._musicMaster.disconnect(); } catch (e) {} this._musicMaster = null; }
}
_playMelodyLoop() {
if (!this._playing) return;
const melody = MELODIES[this.musicId];
if (!melody) return;
const ctx = this._ctx;
const tickDur = 60 / melody.bpm / 4; // 16-я нота
let t = ctx.currentTime + 0.05;
let totalTime = 0;
for (const [note, octave, ticks] of melody.notes) {
const dur = ticks * tickDur;
const freq = n(note, octave);
this._scheduleNote(freq, t, dur * 0.95);
t += dur;
totalTime += dur;
}
// Перезапускаем мелодию по окончании
this._musicTimer = setTimeout(() => this._playMelodyLoop(), (totalTime + 0.5) * 1000);
}
_scheduleNote(freq, startTime, duration) {
const ctx = this._ctx;
const osc = ctx.createOscillator();
osc.type = 'triangle'; // мягкий тон
osc.frequency.value = freq;
const g = ctx.createGain();
// ADSR-огибающая
g.gain.setValueAtTime(0, startTime);
g.gain.linearRampToValueAtTime(0.4, startTime + 0.02);
g.gain.linearRampToValueAtTime(0.2, startTime + 0.1);
g.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.connect(g);
g.connect(this._musicMaster);
osc.start(startTime);
osc.stop(startTime + duration + 0.05);
this._musicNodes.push(osc, g);
// Чистим старые ноды чтобы не накапливались
if (this._musicNodes.length > 200) {
this._musicNodes.splice(0, 100);
}
}
// ============ STATE ============
serialize() {
return {
ambientId: this.ambientId,
ambientVolume: this.ambientVolume,
musicId: this.musicId,
musicVolume: this.musicVolume,
};
}
load(data) {
if (!data) return;
if (typeof data.ambientId === 'string') this.ambientId = data.ambientId;
if (typeof data.ambientVolume === 'number') this.ambientVolume = data.ambientVolume;
if (typeof data.musicId === 'string') this.musicId = data.musicId;
if (typeof data.musicVolume === 'number') this.musicVolume = data.musicVolume;
}
dispose() {
this.stop();
if (this._ctx) {
try { this._ctx.close(); } catch (e) {}
this._ctx = null;
}
}
}