Интерактивный веб-проект — лендинг с визуализацией процесса преобразования звука в электрический сигнал на примере микрофона.
Задача: Нужен интерактив, который не выглядит как тест, а несёт повествование по теме. Желательно с голосовым сопровождением.
Решение: Сделал базовый каркас — окно в стиле Windows 95, экран с CRT-развёрткой, диалоговая панель. Сценарий разбит на 7 глав: пролог, звук, диафрагма, индукция, закон Фарадея, сигнал, лаборатория. Озвучка через Web Speech API с настраиваемыми pitch/rate.
Задача: Добавить вариативность — чтобы можно было использовать и реальный микрофон, и имитацию сигнала.
Решение: Сделал три режима в финале: «Генератор» (синтез сигнала заданной формы), «Микрофон» (реальный поток через getUserMedia), «Текст» (озвучка введённой фразы с визуализацией волны). Обработал случай file:// — браузеры блокируют микрофон, приложение само переключается в доступный режим.
async function enableMic(){
if(isFileProtocol){
alert('Микрофон недоступен со страницы-файла. Используйте localhost или https.');
document.querySelector('.lab-tab[data-mode="imitation"]').click();
return;
}
try{
micStream = await navigator.mediaDevices.getUserMedia({ audio:true });
audioCtx = audioCtx || new (window.AudioContext||window.webkitAudioContext)();
if(audioCtx.state === 'suspended') await audioCtx.resume();
const src = audioCtx.createMediaStreamSource(micStream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = .55;
src.connect(analyser);
timeData = new Uint8Array(analyser.fftSize);
freqData = new Uint8Array(analyser.frequencyBinCount);
micOn = true;
}catch(e){
alert('Микрофон недоступен: ' + e.message);
document.querySelector('.lab-tab[data-mode="imitation"]').click();
}
}
Задача: Озвучка звучит слишком роботизированно. Убрать непонятные пресеты, оставить те, что звучат нормально. Добавить паузы и ритм.
Решение: Убрал пресеты с экстремальными pitch (гул, металлический оттенок, слипание). Оставил четыре: «Дворецкий», «Диктор», «Лектор», «Полушёпот». Ввёл разбиение длинного текста на короткие предложения с паузой 180–220 мс между ними — интонация стала естественнее.
function splitSentences(text){
const parts = text.match(/[^.!?…]+[.!?…]+/g);
if(!parts) return [text.trim()];
const rest = text.slice(parts.join('').length).trim();
if(rest) parts.push(rest);
return parts.map(s=>s.trim()).filter(Boolean);
}
function speakText(text, onDone){
if(!voiceOn || !('speechSynthesis' in window)){ onDone && onDone(); return; }
speechSynthesis.cancel();
const sentences = splitSentences(text);
let i = 0;
const step = ()=>{
if(i >= sentences.length){ onDone && onDone(); return; }
const u = new SpeechSynthesisUtterance(sentences[i]);
u.lang = 'ru-RU';
if(ruVoice) u.voice = ruVoice;
u.pitch = 0.75;
u.rate = 0.9;
u.onend = ()=>{ i++; setTimeout(step, 200); };
speechSynthesis.speak(u);
};
step();
}
Задача: Убрать посторонние шумы и лишние элементы из реплик.
Решение: Удалил модуль фоновой атмосферы (низкочастотный гул и шумовая составляющая). Из текстовых реплик убрал лишние обращения и служебные наименования. Голосовые пресеты оставил в нейтральном виде.
Задача: Картинка размытая. Сделать чёткой.
Решение: Добавил функцию fitCanvas() с учётом devicePixelRatio. Внутренний размер канваса увеличивается пропорционально DPR, рендер идёт через ctx.setTransform() в логических координатах. Плюс imageSmoothingQuality = 'high'.
Заодно починил «застывание» волны в финале — там был алиасинг из-за буферизации сэмплов. Заменил на прямую отрисовку по пикселям с бегущей фазой.
function fitCanvas(){
const rect = cv.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const displayW = rect.width || BASE_W;
const displayH = displayW * BASE_H / BASE_W;
const targetW = Math.max(1, Math.round(displayW * dpr));
const targetH = Math.max(1, Math.round(displayH * dpr));
if(cv.width !== targetW || cv.height !== targetH){
cv.width = targetW;
cv.height = targetH;
}
ctx.setTransform(targetW/BASE_W, 0, 0, targetH/BASE_H, 0, 0);
ctx.imageSmoothingQuality = 'high';
}
window.addEventListener('resize', fitCanvas);
wavePhase += dt * (0.6 + imit.freq / 90);
const cycles = 3 + (imit.freq / 200);
ctx.beginPath();
for(let px = 0; px <= spanX; px++){
const u = px / spanX;
const phase = u * cycles + wavePhase;
let v = waveSample(phase, imit.wave);
const env = 1 - imit.mod * 0.55
+ imit.mod * 0.55 * (0.5 + 0.5 * Math.sin(u * Math.PI * 4 - t * 2.4));
v *= imit.amp * env;
const y = cy - v * scaleY;
const x = 60 + px;
if(px === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
Задача: Добавить что-то техническое, что отличает проект от обычной анимации.
Решение: Сделал спектрограмму-водопад (waterfall spectrogram) через отдельный offscreen canvas. Каждый кадр содержимое сдвигается на строку вниз через getImageData/putImageData, сверху записывается текущий спектр. 30 Гц, 96 полос, цветовая карта от тёмного к светлому.
const SPEC_BARS = 96;
const SPEC_W = 780;
const SPEC_H = 70;
const spectroCanvas = document.createElement('canvas');
spectroCanvas.width = SPEC_W;
spectroCanvas.height = SPEC_H;
const spectroCtx = spectroCanvas.getContext('2d', { willReadFrequently: true });
function updateSpectrogram(barValues){
const img = spectroCtx.getImageData(0, 0, SPEC_W, SPEC_H - 1);
spectroCtx.putImageData(img, 0, 1);
const barW = SPEC_W / barValues.length;
for(let i = 0; i < barValues.length; i++){
spectroCtx.fillStyle = spectroColor(barValues[i]);
spectroCtx.fillRect(Math.floor(i * barW), 0, Math.ceil(barW) + 1, 1);
}
}
let spectroAccum = 0;
const SPECTRO_INTERVAL = 1 / 30;
spectroAccum += dt;
if(spectroAccum >= SPECTRO_INTERVAL){
spectroAccum = 0;
updateSpectrogram(barValues);
}
function spectroColor(v){
v = Math.max(0, Math.min(1, v));
const stops = [
[0.00, [10, 6, 3]],
[0.10, [26, 10, 5]],
[0.25, [74, 32, 16]],
[0.45, [138, 64, 32]],
[0.65, [200, 120, 80]],
[0.80, [212, 168, 96]],
[0.92, [240, 216, 144]],
[1.00, [255, 248, 224]]
];
for(let i = 1; i < stops.length; i++){
if(v <= stops[i][0]){
const t = (v - stops[i-1][0]) / (stops[i][0] - stops[i-1][0]);
const a = stops[i-1][1], b = stops[i][1];
const r = Math.round(a[0] + (b[0] - a[0]) * t);
const g = Math.round(a[1] + (b[1] - a[1]) * t);
const bl = Math.round(a[2] + (b[2] - a[2]) * t);
return `rgb(${r},${g},${bl})`;
}
}
return 'rgb(255,248,224)';
}
Задача: Всё уместить на одну страницу. Вертикальный скролл, как лендинг.
Решение: Переделал архитектуру — вместо пошаговой навигации сделал лендинг. Hero-экран + 6 глав + итоги. Каждая глава — своя canvas-сцена. Анимации запускаются через IntersectionObserver только когда секция в зоне видимости — это экономит ресурсы.
function makeScene(canvasId, drawFn, opts = {}){
const cv = document.getElementById(canvasId);
if(!cv) return null;
const ctx = cv.getContext('2d');
const BASE_W = 900;
const BASE_H = opts.height || parseInt(cv.dataset.h || 300, 10);
let visible = false;
let rafId = null;
let time = 0, last = 0;
const mouse = { x:-1, y:-1, inside:false, down:false };
function loop(ts){
if(!visible) return;
if(!last) last = ts;
const dt = Math.min(.05, (ts - last) / 1000);
last = ts;
time += dt;
drawFn(ctx, BASE_W, BASE_H, time, dt, mouse);
rafId = requestAnimationFrame(loop);
}
const observer = new IntersectionObserver(entries => {
for(const e of entries){
if(e.isIntersecting && !visible){
visible = true;
last = 0;
resize();
rafId = requestAnimationFrame(loop);
} else if(!e.isIntersecting && visible){
visible = false;
if(rafId) cancelAnimationFrame(rafId);
}
}
}, { threshold: 0.1 });
cv.addEventListener('mousemove', e => {
const r = cv.getBoundingClientRect();
mouse.x = (e.clientX - r.left) * (BASE_W / r.width);
mouse.y = (e.clientY - r.top) * (BASE_H / r.height);
mouse.inside = true;
if(opts.onMouseMove) opts.onMouseMove(mouse, ctx, BASE_W, BASE_H);
});
cv.addEventListener('mousedown', e => {
mouse.down = true;
if(opts.onMouseDown) opts.onMouseDown(mouse, ctx, BASE_W, BASE_H);
});
window.addEventListener('mouseup', ()=>{
mouse.down = false;
if(opts.onMouseUp) opts.onMouseUp(mouse);
});
window.addEventListener('resize', resize);
resize();
observer.observe(cv);
return { cv, ctx, BASE_W, BASE_H, mouse, resize };
}
Задача: В каждой главе сделать интерактив. Отдельная кнопка, которая озвучит текст текущей главы. Починить режим «Текст» — сейчас он не работает, введённая фраза не отображается.
Решение: В главы 1–5 добавил по элементу управления. В главе про звук — ползунок громкости. В главе про диафрагму — клик толкает мембрану. В главе про катушку — перетаскивание мышью, гальванометр реагирует. В главе про Фарадея — кнопка «Магнит: вкл/выкл». В главе про сигнал — ползунок усиления и кнопка «Шум».
В шапку добавил кнопку «Читать главу» — она находит секцию, которая сейчас в центре экрана, собирает её заголовок и абзацы, читает по предложениям. Плюс кнопка «Стоп».
Режим «Текст» переписал — теперь введённая фраза выводится прямо на канвасе курсивом, подсвечивается золотым, пока ассистент говорит. Волна и спектрограмма идут синхронно с речью.
function getCurrentScene(){
const scenes = document.querySelectorAll('.hero, .scene');
const marker = window.scrollY + window.innerHeight * 0.4;
let current = scenes[0];
for(const s of scenes){
if(s.offsetTop <= marker) current = s;
}
return current;
}
readSceneBtn.onclick = () => {
const scene = getCurrentScene();
if(!scene) return;
const parts = [];
const title = scene.querySelector('h1, .scene-title');
if(title) parts.push(title.textContent.trim());
scene.querySelectorAll('.scene-text p, .hero .howto').forEach(p => {
parts.push(p.textContent.trim());
});
const text = parts.join(' ');
if(!text) return;
stopSpeech();
speakText(text);
};
if(finalMode === 'text' && currentLabText){
const txt = currentLabText.length > 70
? currentLabText.slice(0, 67) + '…'
: currentLabText;
ctx.save();
ctx.font = "italic 16px 'Courier New',monospace";
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = currentlySpeaking
? C.gold
: 'rgba(232,208,160,.75)';
ctx.shadowColor = currentlySpeaking ? C.gold : 'transparent';
ctx.shadowBlur = currentlySpeaking ? 14 : 0;
ctx.fillText('«' + txt + '»', W/2, cy);
ctx.restore();
}
Задача. Сцена полной сборки не показывала путь от слабого ЭДС до готового сигнала. Требовалось добавить наглядную визуализацию работы усилителя.
Решение. Сцена разнесена на два уровня: сверху акустика (динамик → волна → мембрана → катушка), снизу электрика (слабый ЭДС → усилитель → выходной сигнал). Между уровнями — стрелка ЭДС. Добавлен ползунок усиления, коэффициент отображается рядом с усилителем.
const gainMul = 0.2 + gain * 1.5;
// Слабый сигнал с катушки
ctx.beginPath();
for(let i=0;i<=g1W;i++){
const u = i / g1W;
const y = botCy - Math.cos((t*2.2 + u*3.6*Math.PI)) * (5 + vol * 8);
if(i===0) ctx.moveTo(g1X + i, y); else ctx.lineTo(g1X + i, y);
}
ctx.stroke();
// Усиленный сигнал на выходе
ctx.beginPath();
for(let i=0;i<=g2W;i++){
const u = i / g2W;
const y = botCy - Math.cos((t*2.2 + u*3.6*Math.PI)) * (7 + vol * 14) * gainMul;
if(i===0) ctx.moveTo(g2X + i, y); else ctx.lineTo(g2X + i, y);
}
ctx.stroke();
Задача. Силовые линии магнитного поля были плохо заметны. Движение катушки казалось слишком быстрым и не соответствовало остальным процессам в сцене.
Решение. Количество силовых линий увеличено, добавлены бегущие стрелки направления поля. Расстояние между полюсами N и S увеличено в 2.4 раза для наглядного хода катушки. Убран искусственный множитель скорости катушки — теперь она движется синхронно с мембраной.
// Было:
const coilCx = coilX + bow * 0.6;
// Стало:
const maxMotion = (magX2 - magX1 - magW * 2) / 2 - 30;
const coilMotion = Math.max(-maxMotion, Math.min(maxMotion, bow));
const coilCx = coilX + coilMotion;
Сами магниты разнесены: было magX1=380, magX2=470 (расстояние 90 px), стало magX1=340, magX2=560 (расстояние 220 px). Внутренний зазор между полюсами — 164 px, этого хватает, чтобы катушка двигалась в пределах поля даже при максимальной громкости.
Силовые линии поля теперь рисуются 8 штук с шагом 12 px между ними, по каждой линии бежит стрелка, показывающая направление от N к S.
Задача. Вернуть первую сцену к исходному виду — без мембраны. Показать ход диффузора динамика, чтобы была видна механическая передача толчка в воздух. Молекулы окрасить в один цвет, чтобы не менялся, и было видно колебание каждой отдельной молекулы. Добавить нулевое значение громкости, при котором молекулы замирают на месте.
Решение. Сцена 01 переработана:
const speakerVal = waveValue(speakerX, speakerX, endX, freq, t, waveSpeed);
const diaphragmOffset = speakerVal * vol * 12;
// Конус диффузора — колеблется вправо-влево
ctx.fillStyle = 'rgba(212,168,96,.18)';
ctx.beginPath();
ctx.moveTo(65, cy - 50);
ctx.lineTo(speakerX + diaphragmOffset, cy - 78);
ctx.lineTo(speakerX + diaphragmOffset, cy + 78);
ctx.lineTo(65, cy + 50);
ctx.closePath();
ctx.fill();
ctx.stroke();
Молекулы — одного цвета без градиента:
ctx.fillStyle = C.khaki;
ctx.shadowColor = C.khaki;
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(x, y, 3.2, 0, Math.PI*2);
ctx.fill();
Задача. Добавить страницу про мембрану, на которой была бы видна вся цепочка: динамик с движущимся диффузором, передача толчка через молекулы и реакция мембраны. Требуется сохранить единый стиль: молекулы одного цвета, замирание при нулевой громкости.
Решение. Добавлена глава 02 «Мембрана ловит волну». Структура проекта расширена до семи содержательных сцен: Звук → Мембрана → Катушка → Фарадей → Сигнал → Сборка → Лаборатория.
В главе 02:
const memVal = waveValue(membraneX, speakerX, membraneX, freq, t, waveSpeed);
const bow = memVal * vol * 42;
ctx.beginPath();
const SEG = 44;
for(let i = 0; i <= SEG; i++){
const p = i / SEG;
const y = memTop + (memBot - memTop) * p;
const shape = Math.sin(Math.PI * p);
const x = membraneX + bow * shape;
if(i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
Задача. Привести поведение молекул во всём проекте к единому виду: один цвет без градиента по фазе, замирание при нулевой громкости. Обеспечить те же требования в главе полной сборки и в лаборатории.
Решение. В сценах 06 «Полная сборка» и 07 «Лаборатория» молекулы перекрашены в единый цвет khaki, убрана зависимость яркости от текущей фазы волны. Формулы амплитуды приведены к виду amp = vol × N без базового смещения — при нулевой громкости движение полностью прекращается.
В сцену сборки также добавлен колеблющийся диффузор динамика и затухание графиков при нуле:
// График слабого ЭДС — амплитуда зависит от громкости
const y = botCy - Math.cos((t*2.2 + u*3.6*Math.PI)) * (vol * 12);
Ползунок «Амплитуда» в генераторе лаборатории получил min="0". При нуле волна, молекулы и графики замирают.
Задача. Привести документацию и навигацию в соответствие с новой структурой проекта.
Решение. В вводном экране список глав обновлён до семи позиций: Звук, Мембрана, Катушка, ЭДС, Сигнал, Сборка, Лаборатория. В блоке итогов уточнена формулировка про мембрану:
Мембрана микрофона механически повторяет эти колебания без искажений.
Просто открыть index.html в браузере.
Для режима «Микрофон» — запустить через локальный сервер, потому что браузеры блокируют микрофон на file://:
python -m http.server 8000
Потом открыть http://localhost:8000