# Проект: Акустоэлектрическое преобразование Интерактивный веб-проект — лендинг с визуализацией процесса преобразования звука в электрический сигнал на примере микрофона. --- **Задача:** Нужен интерактив, который не выглядит как тест, а несёт повествование по теме. Желательно с голосовым сопровождением. **Решение:** Сделал базовый каркас — окно в стиле Windows 95, экран с CRT-развёрткой, диалоговая панель. Сценарий разбит на 7 глав: пролог, звук, диафрагма, индукция, закон Фарадея, сигнал, лаборатория. Озвучка через Web Speech API с настраиваемыми pitch/rate. --- **Задача:** Добавить вариативность — чтобы можно было использовать и реальный микрофон, и имитацию сигнала. **Решение:** Сделал три режима в финале: «Генератор» (синтез сигнала заданной формы), «Микрофон» (реальный поток через getUserMedia), «Текст» (озвучка введённой фразы с визуализацией волны). Обработал случай file:// — браузеры блокируют микрофон, приложение само переключается в доступный режим. ```javascript 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 мс между ними — интонация стала естественнее. ```javascript 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'. Заодно починил «застывание» волны в финале — там был алиасинг из-за буферизации сэмплов. Заменил на прямую отрисовку по пикселям с бегущей фазой. ```javascript 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); ``` ```javascript 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 полос, цветовая карта от тёмного к светлому. ```javascript 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); } ``` ```javascript 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 только когда секция в зоне видимости — это экономит ресурсы. ```javascript 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 добавил по элементу управления. В главе про звук — ползунок громкости. В главе про диафрагму — клик толкает мембрану. В главе про катушку — перетаскивание мышью, гальванометр реагирует. В главе про Фарадея — кнопка «Магнит: вкл/выкл». В главе про сигнал — ползунок усиления и кнопка «Шум». В шапку добавил кнопку «Читать главу» — она находит секцию, которая сейчас в центре экрана, собирает её заголовок и абзацы, читает по предложениям. Плюс кнопка «Стоп». Режим «Текст» переписал — теперь введённая фраза выводится прямо на канвасе курсивом, подсвечивается золотым, пока ассистент говорит. Волна и спектрограмма идут синхронно с речью. ```javascript 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); }; ``` ```javascript 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(); } ``` --- ## Запись 12. Добавление усиления в сцену полной сборки **Задача.** Сцена полной сборки не показывала путь от слабого ЭДС до готового сигнала. Требовалось добавить наглядную визуализацию работы усилителя. **Решение.** Сцена разнесена на два уровня: сверху акустика (динамик → волна → мембрана → катушка), снизу электрика (слабый ЭДС → усилитель → выходной сигнал). Между уровнями — стрелка ЭДС. Добавлен ползунок усиления, коэффициент отображается рядом с усилителем. ```javascript 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(); ``` --- ## Запись 13. Улучшение видимости магнитного поля **Задача.** Силовые линии магнитного поля были плохо заметны. Движение катушки казалось слишком быстрым и не соответствовало остальным процессам в сцене. **Решение.** Количество силовых линий увеличено, добавлены бегущие стрелки направления поля. Расстояние между полюсами N и S увеличено в 2.4 раза для наглядного хода катушки. Убран искусственный множитель скорости катушки — теперь она движется синхронно с мембраной. ```javascript // Было: 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. --- ## Запись 14. Возврат сцены «Звук» и движение диффузора **Задача.** Вернуть первую сцену к исходному виду — без мембраны. Показать ход диффузора динамика, чтобы была видна механическая передача толчка в воздух. Молекулы окрасить в один цвет, чтобы не менялся, и было видно колебание каждой отдельной молекулы. Добавить нулевое значение громкости, при котором молекулы замирают на месте. **Решение.** Сцена 01 переработана: - убрана мембрана, остались только динамик и молекулы; - у динамика появился колеблющийся диффузор — конус, который выдвигается вправо-влево в такт волне и толкает молекулы; - все молекулы окрашены в один цвет khaki без градиента по фазе; - амплитуда волны вычисляется как amp = vol × 42 — при нуле амплитуда равна нулю, молекулы стоят, диффузор замирает; - ползунок громкости получил min="0"; - при нулевой громкости внизу появляется надпись «тишина». ```javascript 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(); ``` Молекулы — одного цвета без градиента: ```javascript 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(); ``` --- ## Запись 15. Отдельная глава про мембрану **Задача.** Добавить страницу про мембрану, на которой была бы видна вся цепочка: динамик с движущимся диффузором, передача толчка через молекулы и реакция мембраны. Требуется сохранить единый стиль: молекулы одного цвета, замирание при нулевой громкости. **Решение.** Добавлена глава 02 «Мембрана ловит волну». Структура проекта расширена до семи содержательных сцен: Звук → Мембрана → Катушка → Фарадей → Сигнал → Сборка → Лаборатория. В главе 02: - слева динамик с колеблющимся диффузором; - в центре — молекулы воздуха одного цвета; - справа — мембрана, отклоняющаяся ровно на ту волну, которая до неё дошла; - ползунки частоты и громкости аналогичны сцене 01; - при нулевой громкости молекулы стоят, диффузор замер, мембрана плоская, внизу надпись «тишина». ```javascript 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(); ``` --- ## Запись 16. Унификация поведения молекул во всех сценах **Задача.** Привести поведение молекул во всём проекте к единому виду: один цвет без градиента по фазе, замирание при нулевой громкости. Обеспечить те же требования в главе полной сборки и в лаборатории. **Решение.** В сценах 06 «Полная сборка» и 07 «Лаборатория» молекулы перекрашены в единый цвет khaki, убрана зависимость яркости от текущей фазы волны. Формулы амплитуды приведены к виду amp = vol × N без базового смещения — при нулевой громкости движение полностью прекращается. В сцену сборки также добавлен колеблющийся диффузор динамика и затухание графиков при нуле: ```javascript // График слабого ЭДС — амплитуда зависит от громкости const y = botCy - Math.cos((t*2.2 + u*3.6*Math.PI)) * (vol * 12); ``` Ползунок «Амплитуда» в генераторе лаборатории получил min="0". При нуле волна, молекулы и графики замирают. --- ## Запись 17. Обновление структуры и итогов **Задача.** Привести документацию и навигацию в соответствие с новой структурой проекта. **Решение.** В вводном экране список глав обновлён до семи позиций: Звук, Мембрана, Катушка, ЭДС, Сигнал, Сборка, Лаборатория. В блоке итогов уточнена формулировка про мембрану: > Мембрана микрофона механически повторяет эти колебания > без искажений. --- ## Как запустить Просто открыть index.html в браузере. Для режима «Микрофон» — запустить через локальный сервер, потому что браузеры блокируют микрофон на file://: ``` python -m http.server 8000 ``` Потом открыть http://localhost:8000