Explorar el Código

Загрузить файлы 'docs'

u23-27burdyko hace 1 mes
padre
commit
30fd1a3fe8
Se han modificado 1 ficheros con 392 adiciones y 0 borrados
  1. 392 0
      docs/issues.md.md

+ 392 - 0
docs/issues.md.md

@@ -0,0 +1,392 @@
+# Проект: Акустоэлектрическое преобразование
+
+Интерактивный веб-проект — лендинг с визуализацией процесса
+преобразования звука в электрический сигнал на примере микрофона.
+
+---
+
+**Задача:** Нужен интерактив, который не выглядит как тест,
+а несёт повествование по теме. Оформление в стилистике 90-х.
+Желательно с голосовым сопровождением.
+
+**Решение:** Сделал базовый каркас — окно в стиле 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();
+}
+```
+
+---
+
+## Как запустить
+
+Просто открыть index.html в браузере.
+
+Для режима «Микрофон» — запустить через локальный сервер, потому что
+браузеры блокируют микрофон на file://:
+
+```
+python -m http.server 8000
+```
+
+Потом открыть http://localhost:8000
+
+## Файлы
+
+- index.html — весь проект
+- README.md — этот файл