6afe928c0d
ФУНДАМЕНТ (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>
333 lines
11 KiB
JavaScript
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);
|