Files
Learn_System/frontend/js/labs/_fx_sound.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

333 lines
11 KiB
JavaScript

'use strict';
(function(global) {
global.LabFX = global.LabFX || {};
/* ── AudioContext lazy init ── */
var _ctx = null;
var _masterGain = null;
var _enabled = (function() {
var stored = localStorage.getItem('labfx-sound');
return stored === null ? true : stored === 'true';
})();
function getCtx() {
if (!_ctx) {
_ctx = new (window.AudioContext || window.webkitAudioContext)();
_masterGain = _ctx.createGain();
_masterGain.gain.value = _enabled ? 1 : 0;
_masterGain.connect(_ctx.destination);
}
if (_ctx.state === 'suspended') {
_ctx.resume();
}
return _ctx;
}
function master() {
getCtx();
return _masterGain;
}
/* ── helper: connect chain to master ── */
function chain(nodes) {
/* nodes: array of AudioNode — each is connected to the next, last → master */
for (var i = 0; i < nodes.length - 1; i++) {
nodes[i].connect(nodes[i + 1]);
}
nodes[nodes.length - 1].connect(master());
}
/* ── helper: create gain ── */
function mkGain(value) {
var g = getCtx().createGain();
g.gain.value = value;
return g;
}
/* ── helper: create oscillator ── */
function mkOsc(type, freq) {
var o = getCtx().createOscillator();
o.type = type;
o.frequency.value = freq;
return o;
}
/* ── helper: create biquad ── */
function mkFilter(type, freq, Q) {
var f = getCtx().createBiquadFilter();
f.type = type;
f.frequency.value = freq;
if (Q != null) f.Q.value = Q;
return f;
}
/* ── helper: noise buffer (white) ── */
function makeNoise(color) {
/* color: 'white' | 'pink' | 'brown' */
var ac = getCtx();
var length = ac.sampleRate * 2;
var buffer = ac.createBuffer(1, length, ac.sampleRate);
var data = buffer.getChannelData(0);
var b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
for (var i = 0; i < length; i++) {
var white = Math.random() * 2 - 1;
if (color === 'pink') {
b0 = 0.99886 * b0 + white * 0.0555179;
b1 = 0.99332 * b1 + white * 0.0750759;
b2 = 0.96900 * b2 + white * 0.1538520;
b3 = 0.86650 * b3 + white * 0.3104856;
b4 = 0.55000 * b4 + white * 0.5329522;
b5 = -0.7616 * b5 - white * 0.0168980;
data[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362) * 0.11;
b6 = white * 0.115926;
} else if (color === 'brown') {
b0 = (b0 + 0.02 * white) / 1.02;
data[i] = b0 * 3.5;
} else {
data[i] = white;
}
}
return buffer;
}
/* ── noise source ── */
function noiseSource(color) {
var ac = getCtx();
var src = ac.createBufferSource();
src.buffer = makeNoise(color || 'white');
src.loop = true;
return src;
}
/* ─────────────────────────────────────────────
SYNTH RECIPES
───────────────────────────────────────────── */
var recipes = {
click: function(opts) {
/* short filtered noise burst, 30ms */
var ac = getCtx();
var now = ac.currentTime;
var vol = (opts.volume || 0.5) * 0.6;
var src = noiseSource('white');
var flt = mkFilter('bandpass', 1200 * (opts.pitch || 1), 8);
var g = mkGain(vol);
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.030);
chain([src, flt, g]);
src.start(now);
src.stop(now + 0.035);
},
tick: function(opts) {
/* Geiger tick: brown noise + very short envelope, 15ms */
var ac = getCtx();
var now = ac.currentTime;
var vol = (opts.volume || 0.5) * 0.4;
var src = noiseSource('brown');
var flt = mkFilter('highpass', 800, 1);
var g = mkGain(vol);
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.015);
chain([src, flt, g]);
src.start(now);
src.stop(now + 0.02);
},
whoosh: function(opts) {
/* descending swoosh: filtered noise sweep 800→200 Hz, 250ms */
var ac = getCtx();
var now = ac.currentTime;
var vol = (opts.volume || 0.5) * 0.7;
var src = noiseSource('pink');
var flt = mkFilter('bandpass', 800 * (opts.pitch || 1), 3);
var g = mkGain(vol);
flt.frequency.setValueAtTime(800 * (opts.pitch || 1), now);
flt.frequency.exponentialRampToValueAtTime(200 * (opts.pitch || 1), now + 0.25);
g.gain.setValueAtTime(0.0001, now);
g.gain.linearRampToValueAtTime(vol, now + 0.03);
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25);
chain([src, flt, g]);
src.start(now);
src.stop(now + 0.28);
},
chime: function(opts) {
/* bright bell: additive sines 880+1320+1760 Hz, exp decay, 600ms */
var ac = getCtx();
var now = ac.currentTime;
var vol = (opts.volume || 0.5);
var pitch = opts.pitch || 1;
var freqs = [880, 1320, 1760];
var pan = opts.pan || 0;
freqs.forEach(function(f, idx) {
var osc = mkOsc('sine', f * pitch);
var g = mkGain(vol * (0.5 / (idx + 1)));
var panner = ac.createStereoPanner ? ac.createStereoPanner() : null;
g.gain.setValueAtTime(vol * (0.5 / (idx + 1)), now);
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.6);
if (panner) {
panner.pan.value = pan;
chain([osc, g, panner]);
} else {
chain([osc, g]);
}
osc.start(now);
osc.stop(now + 0.65);
});
},
fizz: function(opts) {
/* pink noise, 300ms, low-pass sweep */
var ac = getCtx();
var now = ac.currentTime;
var vol = (opts.volume || 0.5) * 0.6;
var src = noiseSource('pink');
var flt = mkFilter('lowpass', 3000 * (opts.pitch || 1), 1);
var g = mkGain(vol);
flt.frequency.setValueAtTime(3000 * (opts.pitch || 1), now);
flt.frequency.exponentialRampToValueAtTime(400 * (opts.pitch || 1), now + 0.3);
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.3);
chain([src, flt, g]);
src.start(now);
src.stop(now + 0.35);
},
spark: function(opts) {
/* white noise + triangle 2000Hz, 50ms */
var ac = getCtx();
var now = ac.currentTime;
var vol = (opts.volume || 0.5) * 0.7;
var src = noiseSource('white');
var osc = mkOsc('triangle', 2000 * (opts.pitch || 1));
var gn = mkGain(vol * 0.5);
var go = mkGain(vol * 0.5);
var mix = mkGain(1);
gn.gain.setValueAtTime(vol * 0.5, now);
gn.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
go.gain.setValueAtTime(vol * 0.5, now);
go.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
src.connect(gn); gn.connect(mix);
osc.connect(go); go.connect(mix);
mix.connect(master());
src.start(now); osc.start(now);
src.stop(now + 0.06); osc.stop(now + 0.06);
},
bounce: function(opts) {
/* sine sweep 600→300 Hz, 100ms */
var ac = getCtx();
var now = ac.currentTime;
var vol = (opts.volume || 0.5) * 0.6;
var osc = mkOsc('sine', 600 * (opts.pitch || 1));
var g = mkGain(vol);
osc.frequency.setValueAtTime(600 * (opts.pitch || 1), now);
osc.frequency.exponentialRampToValueAtTime(300 * (opts.pitch || 1), now + 0.1);
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.1);
chain([osc, g]);
osc.start(now);
osc.stop(now + 0.12);
},
pour: function(opts) {
/* filtered noise with random pitch wobble, 200ms+ */
var ac = getCtx();
var now = ac.currentTime;
var vol = (opts.volume || 0.5) * 0.5;
var src = noiseSource('pink');
var flt = mkFilter('bandpass', 400 * (opts.pitch || 1), 2);
var g = mkGain(vol);
/* wobble frequency */
var steps = 8;
for (var i = 0; i <= steps; i++) {
var t = now + (i / steps) * 0.2;
var freq = (350 + Math.random() * 150) * (opts.pitch || 1);
flt.frequency.setValueAtTime(freq, t);
}
g.gain.setValueAtTime(0.0001, now);
g.gain.linearRampToValueAtTime(vol, now + 0.04);
g.gain.setValueAtTime(vol, now + 0.18);
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
chain([src, flt, g]);
src.start(now);
src.stop(now + 0.25);
}
};
/* ─────────────────────────────────────────────
DRONE — sustained sound (separate lifecycle)
───────────────────────────────────────────── */
var _droneNodes = {};
var droneRecipes = {
drone: function(ac, vol) {
var sine = mkOsc('sine', 80);
var saw = mkOsc('sawtooth', 160);
var gs = mkGain(vol * 0.5);
var gw = mkGain(vol * 0.25);
var mix = mkGain(0.05);
sine.connect(gs); gs.connect(mix);
saw.connect(gw); gw.connect(mix);
mix.connect(master());
sine.start();
saw.start();
return { nodes: [sine, saw, gs, gw, mix] };
}
};
/* ─────────────────────────────────────────────
PUBLIC API
───────────────────────────────────────────── */
global.LabFX.sound = {
play: function(name, opts) {
if (!_enabled) return;
opts = opts || {};
getCtx();
var recipe = recipes[name];
if (recipe) {
try { recipe(opts); } catch(e) { /* silent */ }
}
},
startDrone: function(name) {
if (_droneNodes[name]) return _droneNodes[name]; /* already running */
var recipe = droneRecipes[name];
if (!recipe) return null;
var ac = getCtx();
var vol = 0.5;
var handle = recipe(ac, vol);
_droneNodes[name] = handle;
handle.stop = function() {
handle.nodes.forEach(function(n) {
try { n.stop(); } catch(e) {}
try { n.disconnect(); } catch(e) {}
});
delete _droneNodes[name];
};
return handle;
},
setEnabled: function(bool) {
_enabled = !!bool;
localStorage.setItem('labfx-sound', _enabled ? 'true' : 'false');
if (_masterGain) {
_masterGain.gain.cancelScheduledValues(0);
_masterGain.gain.value = _enabled ? 1 : 0;
}
},
isEnabled: function() {
return _enabled;
}
};
})(window);