|
@@ -0,0 +1,1946 @@
|
|
|
|
|
+<!DOCTYPE html>
|
|
|
|
|
+<html lang="ru">
|
|
|
|
|
+<head>
|
|
|
|
|
+<meta charset="UTF-8">
|
|
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
+<title>Акустоэлектрическое преобразование — интерактивная лаборатория</title>
|
|
|
|
|
+<style>
|
|
|
|
|
+ :root{
|
|
|
|
|
+ --bg-1:#1a1005; --bg-2:#0a0603;
|
|
|
|
|
+ --amber:#d4a860; --gold:#f0d890; --terra:#c87850;
|
|
|
|
|
+ --khaki:#b8c878; --cream:#e8d0a0; --rust:#b86848; --olive:#8a9860;
|
|
|
|
|
+ }
|
|
|
|
|
+ *{box-sizing:border-box}
|
|
|
|
|
+ html{scroll-behavior:smooth}
|
|
|
|
|
+ body{
|
|
|
|
|
+ margin:0;
|
|
|
|
|
+ background:
|
|
|
|
|
+ radial-gradient(circle at 20% 0%, #2a1a0e 0%, transparent 55%),
|
|
|
|
|
+ radial-gradient(circle at 80% 100%, #1a2218 0%, transparent 55%),
|
|
|
|
|
+ #0a0603;
|
|
|
|
|
+ color:var(--cream);
|
|
|
|
|
+ font-family:"Courier New","JetBrains Mono",monospace;
|
|
|
|
|
+ font-size:15px; line-height:1.7; overflow-x:hidden;
|
|
|
|
|
+ }
|
|
|
|
|
+ body::before{
|
|
|
|
|
+ content:""; position:fixed; inset:0;
|
|
|
|
|
+ background-image:
|
|
|
|
|
+ linear-gradient(rgba(212,168,96,.03) 1px, transparent 1px),
|
|
|
|
|
+ linear-gradient(90deg, rgba(212,168,96,.03) 1px, transparent 1px);
|
|
|
|
|
+ background-size:40px 40px; pointer-events:none; z-index:0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .topbar{
|
|
|
|
|
+ position:fixed; top:0; left:0; right:0;
|
|
|
|
|
+ background:linear-gradient(180deg, rgba(20,12,6,.95), rgba(20,12,6,.75));
|
|
|
|
|
+ backdrop-filter:blur(8px);
|
|
|
|
|
+ border-bottom:1px solid rgba(212,168,96,.25);
|
|
|
|
|
+ padding:8px 20px;
|
|
|
|
|
+ display:flex; justify-content:space-between; align-items:center;
|
|
|
|
|
+ z-index:100; font-size:12.5px; letter-spacing:1px;
|
|
|
|
|
+ gap:12px; flex-wrap:wrap;
|
|
|
|
|
+ }
|
|
|
|
|
+ .topbar .brand{
|
|
|
|
|
+ color:var(--amber); font-weight:bold;
|
|
|
|
|
+ text-shadow:0 0 10px rgba(212,168,96,.6);
|
|
|
|
|
+ flex-shrink:0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .topbar .controls{display:flex; gap:8px; align-items:center; flex-wrap:wrap}
|
|
|
|
|
+ .topbar button{
|
|
|
|
|
+ background:rgba(212,168,96,.1); color:var(--amber);
|
|
|
|
|
+ border:1px solid rgba(212,168,96,.4);
|
|
|
|
|
+ padding:5px 12px; font-family:inherit; font-size:11.5px;
|
|
|
|
|
+ letter-spacing:1px; cursor:pointer;
|
|
|
|
|
+ transition:background .2s; border-radius:2px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .topbar button:hover{background:rgba(212,168,96,.22)}
|
|
|
|
|
+ .topbar button.active{background:var(--amber); color:#1a1005}
|
|
|
|
|
+ .scroll-progress{
|
|
|
|
|
+ position:absolute; bottom:-1px; left:0; height:2px;
|
|
|
|
|
+ background:linear-gradient(90deg,var(--terra),var(--amber),var(--gold));
|
|
|
|
|
+ width:0%; box-shadow:0 0 8px var(--amber);
|
|
|
|
|
+ transition:width .1s linear;
|
|
|
|
|
+ }
|
|
|
|
|
+ .scene{
|
|
|
|
|
+ position:relative; z-index:1;
|
|
|
|
|
+ max-width:980px; margin:0 auto; padding:80px 24px;
|
|
|
|
|
+ opacity:0; transform:translateY(40px);
|
|
|
|
|
+ transition:opacity .9s cubic-bezier(.2,.8,.3,1), transform .9s cubic-bezier(.2,.8,.3,1);
|
|
|
|
|
+ }
|
|
|
|
|
+ .scene.in-view{opacity:1; transform:translateY(0)}
|
|
|
|
|
+ .scene-header{
|
|
|
|
|
+ display:flex; align-items:baseline; gap:20px;
|
|
|
|
|
+ margin-bottom:24px; padding-bottom:14px;
|
|
|
|
|
+ border-bottom:1px solid rgba(212,168,96,.2);
|
|
|
|
|
+ }
|
|
|
|
|
+ .scene-num{
|
|
|
|
|
+ font-size:56px; color:var(--terra); font-weight:bold;
|
|
|
|
|
+ line-height:1; text-shadow:0 0 24px rgba(200,120,80,.5);
|
|
|
|
|
+ letter-spacing:-2px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .scene-title{
|
|
|
|
|
+ font-size:24px; color:var(--amber);
|
|
|
|
|
+ text-shadow:0 0 16px rgba(212,168,96,.5);
|
|
|
|
|
+ letter-spacing:1px; margin:0; font-weight:bold;
|
|
|
|
|
+ }
|
|
|
|
|
+ .scene-text{
|
|
|
|
|
+ font-size:15.5px; color:var(--cream);
|
|
|
|
|
+ margin:0 0 24px 0; max-width:720px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .scene-text p{margin:0 0 14px 0}
|
|
|
|
|
+ .scene-text strong{color:var(--gold); font-weight:bold}
|
|
|
|
|
+ .scene-text em{color:var(--khaki); font-style:normal}
|
|
|
|
|
+ .screen-frame{
|
|
|
|
|
+ background:#000;
|
|
|
|
|
+ border:2px solid rgba(212,168,96,.35);
|
|
|
|
|
+ border-radius:3px; overflow:hidden; position:relative;
|
|
|
|
|
+ box-shadow:0 0 0 1px rgba(0,0,0,.8), 0 0 40px rgba(212,168,96,.1);
|
|
|
|
|
+ }
|
|
|
|
|
+ .screen-frame::after{
|
|
|
|
|
+ content:""; position:absolute; inset:0;
|
|
|
|
|
+ background:repeating-linear-gradient(to bottom,
|
|
|
|
|
+ transparent 0, transparent 2px,
|
|
|
|
|
+ rgba(0,0,0,.18) 3px, rgba(0,0,0,.18) 4px);
|
|
|
|
|
+ pointer-events:none; z-index:2;
|
|
|
|
|
+ }
|
|
|
|
|
+ .screen-frame::before{
|
|
|
|
|
+ content:""; position:absolute; inset:0;
|
|
|
|
|
+ background:radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.5) 100%);
|
|
|
|
|
+ pointer-events:none; z-index:3;
|
|
|
|
|
+ }
|
|
|
|
|
+ canvas{display:block; width:100%; height:auto; position:relative; z-index:1}
|
|
|
|
|
+ .interact-hint{
|
|
|
|
|
+ margin-top:10px; padding:8px 14px;
|
|
|
|
|
+ background:rgba(212,168,96,.06);
|
|
|
|
|
+ border-left:2px solid var(--terra);
|
|
|
|
|
+ color:rgba(232,208,160,.85);
|
|
|
|
|
+ font-size:12.5px; letter-spacing:.5px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .interact-hint::before{content:"▸ "; color:var(--terra); font-weight:bold}
|
|
|
|
|
+ .formula{
|
|
|
|
|
+ display:inline-block;
|
|
|
|
|
+ background:rgba(212,168,96,.08);
|
|
|
|
|
+ border:1px solid rgba(212,168,96,.35);
|
|
|
|
|
+ padding:10px 20px; margin:14px 0;
|
|
|
|
|
+ color:var(--gold); font-size:20px;
|
|
|
|
|
+ letter-spacing:2px; border-radius:2px;
|
|
|
|
|
+ text-shadow:0 0 12px rgba(240,216,144,.5);
|
|
|
|
|
+ }
|
|
|
|
|
+ .formula small{
|
|
|
|
|
+ display:block; font-size:12px;
|
|
|
|
|
+ color:rgba(232,208,160,.7);
|
|
|
|
|
+ letter-spacing:1px; margin-top:6px; text-shadow:none;
|
|
|
|
|
+ }
|
|
|
|
|
+ .sliders-row{
|
|
|
|
|
+ display:flex; flex-wrap:wrap; gap:20px;
|
|
|
|
|
+ padding:14px 18px; margin-top:12px;
|
|
|
|
|
+ background:rgba(20,12,6,.6);
|
|
|
|
|
+ border:1px solid rgba(212,168,96,.25);
|
|
|
|
|
+ border-radius:2px;
|
|
|
|
|
+ align-items:center;
|
|
|
|
|
+ }
|
|
|
|
|
+ .sliders-row label{
|
|
|
|
|
+ font-size:12.5px; color:var(--cream);
|
|
|
|
|
+ display:flex; align-items:center; gap:10px; letter-spacing:.5px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .sliders-row input[type=range]{width:180px; accent-color:var(--terra)}
|
|
|
|
|
+ .sliders-row .value{
|
|
|
|
|
+ color:var(--amber); font-family:"Courier New",monospace;
|
|
|
|
|
+ font-size:12px; min-width:52px; text-align:right;
|
|
|
|
|
+ }
|
|
|
|
|
+ .hero{
|
|
|
|
|
+ min-height:100vh; display:flex; flex-direction:column;
|
|
|
|
|
+ justify-content:center; align-items:center;
|
|
|
|
|
+ text-align:center; padding:80px 24px;
|
|
|
|
|
+ position:relative; z-index:1;
|
|
|
|
|
+ opacity:1; transform:none;
|
|
|
|
|
+ }
|
|
|
|
|
+ .hero h1{
|
|
|
|
|
+ font-size:clamp(28px, 5vw, 52px);
|
|
|
|
|
+ color:var(--amber); margin:0 0 20px 0;
|
|
|
|
|
+ letter-spacing:3px;
|
|
|
|
|
+ text-shadow:0 0 20px rgba(212,168,96,.7), 0 0 60px rgba(212,168,96,.3);
|
|
|
|
|
+ line-height:1.1;
|
|
|
|
|
+ }
|
|
|
|
|
+ .hero h2{
|
|
|
|
|
+ font-size:clamp(14px, 2vw, 18px);
|
|
|
|
|
+ color:var(--khaki); letter-spacing:2px;
|
|
|
|
|
+ font-weight:normal; margin:0 0 40px 0; opacity:.85;
|
|
|
|
|
+ }
|
|
|
|
|
+ .hero .divider{
|
|
|
|
|
+ width:120px; height:1px;
|
|
|
|
|
+ background:linear-gradient(90deg, transparent, var(--amber), transparent);
|
|
|
|
|
+ margin:24px 0; box-shadow:0 0 12px var(--amber);
|
|
|
|
|
+ }
|
|
|
|
|
+ .hero .meta{
|
|
|
|
|
+ display:flex; gap:36px; flex-wrap:wrap; justify-content:center;
|
|
|
|
|
+ color:rgba(232,208,160,.6);
|
|
|
|
|
+ font-size:12px; letter-spacing:1.5px; margin-top:32px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .hero .meta span{display:flex; gap:8px; align-items:center}
|
|
|
|
|
+ .hero .meta b{color:var(--amber)}
|
|
|
|
|
+ .hero .howto{
|
|
|
|
|
+ margin-top:48px; color:rgba(232,208,160,.55);
|
|
|
|
|
+ font-size:12.5px; letter-spacing:1px; max-width:560px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .hero .howto b{color:var(--terra)}
|
|
|
|
|
+ .scroll-hint{
|
|
|
|
|
+ position:absolute; bottom:36px;
|
|
|
|
|
+ left:50%; transform:translateX(-50%);
|
|
|
|
|
+ color:var(--amber); font-size:11px; letter-spacing:2px;
|
|
|
|
|
+ opacity:.7; animation:bob 2s ease-in-out infinite;
|
|
|
|
|
+ }
|
|
|
|
|
+ @keyframes bob{
|
|
|
|
|
+ 0%,100%{transform:translate(-50%, 0)}
|
|
|
|
|
+ 50%{transform:translate(-50%, 10px)}
|
|
|
|
|
+ }
|
|
|
|
|
+ .scroll-hint::after{
|
|
|
|
|
+ content:"▼"; display:block; text-align:center; margin-top:6px;
|
|
|
|
|
+ color:var(--terra); font-size:14px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .lab-tabs{display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap}
|
|
|
|
|
+ .lab-tab{
|
|
|
|
|
+ background:rgba(212,168,96,.08); color:var(--amber);
|
|
|
|
|
+ border:1px solid rgba(212,168,96,.3);
|
|
|
|
|
+ padding:8px 18px; font-family:inherit; font-size:13px;
|
|
|
|
|
+ letter-spacing:1px; cursor:pointer;
|
|
|
|
|
+ transition:all .2s; border-radius:2px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .lab-tab:hover{background:rgba(212,168,96,.18)}
|
|
|
|
|
+ .lab-tab.active{
|
|
|
|
|
+ background:var(--amber); color:#1a1005;
|
|
|
|
|
+ box-shadow:0 0 20px rgba(212,168,96,.5);
|
|
|
|
|
+ }
|
|
|
|
|
+ .lab-panel{
|
|
|
|
|
+ display:none; padding:14px 18px;
|
|
|
|
|
+ background:rgba(20,12,6,.6);
|
|
|
|
|
+ border:1px solid rgba(212,168,96,.25);
|
|
|
|
|
+ margin-top:12px; border-radius:2px;
|
|
|
|
|
+ flex-wrap:wrap; gap:14px; align-items:center;
|
|
|
|
|
+ }
|
|
|
|
|
+ .lab-panel.on{display:flex}
|
|
|
|
|
+ .lab-panel label{
|
|
|
|
|
+ font-size:12.5px; color:var(--cream);
|
|
|
|
|
+ display:flex; align-items:center; gap:8px; letter-spacing:.5px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .lab-panel input[type=range]{width:130px; accent-color:var(--terra)}
|
|
|
|
|
+ .lab-panel select, .lab-panel input[type=text]{
|
|
|
|
|
+ background:rgba(0,0,0,.5); color:var(--gold);
|
|
|
|
|
+ border:1px solid rgba(212,168,96,.4);
|
|
|
|
|
+ padding:5px 10px; font-family:inherit; font-size:13px;
|
|
|
|
|
+ border-radius:2px; outline:none;
|
|
|
|
|
+ }
|
|
|
|
|
+ .lab-panel select option{background:#1a1005}
|
|
|
|
|
+ .lab-panel .value{
|
|
|
|
|
+ color:var(--amber); font-family:"Courier New",monospace;
|
|
|
|
|
+ font-size:12px; min-width:52px; text-align:right;
|
|
|
|
|
+ }
|
|
|
|
|
+ .lab-btn{
|
|
|
|
|
+ background:rgba(212,168,96,.1); color:var(--amber);
|
|
|
|
|
+ border:1px solid rgba(212,168,96,.4);
|
|
|
|
|
+ padding:7px 16px; font-family:inherit; font-size:12.5px;
|
|
|
|
|
+ letter-spacing:1px; cursor:pointer;
|
|
|
|
|
+ border-radius:2px; transition:all .2s;
|
|
|
|
|
+ }
|
|
|
|
|
+ .lab-btn:hover{background:rgba(212,168,96,.22)}
|
|
|
|
|
+ .lab-btn.primary{background:var(--terra); color:#f0f8e0; border-color:var(--terra)}
|
|
|
|
|
+ .lab-btn.primary:hover{background:#d88858}
|
|
|
|
|
+ .lab-btn.rec{background:rgba(200,72,72,.5); color:#fff; border-color:rgba(200,72,72,.7)}
|
|
|
|
|
+ .lab-btn.rec.on{background:#c84848; animation:recPulse 1s infinite}
|
|
|
|
|
+ @keyframes recPulse{50%{background:#8a2828}}
|
|
|
|
|
+ .lab-btn:disabled{opacity:.4; cursor:default}
|
|
|
|
|
+ .chip-row{display:flex; flex-wrap:wrap; gap:6px; margin-top:10px}
|
|
|
|
|
+ .chip{
|
|
|
|
|
+ background:rgba(212,168,96,.06);
|
|
|
|
|
+ color:rgba(232,208,160,.85);
|
|
|
|
|
+ border:1px solid rgba(212,168,96,.25);
|
|
|
|
|
+ padding:5px 12px; font-family:inherit; font-size:11.5px;
|
|
|
|
|
+ cursor:pointer; border-radius:2px; transition:all .2s;
|
|
|
|
|
+ }
|
|
|
|
|
+ .chip:hover{background:rgba(212,168,96,.18); color:var(--gold)}
|
|
|
|
|
+ footer{
|
|
|
|
|
+ position:relative; z-index:1;
|
|
|
|
|
+ text-align:center; padding:80px 24px 100px;
|
|
|
|
|
+ border-top:1px solid rgba(212,168,96,.2);
|
|
|
|
|
+ margin-top:80px;
|
|
|
|
|
+ }
|
|
|
|
|
+ footer h3{
|
|
|
|
|
+ color:var(--amber); font-size:22px; letter-spacing:2px;
|
|
|
|
|
+ text-shadow:0 0 16px rgba(212,168,96,.5);
|
|
|
|
|
+ margin:0 0 24px 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ footer ul{list-style:none; padding:0; max-width:640px; margin:0 auto 32px; text-align:left}
|
|
|
|
|
+ footer li{
|
|
|
|
|
+ padding:10px 0 10px 28px; position:relative;
|
|
|
|
|
+ color:var(--cream); font-size:14px;
|
|
|
|
|
+ }
|
|
|
|
|
+ footer li::before{content:"▸"; position:absolute; left:0; top:10px; color:var(--terra)}
|
|
|
|
|
+ footer .sig{color:rgba(232,208,160,.4); font-size:11.5px; letter-spacing:1.5px; margin-top:24px}
|
|
|
|
|
+ @media (max-width:640px){
|
|
|
|
|
+ .topbar{padding:6px 12px; font-size:11px}
|
|
|
|
|
+ .topbar .brand{font-size:10.5px; letter-spacing:1px; max-width:60%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
|
|
|
|
+ .scene{padding:56px 16px}
|
|
|
|
|
+ .scene-num{font-size:40px}
|
|
|
|
|
+ .scene-title{font-size:19px}
|
|
|
|
|
+ .scene-text{font-size:14px}
|
|
|
|
|
+ .lab-tab{padding:6px 12px; font-size:11.5px}
|
|
|
|
|
+ .lab-panel{padding:10px 12px; gap:10px}
|
|
|
|
|
+ .lab-panel input[type=range]{width:90px}
|
|
|
|
|
+ .sliders-row{padding:10px 12px; gap:12px}
|
|
|
|
|
+ .sliders-row input[type=range]{width:120px}
|
|
|
|
|
+ }
|
|
|
|
|
+</style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+
|
|
|
|
|
+<div class="topbar">
|
|
|
|
|
+ <div class="brand">◤ ACOUSTO-LAB</div>
|
|
|
|
|
+ <div class="controls">
|
|
|
|
|
+ <button id="readSceneBtn" title="Озвучить текущую главу">📖 ЧИТАТЬ ГЛАВУ</button>
|
|
|
|
|
+ <button id="voiceToggle" class="active">🔊 ГОЛОС</button>
|
|
|
|
|
+ <button id="stopBtn" title="Стоп">■</button>
|
|
|
|
|
+ <button id="scrollTopBtn" title="Наверх">↑</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="scroll-progress" id="scrollProgress"></div>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<header class="hero" id="hero">
|
|
|
|
|
+ <h1>АКУСТОЭЛЕКТРИЧЕСКОЕ<br>ПРЕОБРАЗОВАНИЕ</h1>
|
|
|
|
|
+ <div class="divider"></div>
|
|
|
|
|
+ <h2>ИНТЕРАКТИВНАЯ ЛАБОРАТОРИЯ · КАК ЗВУК СТАНОВИТСЯ ЭЛЕКТРИЧЕСТВОМ</h2>
|
|
|
|
|
+ <div class="meta">
|
|
|
|
|
+ <span><b>01</b> Динамик и мембрана</span>
|
|
|
|
|
+ <span><b>02</b> Катушка</span>
|
|
|
|
|
+ <span><b>03</b> ЭДС</span>
|
|
|
|
|
+ <span><b>04</b> Сигнал</span>
|
|
|
|
|
+ <span><b>05</b> Полная сборка</span>
|
|
|
|
|
+ <span><b>06</b> Лаборатория</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="howto">
|
|
|
|
|
+ В каждой главе — <b>свой интерактив</b>. Кнопка <b>«📖 ЧИТАТЬ ГЛАВУ»</b>
|
|
|
|
|
+ в шапке озвучит текст той главы, что сейчас на экране.
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="scroll-hint">СКРОЛЛ</div>
|
|
|
|
|
+</header>
|
|
|
|
|
+
|
|
|
|
|
+<section class="scene" id="scene-chain">
|
|
|
|
|
+ <div class="scene-header">
|
|
|
|
|
+ <div class="scene-num">01</div>
|
|
|
|
|
+ <h2 class="scene-title">Динамик → волна → мембрана</h2>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="scene-text">
|
|
|
|
|
+ <p>Динамик создаёт <strong>колебания давления воздуха</strong>. Молекулы передают толчок друг другу — от источника к приёмнику. Это и есть звуковая волна.</p>
|
|
|
|
|
+ <p>Справа — <strong>мембрана микрофона</strong>. Она отклоняется ровно на ту волну, которая до неё дошла. Форма мембраны в точности повторяет форму волны — <em>без искажений</em>.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="screen-frame">
|
|
|
|
|
+ <canvas id="cv-chain" data-h="340"></canvas>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="sliders-row">
|
|
|
|
|
+ <label>Частота
|
|
|
|
|
+ <input type="range" id="chainFreq" min="1" max="10" value="4">
|
|
|
|
|
+ <span class="value" id="chainFreqVal">4</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <label>Громкость
|
|
|
|
|
+ <input type="range" id="chainVol" min="10" max="100" value="60">
|
|
|
|
|
+ <span class="value" id="chainVolVal">60%</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="interact-hint">Ползунок «Частота» меняет длину волны, «Громкость» — её амплитуду и размах мембраны.</div>
|
|
|
|
|
+</section>
|
|
|
|
|
+
|
|
|
|
|
+<section class="scene" id="scene-coil">
|
|
|
|
|
+ <div class="scene-header">
|
|
|
|
|
+ <div class="scene-num">02</div>
|
|
|
|
|
+ <h2 class="scene-title">Катушка в магнитном поле</h2>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="scene-text">
|
|
|
|
|
+ <p>К мембране жёстко прикреплена <strong>катушка</strong> из тонкого провода. Она находится в зазоре между полюсами постоянного магнита <em>N</em> и <em>S</em>.</p>
|
|
|
|
|
+ <p>Когда мембрана колеблется — катушка движется в магнитном поле. Движение проводника в поле создаёт в нём <strong>электрический ток</strong>.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="screen-frame">
|
|
|
|
|
+ <canvas id="cv-coil" data-h="360"></canvas>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="interact-hint">Потяни катушку мышью влево-вправо — стрелка гальванометра отреагирует на движение.</div>
|
|
|
|
|
+</section>
|
|
|
|
|
+
|
|
|
|
|
+<section class="scene" id="scene-faraday">
|
|
|
|
|
+ <div class="scene-header">
|
|
|
|
|
+ <div class="scene-num">03</div>
|
|
|
|
|
+ <h2 class="scene-title">Закон Фарадея</h2>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="scene-text">
|
|
|
|
|
+ <p>Величина ЭДС пропорциональна <strong>скорости изменения магнитного потока</strong>. Быстрее движется катушка — сильнее ток. Стоит на месте — тока нет.</p>
|
|
|
|
|
+ <div class="formula">
|
|
|
|
|
+ ε = − dΦ / dt
|
|
|
|
|
+ <small>ЭДС индукции = минус скорость изменения магнитного потока</small>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="screen-frame">
|
|
|
|
|
+ <canvas id="cv-faraday" data-h="340"></canvas>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="interact-hint">Нажми на кнопку «МАГНИТ: ВКЛ / ВЫКЛ» прямо на экране — увидишь, как гаснет ЭДС без поля.</div>
|
|
|
|
|
+</section>
|
|
|
|
|
+
|
|
|
|
|
+<section class="scene" id="scene-signal">
|
|
|
|
|
+ <div class="scene-header">
|
|
|
|
|
+ <div class="scene-num">04</div>
|
|
|
|
|
+ <h2 class="scene-title">Электрический сигнал</h2>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="scene-text">
|
|
|
|
|
+ <p>На выходе микрофона — <strong>переменное напряжение</strong>, форма которого повторяет звуковую волну. Это и есть акустоэлектрическое преобразование.</p>
|
|
|
|
|
+ <p>Сигнал слабый — <em>милливольты</em>. Его усиливают и передают дальше: в усилитель, в колонки, в запись.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="screen-frame">
|
|
|
|
|
+ <canvas id="cv-signal" data-h="280"></canvas>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="interact-hint">Двигай ползунок усиления. Кнопка «ШУМ» добавит помехи.</div>
|
|
|
|
|
+</section>
|
|
|
|
|
+
|
|
|
|
|
+<section class="scene" id="scene-assembly">
|
|
|
|
|
+ <div class="scene-header">
|
|
|
|
|
+ <div class="scene-num">05</div>
|
|
|
|
|
+ <h2 class="scene-title">Полная сборка</h2>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="scene-text">
|
|
|
|
|
+ <p>Сверху — <strong>акустическая часть</strong>: динамик, волна, мембрана, катушка в магнитах. Снизу — <strong>электрическая</strong>: слабая ЭДС с катушки, усилитель и готовый сигнал на выходе.</p>
|
|
|
|
|
+ <p>Ползунок «Усиление» показывает, во сколько раз усилитель увеличивает слабый сигнал.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="screen-frame">
|
|
|
|
|
+ <canvas id="cv-assembly" data-h="380"></canvas>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="sliders-row">
|
|
|
|
|
+ <label>Частота
|
|
|
|
|
+ <input type="range" id="asmFreq" min="1" max="10" value="4">
|
|
|
|
|
+ <span class="value" id="asmFreqVal">4</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <label>Громкость
|
|
|
|
|
+ <input type="range" id="asmVol" min="10" max="100" value="60">
|
|
|
|
|
+ <span class="value" id="asmVolVal">60%</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <label>Усиление
|
|
|
|
|
+ <input type="range" id="asmGain" min="0" max="100" value="50">
|
|
|
|
|
+ <span class="value" id="asmGainVal">50%</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="interact-hint">Меняй усиление — правый график растёт, левый остаётся слабым. Так работает усилитель.</div>
|
|
|
|
|
+</section>
|
|
|
|
|
+
|
|
|
|
|
+<section class="scene" id="scene-lab">
|
|
|
|
|
+ <div class="scene-header">
|
|
|
|
|
+ <div class="scene-num">06</div>
|
|
|
|
|
+ <h2 class="scene-title">Лаборатория</h2>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="scene-text">
|
|
|
|
|
+ <p>Три режима: <strong>генератор</strong>, <strong>микрофон</strong> и <strong>текст</strong>. Схема сверху реагирует только на реальный сигнал — когда ассистент делает паузу между фразами, схема замирает.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="lab-tabs">
|
|
|
|
|
+ <button class="lab-tab active" data-mode="imitation">◉ Генератор</button>
|
|
|
|
|
+ <button class="lab-tab" data-mode="mic">🎤 Микрофон</button>
|
|
|
|
|
+ <button class="lab-tab" data-mode="text">⌨ Текст</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="screen-frame">
|
|
|
|
|
+ <canvas id="cv-lab" data-h="500"></canvas>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="lab-panel on" id="panel-imitation">
|
|
|
|
|
+ <label>Частота <input type="range" id="labFreqRange" min="60" max="400" value="140"><span class="value" id="labFreqVal">140</span></label>
|
|
|
|
|
+ <label>Амплитуда <input type="range" id="labAmpRange" min="10" max="100" value="60"><span class="value" id="labAmpVal">60%</span></label>
|
|
|
|
|
+ <label>Форма
|
|
|
|
|
+ <select id="labWaveSelect">
|
|
|
|
|
+ <option value="sine">синус</option>
|
|
|
|
|
+ <option value="saw">пила</option>
|
|
|
|
|
+ <option value="square">квадрат</option>
|
|
|
|
|
+ <option value="noise">шум</option>
|
|
|
|
|
+ <option value="voice">голос</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="lab-panel" id="panel-mic">
|
|
|
|
|
+ <button class="lab-btn rec" id="recBtn">● ЗАПИСАТЬ</button>
|
|
|
|
|
+ <button class="lab-btn" id="playBtn" disabled>▶ ВОСПРОИЗВЕСТИ</button>
|
|
|
|
|
+ <span style="color:var(--cream); font-size:12px">Уровень: <span id="levelVal" class="value">0%</span></span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="lab-panel" id="panel-text">
|
|
|
|
|
+ <div style="width:100%; display:flex; gap:10px; flex-wrap:wrap">
|
|
|
|
|
+ <input type="text" id="textInput" placeholder="Напиши фразу и нажми Enter…" style="flex:1; min-width:200px">
|
|
|
|
|
+ <button class="lab-btn primary" id="speakInputBtn">▶ ОЗВУЧИТЬ</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="chip-row" style="width:100%">
|
|
|
|
|
+ <button class="chip" data-phrase="Звук — это колебания давления воздуха.">звук</button>
|
|
|
|
|
+ <button class="chip" data-phrase="Катушка движется в магнитном поле, и возникает ток.">индукция</button>
|
|
|
|
|
+ <button class="chip" data-phrase="Так микрофон превращает голос в электрический сигнал.">микрофон</button>
|
|
|
|
|
+ <button class="chip" data-phrase="Проверка, раз, два, три.">проверка</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</section>
|
|
|
|
|
+
|
|
|
|
|
+<footer>
|
|
|
|
|
+ <h3>ИТОГИ</h3>
|
|
|
|
|
+ <ul>
|
|
|
|
|
+ <li>Звук — это колебания давления воздуха, распространяющиеся как волна.</li>
|
|
|
|
|
+ <li>Диафрагма микрофона механически повторяет эти колебания.</li>
|
|
|
|
|
+ <li>Катушка, прикреплённая к диафрагме, движется в магнитном поле.</li>
|
|
|
|
|
+ <li>По закону Фарадея возникает ЭДС индукции — переменное напряжение.</li>
|
|
|
|
|
+ <li>Усилитель увеличивает слабый сигнал до пригодного для передачи.</li>
|
|
|
|
|
+ <li>Тот же принцип работает в телефонах, диктофонах, гидрофонах и слуховых аппаратах.</li>
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ <div class="sig">ACOUSTO-LAB.EXE · интерактивный проект · 2025</div>
|
|
|
|
|
+</footer>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+const C = {
|
|
|
|
|
+ amber:'#d4a860', gold:'#f0d890', terra:'#c87850',
|
|
|
|
|
+ khaki:'#b8c878', cream:'#e8d0a0', rust:'#b86848',
|
|
|
|
|
+ olive:'#8a9860', bg:'#1a1005'
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function clearScreen(ctx,W,H){ ctx.fillStyle=C.bg; ctx.fillRect(0,0,W,H); }
|
|
|
|
|
+function grid(ctx,W,H){
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle='rgba(212,168,96,.08)'; ctx.lineWidth=1;
|
|
|
|
|
+ for(let x=0;x<W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
|
|
|
|
|
+ for(let y=0;y<H;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+}
|
|
|
|
|
+function label(ctx,text,x,y,size,color,align='center'){
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.font=`bold ${size}px 'Courier New',monospace`;
|
|
|
|
|
+ ctx.textAlign=align; ctx.textBaseline='middle';
|
|
|
|
|
+ ctx.shadowColor=color; ctx.shadowBlur=8;
|
|
|
|
|
+ ctx.fillStyle=color; ctx.fillText(text,x,y);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+}
|
|
|
|
|
+function dimText(ctx,text,x,y,size,align='center'){
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.font=`${size}px 'Courier New',monospace`;
|
|
|
|
|
+ ctx.textAlign=align; ctx.textBaseline='middle';
|
|
|
|
|
+ ctx.fillStyle='rgba(232,208,160,.6)';
|
|
|
|
|
+ ctx.fillText(text,x,y);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+}
|
|
|
|
|
+function rr(ctx,x,y,w,h,r){
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(x+r,y);
|
|
|
|
|
+ ctx.arcTo(x+w,y,x+w,y+h,r);
|
|
|
|
|
+ ctx.arcTo(x+w,y+h,x,y+h,r);
|
|
|
|
|
+ ctx.arcTo(x,y+h,x,y,r);
|
|
|
|
|
+ ctx.arcTo(x,y,x+w,y,r);
|
|
|
|
|
+ ctx.closePath();
|
|
|
|
|
+}
|
|
|
|
|
+function waveSample(phase, type){
|
|
|
|
|
+ const p = phase - Math.floor(phase);
|
|
|
|
|
+ switch(type){
|
|
|
|
|
+ case 'sine': return Math.sin(p*Math.PI*2);
|
|
|
|
|
+ case 'saw': return 2*p - 1;
|
|
|
|
|
+ case 'square': return p < 0.5 ? 1 : -1;
|
|
|
|
|
+ case 'noise': return Math.random()*2 - 1;
|
|
|
|
|
+ case 'voice':
|
|
|
|
|
+ return Math.sin(p*Math.PI*2)*0.5
|
|
|
|
|
+ + Math.sin(p*Math.PI*4)*0.25
|
|
|
|
|
+ + Math.sin(p*Math.PI*6)*0.12
|
|
|
|
|
+ + (Math.random()*2-1)*0.1;
|
|
|
|
|
+ }
|
|
|
|
|
+ return 0;
|
|
|
|
|
+}
|
|
|
|
|
+function waveValue(x, xStart, xEnd, freq, t, speed){
|
|
|
|
|
+ const cycles = freq;
|
|
|
|
|
+ const span = xEnd - xStart;
|
|
|
|
|
+ const rel = (x - xStart) / span;
|
|
|
|
|
+ const phase = rel * cycles - t * speed;
|
|
|
|
|
+ return Math.sin(phase * Math.PI * 2);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const state = {
|
|
|
|
|
+ chain: { freq: 4, volume: 0.6 },
|
|
|
|
|
+ asm: { freq: 4, volume: 0.6, gain: 0.5 },
|
|
|
|
|
+ lab: { mode: 'imitation' },
|
|
|
|
|
+ speechEnergy: 0,
|
|
|
|
|
+ syllablePhase: 0,
|
|
|
|
|
+ imit: { freq: 140, amp: 0.6, wave: 'sine' },
|
|
|
|
|
+ magnetOn: true,
|
|
|
|
|
+ gain: 0.5,
|
|
|
|
|
+ noise: false,
|
|
|
|
|
+ noiseMix: 0,
|
|
|
|
|
+ micOn: false,
|
|
|
|
|
+ analyser: null,
|
|
|
|
|
+ timeData: null,
|
|
|
|
|
+ freqData: null,
|
|
|
|
|
+ micStream: null,
|
|
|
|
|
+ mediaRecorder: null,
|
|
|
|
|
+ recordedChunks: [],
|
|
|
|
|
+ recordedUrl: null,
|
|
|
|
|
+ audioCtx: null,
|
|
|
|
|
+ isFileProtocol: location.protocol === 'file:'
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+let ruVoice = null;
|
|
|
|
|
+let voiceOn = true;
|
|
|
|
|
+let currentlySpeaking = false;
|
|
|
|
|
+let speakToken = 0;
|
|
|
|
|
+let currentLabText = '';
|
|
|
|
|
+
|
|
|
|
|
+function pickVoice(){
|
|
|
|
|
+ if(!('speechSynthesis' in window)) return;
|
|
|
|
|
+ const list = speechSynthesis.getVoices();
|
|
|
|
|
+ if(!list.length) return;
|
|
|
|
|
+ ruVoice =
|
|
|
|
|
+ list.find(v=>/ru[-_]RU/i.test(v.lang) && /yuri|dmitri|pavel|male|мужск/i.test(v.name)) ||
|
|
|
|
|
+ list.find(v=>/ru[-_]RU/i.test(v.lang)) ||
|
|
|
|
|
+ list.find(v=>/^ru/i.test(v.lang)) || null;
|
|
|
|
|
+}
|
|
|
|
|
+if('speechSynthesis' in window){
|
|
|
|
|
+ pickVoice();
|
|
|
|
|
+ speechSynthesis.onvoiceschanged = pickVoice;
|
|
|
|
|
+}
|
|
|
|
|
+function setSpeaking(on){ currentlySpeaking = on; }
|
|
|
|
|
+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)){
|
|
|
|
|
+ setSpeaking(false);
|
|
|
|
|
+ if(onDone) setTimeout(onDone, 200);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try{
|
|
|
|
|
+ speechSynthesis.cancel();
|
|
|
|
|
+ const myToken = ++speakToken;
|
|
|
|
|
+ const sentences = splitSentences(text);
|
|
|
|
|
+ let i = 0;
|
|
|
|
|
+ const step = ()=>{
|
|
|
|
|
+ if(myToken !== speakToken){ setSpeaking(false); return; }
|
|
|
|
|
+ if(i >= sentences.length){ setSpeaking(false); if(onDone) onDone(); return; }
|
|
|
|
|
+ setSpeaking(true);
|
|
|
|
|
+ const u = new SpeechSynthesisUtterance(sentences[i]);
|
|
|
|
|
+ u.lang = 'ru-RU';
|
|
|
|
|
+ if(ruVoice) u.voice = ruVoice;
|
|
|
|
|
+ u.pitch = 0.75; u.rate = 0.9; u.volume = 1;
|
|
|
|
|
+ u.onend = ()=>{
|
|
|
|
|
+ if(myToken !== speakToken){ setSpeaking(false); return; }
|
|
|
|
|
+ setSpeaking(false);
|
|
|
|
|
+ i++;
|
|
|
|
|
+ setTimeout(()=>{
|
|
|
|
|
+ if(myToken !== speakToken){ setSpeaking(false); return; }
|
|
|
|
|
+ step();
|
|
|
|
|
+ }, 200);
|
|
|
|
|
+ };
|
|
|
|
|
+ u.onerror = ()=>{
|
|
|
|
|
+ if(myToken !== speakToken){ setSpeaking(false); return; }
|
|
|
|
|
+ setSpeaking(false);
|
|
|
|
|
+ i++;
|
|
|
|
|
+ setTimeout(()=>{
|
|
|
|
|
+ if(myToken !== speakToken){ setSpeaking(false); return; }
|
|
|
|
|
+ step();
|
|
|
|
|
+ }, 200);
|
|
|
|
|
+ };
|
|
|
|
|
+ speechSynthesis.speak(u);
|
|
|
|
|
+ };
|
|
|
|
|
+ step();
|
|
|
|
|
+ }catch(e){ setSpeaking(false); if(onDone) onDone(); }
|
|
|
|
|
+}
|
|
|
|
|
+function stopSpeech(){
|
|
|
|
|
+ speakToken++;
|
|
|
|
|
+ try{ speechSynthesis.cancel(); }catch(e){}
|
|
|
|
|
+ setSpeaking(false);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 resize(){
|
|
|
|
|
+ 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';
|
|
|
|
|
+ if(!visible) drawFn(ctx, BASE_W, BASE_H, 0, 0.016, mouse);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function loop(ts){
|
|
|
|
|
+ if(!visible) return;
|
|
|
|
|
+ if(!last) last = ts;
|
|
|
|
|
+ const dt = Math.min(.05, (ts - last)/1000);
|
|
|
|
|
+ last = ts;
|
|
|
|
|
+ time += dt;
|
|
|
|
|
+ try{ drawFn(ctx, BASE_W, BASE_H, time, dt, mouse); }catch(e){ console.error(e); }
|
|
|
|
|
+ rafId = requestAnimationFrame(loop);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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('mouseleave', ()=>{ mouse.inside = false; });
|
|
|
|
|
+ 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); });
|
|
|
|
|
+ cv.addEventListener('touchstart', e => {
|
|
|
|
|
+ if(e.touches.length){
|
|
|
|
|
+ const r = cv.getBoundingClientRect();
|
|
|
|
|
+ mouse.x = (e.touches[0].clientX - r.left) * (BASE_W / r.width);
|
|
|
|
|
+ mouse.y = (e.touches[0].clientY - r.top) * (BASE_H / r.height);
|
|
|
|
|
+ mouse.inside = true; mouse.down = true;
|
|
|
|
|
+ if(opts.onMouseDown) opts.onMouseDown(mouse, ctx, BASE_W, BASE_H);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, {passive:true});
|
|
|
|
|
+ cv.addEventListener('touchmove', e => {
|
|
|
|
|
+ if(e.touches.length){
|
|
|
|
|
+ const r = cv.getBoundingClientRect();
|
|
|
|
|
+ mouse.x = (e.touches[0].clientX - r.left) * (BASE_W / r.width);
|
|
|
|
|
+ mouse.y = (e.touches[0].clientY - r.top) * (BASE_H / r.height);
|
|
|
|
|
+ if(opts.onMouseMove) opts.onMouseMove(mouse, ctx, BASE_W, BASE_H);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, {passive:true});
|
|
|
|
|
+ cv.addEventListener('touchend', ()=>{
|
|
|
|
|
+ mouse.down = false;
|
|
|
|
|
+ if(opts.onMouseUp) opts.onMouseUp(mouse);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ 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 });
|
|
|
|
|
+ window.addEventListener('resize', resize);
|
|
|
|
|
+ resize();
|
|
|
|
|
+ observer.observe(cv);
|
|
|
|
|
+ return { cv, ctx, BASE_W, BASE_H, mouse, get visible(){ return visible; }, resize };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ ГЛАВА 1
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+const chainScene = makeScene('cv-chain', (ctx, W, H, t) => {
|
|
|
|
|
+ clearScreen(ctx, W, H); grid(ctx, W, H);
|
|
|
|
|
+
|
|
|
|
|
+ const cy = H / 2;
|
|
|
|
|
+ const speakerX = 100;
|
|
|
|
|
+ const membraneX = W - 110;
|
|
|
|
|
+
|
|
|
|
|
+ const freq = state.chain.freq;
|
|
|
|
|
+ const vol = state.chain.volume;
|
|
|
|
|
+ const amp = 6 + vol * 32;
|
|
|
|
|
+ const waveSpeed = 2.5;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.amber; ctx.lineWidth = 3;
|
|
|
|
|
+ ctx.shadowColor = C.amber; ctx.shadowBlur = 12;
|
|
|
|
|
+ ctx.fillStyle = 'rgba(212,168,96,.07)';
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(40, cy-60); ctx.lineTo(80, cy-60);
|
|
|
|
|
+ ctx.lineTo(115, cy-100);ctx.lineTo(115, cy+100);
|
|
|
|
|
+ ctx.lineTo(80, cy+60); ctx.lineTo(40, cy+60);
|
|
|
|
|
+ ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ const N = 60;
|
|
|
|
|
+ for(let i = 0; i < N; i++){
|
|
|
|
|
+ const u = i / (N - 1);
|
|
|
|
|
+ const baseX = speakerX + (membraneX - speakerX) * u;
|
|
|
|
|
+ const v = waveValue(baseX, speakerX, membraneX, freq, t, waveSpeed);
|
|
|
|
|
+ const disp = amp * v;
|
|
|
|
|
+ const x = baseX + disp;
|
|
|
|
|
+ const y = cy + Math.sin(i * 1.4) * 3;
|
|
|
|
|
+ const bright = (v + 1) / 2;
|
|
|
|
|
+ const g = Math.round(140 + bright * 100);
|
|
|
|
|
+ const col = `rgb(${Math.round(180 + bright*60)}, ${g}, ${Math.round(110 + bright*80)})`;
|
|
|
|
|
+ const radius = 2.5 + bright * 1.6;
|
|
|
|
|
+ ctx.fillStyle = col;
|
|
|
|
|
+ ctx.shadowColor = col;
|
|
|
|
|
+ ctx.shadowBlur = 6 + bright * 6;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.arc(x, y, radius, 0, Math.PI*2);
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.shadowBlur = 0;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(200,120,80,.35)';
|
|
|
|
|
+ ctx.lineWidth = 1.5;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ const arrowY = cy + 90;
|
|
|
|
|
+ ctx.moveTo(speakerX + 60, arrowY);
|
|
|
|
|
+ ctx.lineTo(membraneX - 60, arrowY);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(membraneX - 60, arrowY);
|
|
|
|
|
+ ctx.lineTo(membraneX - 72, arrowY - 6);
|
|
|
|
|
+ ctx.moveTo(membraneX - 60, arrowY);
|
|
|
|
|
+ ctx.lineTo(membraneX - 72, arrowY + 6);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ dimText(ctx, 'направление волны', (speakerX + membraneX) / 2, arrowY + 18, 10);
|
|
|
|
|
+
|
|
|
|
|
+ const memVal = waveValue(membraneX, speakerX, membraneX, freq, t, waveSpeed);
|
|
|
|
|
+ const bow = memVal * amp * 1.4;
|
|
|
|
|
+ const top = cy - 100;
|
|
|
|
|
+ const bot = cy + 100;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.amber; ctx.lineWidth = 4;
|
|
|
|
|
+ ctx.shadowColor = C.amber; ctx.shadowBlur = 16;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ const SEG = 44;
|
|
|
|
|
+ for(let i = 0; i <= SEG; i++){
|
|
|
|
|
+ const p = i / SEG;
|
|
|
|
|
+ const y = top + (bot - top) * 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();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(168,120,138,.55)';
|
|
|
|
|
+ ctx.lineWidth = 3;
|
|
|
|
|
+ ctx.beginPath(); ctx.moveTo(membraneX, top); ctx.lineTo(membraneX - 60, top - 30); ctx.stroke();
|
|
|
|
|
+ ctx.beginPath(); ctx.moveTo(membraneX, bot); ctx.lineTo(membraneX - 60, bot + 30); ctx.stroke();
|
|
|
|
|
+ ctx.beginPath(); ctx.moveTo(membraneX, top); ctx.lineTo(membraneX + 60, top - 30); ctx.stroke();
|
|
|
|
|
+ ctx.beginPath(); ctx.moveTo(membraneX, bot); ctx.lineTo(membraneX + 60, bot + 30); ctx.stroke();
|
|
|
|
|
+
|
|
|
|
|
+ label(ctx, 'ДИНАМИК', speakerX - 10, 30, 14, C.amber);
|
|
|
|
|
+ label(ctx, 'МОЛЕКУЛЫ ВОЗДУХА', (speakerX + membraneX) / 2, 30, 13, C.khaki);
|
|
|
|
|
+ label(ctx, 'МЕМБРАНА', membraneX, 30, 14, C.amber);
|
|
|
|
|
+ dimText(ctx, 'мембрана повторяет форму волны', membraneX, H - 22, 11);
|
|
|
|
|
+}, { height: 340 });
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ ГЛАВА 2
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+const coilState = { x: 0, v: 0, dragging: false, dragOffset: 0 };
|
|
|
|
|
+const coilScene = makeScene('cv-coil', (ctx, W, H, t, dt, mouse) => {
|
|
|
|
|
+ clearScreen(ctx, W, H); grid(ctx, W, H);
|
|
|
|
|
+ const cy = H/2 - 20;
|
|
|
|
|
+
|
|
|
|
|
+ if(coilState.dragging){
|
|
|
|
|
+ const targetX = Math.max(-80, Math.min(80, mouse.x - W/2 - coilState.dragOffset));
|
|
|
|
|
+ coilState.x += (targetX - coilState.x) * Math.min(1, dt * 16);
|
|
|
|
|
+ coilState.v = (targetX - coilState.x) * 0.2;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if(Math.abs(coilState.v) < 0.01 && Math.abs(coilState.x) < 0.5){
|
|
|
|
|
+ coilState.x = Math.sin(t*2.2) * 60;
|
|
|
|
|
+ coilState.v = Math.cos(t*2.2) * 60 * 2.2;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const k = 25, damp = 1.8;
|
|
|
|
|
+ coilState.v += (-k*coilState.x - damp*coilState.v) * dt;
|
|
|
|
|
+ coilState.x += coilState.v * dt;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const cur = Math.max(-1, Math.min(1, coilState.v * 0.08));
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle='rgba(184,104,72,.16)'; ctx.strokeStyle=C.rust; ctx.lineWidth=3;
|
|
|
|
|
+ ctx.shadowColor=C.rust; ctx.shadowBlur=12;
|
|
|
|
|
+ ctx.fillRect(150,cy-100,65,200); ctx.strokeRect(150,cy-100,65,200);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ label(ctx,'N',182,cy,42,C.rust);
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle='rgba(138,152,96,.16)'; ctx.strokeStyle=C.olive; ctx.lineWidth=3;
|
|
|
|
|
+ ctx.shadowColor=C.olive; ctx.shadowBlur=12;
|
|
|
|
|
+ ctx.fillRect(W-215,cy-100,65,200); ctx.strokeRect(W-215,cy-100,65,200);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ label(ctx,'S',W-182,cy,42,C.olive);
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle='rgba(212,168,96,.22)'; ctx.lineWidth=1.5;
|
|
|
|
|
+ for(let i=0;i<8;i++){
|
|
|
|
|
+ const y = cy-88+i*25;
|
|
|
|
|
+ ctx.beginPath(); ctx.moveTo(215,y); ctx.lineTo(W-215,y); ctx.stroke();
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ const cx = W/2 + coilState.x;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.gold; ctx.lineWidth = 3;
|
|
|
|
|
+ ctx.shadowColor = C.gold; ctx.shadowBlur = coilState.dragging ? 20 : 12;
|
|
|
|
|
+ for(let i=0;i<7;i++){
|
|
|
|
|
+ const lx = cx - 55 + i*(110/6);
|
|
|
|
|
+ ctx.beginPath(); ctx.ellipse(lx, cy, 8, 72, 0, 0, Math.PI*2); ctx.stroke();
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ const gx = W - 130, gy = cy + 130;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle='#1a1005'; ctx.strokeStyle='rgba(232,208,160,.3)'; ctx.lineWidth=2;
|
|
|
|
|
+ ctx.beginPath(); ctx.arc(gx,gy,38,0,Math.PI*2); ctx.fill(); ctx.stroke();
|
|
|
|
|
+ const needle = cur * 1.15;
|
|
|
|
|
+ ctx.strokeStyle = Math.abs(cur) > .02 ? C.khaki : '#5a5040';
|
|
|
|
|
+ ctx.shadowColor = C.khaki; ctx.shadowBlur = Math.abs(cur) * 16;
|
|
|
|
|
+ ctx.lineWidth = 3;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(gx, gy + 22);
|
|
|
|
|
+ ctx.lineTo(gx + Math.sin(needle)*32, gy + 22 - Math.cos(needle)*40);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ label(ctx,'КАТУШКА В МАГНИТНОМ ПОЛЕ', W/2, 30, 17, C.amber);
|
|
|
|
|
+ dimText(ctx,'тяни катушку мышью — отпусти, чтобы качнулась', W/2, 52, 11);
|
|
|
|
|
+
|
|
|
|
|
+ if(mouse.inside && Math.abs(mouse.x - cx) < 90 && Math.abs(mouse.y - cy) < 100){
|
|
|
|
|
+ ctx.canvas.style.cursor = coilState.dragging ? 'grabbing' : 'grab';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ctx.canvas.style.cursor = 'default';
|
|
|
|
|
+ }
|
|
|
|
|
+}, {
|
|
|
|
|
+ height: 360,
|
|
|
|
|
+ onMouseDown: (mouse)=>{
|
|
|
|
|
+ const cx = 900/2 + coilState.x;
|
|
|
|
|
+ if(Math.abs(mouse.x - cx) < 90 && Math.abs(mouse.y - (360/2 - 20)) < 100){
|
|
|
|
|
+ coilState.dragging = true;
|
|
|
|
|
+ coilState.dragOffset = mouse.x - (900/2 + coilState.x);
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onMouseUp: ()=>{ coilState.dragging = false; }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ ГЛАВА 3
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+const faradayScene = makeScene('cv-faraday', (ctx, W, H, t, dt, mouse) => {
|
|
|
|
|
+ clearScreen(ctx, W, H); grid(ctx, W, H);
|
|
|
|
|
+ const cy = H * 0.32;
|
|
|
|
|
+ const on = state.magnetOn;
|
|
|
|
|
+ const coilX = on ? Math.sin(t*2.2) * 60 : 0;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle = on ? 'rgba(184,104,72,.14)' : 'rgba(184,104,72,.05)';
|
|
|
|
|
+ ctx.strokeStyle = on ? C.rust : 'rgba(184,104,72,.3)';
|
|
|
|
|
+ ctx.lineWidth=3;
|
|
|
|
|
+ ctx.shadowColor=C.rust; ctx.shadowBlur = on ? 10 : 0;
|
|
|
|
|
+ ctx.fillRect(160,cy-60,50,120); ctx.strokeRect(160,cy-60,50,120);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ label(ctx,'N',185,cy,26, on ? C.rust : 'rgba(184,104,72,.4)');
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle = on ? 'rgba(138,152,96,.14)' : 'rgba(138,152,96,.05)';
|
|
|
|
|
+ ctx.strokeStyle = on ? C.olive : 'rgba(138,152,96,.3)';
|
|
|
|
|
+ ctx.lineWidth=3;
|
|
|
|
|
+ ctx.shadowColor=C.olive; ctx.shadowBlur = on ? 10 : 0;
|
|
|
|
|
+ ctx.fillRect(W-210,cy-60,50,120); ctx.strokeRect(W-210,cy-60,50,120);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ label(ctx,'S',W-185,cy,26, on ? C.olive : 'rgba(138,152,96,.4)');
|
|
|
|
|
+
|
|
|
|
|
+ const cx = W/2 + coilX;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle=C.gold; ctx.lineWidth=3;
|
|
|
|
|
+ ctx.shadowColor=C.gold; ctx.shadowBlur=10;
|
|
|
|
|
+ for(let i=0;i<6;i++){
|
|
|
|
|
+ const lx = cx - 45 + i*(90/5);
|
|
|
|
|
+ ctx.beginPath(); ctx.ellipse(lx, cy, 7, 46, 0, 0, Math.PI*2); ctx.stroke();
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ const gTop = H*.62, gBot = H - 40, gMid = (gTop+gBot)/2;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle='rgba(168,120,138,.28)'; ctx.lineWidth=1.5;
|
|
|
|
|
+ ctx.strokeRect(80, gTop, W-160, gBot-gTop);
|
|
|
|
|
+ ctx.beginPath(); ctx.moveTo(80,gMid); ctx.lineTo(W-80,gMid); ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = on ? C.khaki : 'rgba(184,200,120,.25)';
|
|
|
|
|
+ ctx.lineWidth = 2.4;
|
|
|
|
|
+ ctx.shadowColor = C.khaki; ctx.shadowBlur = on ? 10 : 0;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ for(let x=0;x<=W-160;x++){
|
|
|
|
|
+ const u = x/(W-160);
|
|
|
|
|
+ const amp = on ? 40 : 3;
|
|
|
|
|
+ const y = gMid - Math.cos((t*2.2 + u*3.6*Math.PI))*amp;
|
|
|
|
|
+ if(x===0) ctx.moveTo(80+x, y); else ctx.lineTo(80+x, y);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ dimText(ctx,'ЭДС индукции', 92, gTop+16, 11, 'left');
|
|
|
|
|
+ dimText(ctx,'время →', W-92, gBot-8, 11, 'right');
|
|
|
|
|
+ label(ctx,'ε = − dΦ / dt', W-150, gTop+20, 18, C.terra);
|
|
|
|
|
+ label(ctx,'ЗАКОН ЭЛЕКТРОМАГНИТНОЙ ИНДУКЦИИ', W/2, 30, 17, C.amber);
|
|
|
|
|
+
|
|
|
|
|
+ const btnW = 180, btnH = 36;
|
|
|
|
|
+ const bx = W/2 - btnW/2, by = H - 66;
|
|
|
|
|
+ const hover = mouse.inside && mouse.x > bx && mouse.x < bx+btnW && mouse.y > by && mouse.y < by+btnH;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle = on ? C.rust : 'rgba(184,104,72,.4)';
|
|
|
|
|
+ if(hover) ctx.fillStyle = on ? '#d88858' : 'rgba(184,104,72,.55)';
|
|
|
|
|
+ rr(ctx, bx, by, btnW, btnH, 4);
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+ ctx.strokeStyle = '#1a1005'; ctx.lineWidth = 2; ctx.stroke();
|
|
|
|
|
+ ctx.fillStyle = '#f0f8e0';
|
|
|
|
|
+ ctx.font = "bold 13px 'Courier New',monospace";
|
|
|
|
|
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
|
|
|
+ ctx.fillText(on ? 'МАГНИТ: ВКЛ' : 'МАГНИТ: ВЫКЛ', W/2, by + btnH/2);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ ctx.canvas.style.cursor = hover ? 'pointer' : 'default';
|
|
|
|
|
+}, {
|
|
|
|
|
+ height: 340,
|
|
|
|
|
+ onMouseDown: (mouse)=>{
|
|
|
|
|
+ const btnW = 180, btnH = 36;
|
|
|
|
|
+ const bx = 900/2 - btnW/2, by = 340 - 66;
|
|
|
|
|
+ if(mouse.x > bx && mouse.x < bx+btnW && mouse.y > by && mouse.y < by+btnH){
|
|
|
|
|
+ state.magnetOn = !state.magnetOn;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ ГЛАВА 4
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+const signalScene = makeScene('cv-signal', (ctx, W, H, t, dt, mouse) => {
|
|
|
|
|
+ clearScreen(ctx, W, H); grid(ctx, W, H);
|
|
|
|
|
+ const cy = H/2 - 10;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle='rgba(212,168,96,.35)'; ctx.lineWidth=1.5;
|
|
|
|
|
+ ctx.beginPath(); ctx.moveTo(50,cy); ctx.lineTo(W-50,cy); ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ const targetMix = state.noise ? 1 : 0;
|
|
|
|
|
+ state.noiseMix += (targetMix - state.noiseMix) * 0.1;
|
|
|
|
|
+ const gain = 20 + state.gain * 100;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.khaki; ctx.lineWidth = 2.4;
|
|
|
|
|
+ ctx.shadowColor = C.khaki; ctx.shadowBlur = 12;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ for(let x=50;x<=W-50;x++){
|
|
|
|
|
+ const u = (x-50)/(W-100);
|
|
|
|
|
+ const env = Math.exp(-Math.pow((u-.5)*2.4, 2));
|
|
|
|
|
+ let v = env*(
|
|
|
|
|
+ Math.sin(u*32 - t*9)*.55 +
|
|
|
|
|
+ Math.sin(u*74 - t*14)*.28 +
|
|
|
|
|
+ Math.sin(u*14 - t*5)*.45);
|
|
|
|
|
+ v += (Math.random()-0.5) * 0.8 * state.noiseMix;
|
|
|
|
|
+ const y = cy - gain * v;
|
|
|
|
|
+ if(x===50) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ label(ctx,'ЭЛЕКТРИЧЕСКИЙ СИГНАЛ', W/2, 30, 18, C.amber);
|
|
|
|
|
+ dimText(ctx, '~ милливольты · точная копия голоса', W/2, 52, 11);
|
|
|
|
|
+
|
|
|
|
|
+ const sbX = 100, sbY = H - 55, sbW = 280, sbH = 8;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle = 'rgba(212,168,96,.15)';
|
|
|
|
|
+ ctx.fillRect(sbX, sbY, sbW, sbH);
|
|
|
|
|
+ ctx.fillStyle = C.khaki;
|
|
|
|
|
+ ctx.fillRect(sbX, sbY, sbW * state.gain, sbH);
|
|
|
|
|
+ const hx = sbX + sbW * state.gain;
|
|
|
|
|
+ ctx.fillStyle = C.gold;
|
|
|
|
|
+ ctx.beginPath(); ctx.arc(hx, sbY + sbH/2, 9, 0, Math.PI*2); ctx.fill();
|
|
|
|
|
+ ctx.strokeStyle = '#1a1005'; ctx.lineWidth = 2; ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ dimText(ctx, 'усиление', sbX + sbW/2, sbY + 26, 11);
|
|
|
|
|
+
|
|
|
|
|
+ const nbtnW = 100, nbtnH = 32;
|
|
|
|
|
+ const nbx = W - 200, nby = H - 66;
|
|
|
|
|
+ const hoverN = mouse.inside && mouse.x > nbx && mouse.x < nbx+nbtnW && mouse.y > nby && mouse.y < nby+nbtnH;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle = state.noise ? C.terra : 'rgba(212,168,96,.15)';
|
|
|
|
|
+ if(hoverN) ctx.fillStyle = state.noise ? '#d88858' : 'rgba(212,168,96,.28)';
|
|
|
|
|
+ rr(ctx, nbx, nby, nbtnW, nbtnH, 4);
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+ ctx.strokeStyle = '#1a1005'; ctx.lineWidth = 2; ctx.stroke();
|
|
|
|
|
+ ctx.fillStyle = state.noise ? '#f0f8e0' : C.amber;
|
|
|
|
|
+ ctx.font = "bold 12px 'Courier New',monospace";
|
|
|
|
|
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
|
|
|
+ ctx.fillText('ШУМ: ' + (state.noise ? 'ВКЛ' : 'ВЫКЛ'), nbx + nbtnW/2, nby + nbtnH/2);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ const overSlider = mouse.inside && mouse.y > sbY - 14 && mouse.y < sbY + 20;
|
|
|
|
|
+ if(overSlider){
|
|
|
|
|
+ ctx.canvas.style.cursor = 'ew-resize';
|
|
|
|
|
+ if(mouse.down){
|
|
|
|
|
+ state.gain = Math.max(0, Math.min(1, (mouse.x - sbX) / sbW));
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if(hoverN){
|
|
|
|
|
+ ctx.canvas.style.cursor = 'pointer';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ctx.canvas.style.cursor = 'default';
|
|
|
|
|
+ }
|
|
|
|
|
+}, {
|
|
|
|
|
+ height: 280,
|
|
|
|
|
+ onMouseDown: (mouse)=>{
|
|
|
|
|
+ const nbtnW = 100, nbtnH = 32;
|
|
|
|
|
+ const nbx = 900 - 200, nby = 280 - 66;
|
|
|
|
|
+ if(mouse.x > nbx && mouse.x < nbx+nbtnW && mouse.y > nby && mouse.y < nby+nbtnH){
|
|
|
|
|
+ state.noise = !state.noise;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ ГЛАВА 5 — магниты разнесены, катушка движется синхронно с мембраной
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+const assemblyScene = makeScene('cv-assembly', (ctx, W, H, t) => {
|
|
|
|
|
+ clearScreen(ctx, W, H); grid(ctx, W, H);
|
|
|
|
|
+
|
|
|
|
|
+ const freq = state.asm.freq;
|
|
|
|
|
+ const vol = state.asm.volume;
|
|
|
|
|
+ const gain = state.asm.gain;
|
|
|
|
|
+ const amp = 5 + vol * 18;
|
|
|
|
|
+ const waveSpeed = 2.5;
|
|
|
|
|
+
|
|
|
|
|
+ // ============ ВЕРХНИЙ УРОВЕНЬ: АКУСТИКА ============
|
|
|
|
|
+ const topCy = H * 0.30;
|
|
|
|
|
+
|
|
|
|
|
+ const speakerX = 70;
|
|
|
|
|
+ const memX = 260;
|
|
|
|
|
+ const magX1 = 340;
|
|
|
|
|
+ const magX2 = 560;
|
|
|
|
|
+ const coilX = (magX1 + magX2) / 2;
|
|
|
|
|
+ const magW = 28; // ширина полюсного блока
|
|
|
|
|
+ const magH = 130; // высота полюсного блока
|
|
|
|
|
+
|
|
|
|
|
+ // Динамик
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.amber; ctx.lineWidth = 2.5;
|
|
|
|
|
+ ctx.shadowColor = C.amber; ctx.shadowBlur = 8;
|
|
|
|
|
+ ctx.fillStyle = 'rgba(212,168,96,.07)';
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(30, topCy-35); ctx.lineTo(55, topCy-35);
|
|
|
|
|
+ ctx.lineTo(75, topCy-55); ctx.lineTo(75, topCy+55);
|
|
|
|
|
+ ctx.lineTo(55, topCy+35); ctx.lineTo(30, topCy+35);
|
|
|
|
|
+ ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ // Молекулы
|
|
|
|
|
+ const N = 20;
|
|
|
|
|
+ for(let i = 0; i < N; i++){
|
|
|
|
|
+ const u = i / (N - 1);
|
|
|
|
|
+ const baseX = speakerX + (memX - speakerX) * u;
|
|
|
|
|
+ const v = waveValue(baseX, speakerX, memX, freq, t, waveSpeed);
|
|
|
|
|
+ const disp = amp * v;
|
|
|
|
|
+ const x = baseX + disp;
|
|
|
|
|
+ const y = topCy + Math.sin(i * 1.5) * 2;
|
|
|
|
|
+ const bright = (v + 1) / 2;
|
|
|
|
|
+ const col = `rgb(${Math.round(180 + bright*60)}, ${Math.round(140 + bright*100)}, ${Math.round(110 + bright*80)})`;
|
|
|
|
|
+ ctx.fillStyle = col;
|
|
|
|
|
+ ctx.shadowColor = col;
|
|
|
|
|
+ ctx.shadowBlur = 5 + bright * 5;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.arc(x, y, 2 + bright * 1.5, 0, Math.PI*2);
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.shadowBlur = 0;
|
|
|
|
|
+
|
|
|
|
|
+ // Мембрана
|
|
|
|
|
+ const memVal = waveValue(memX, speakerX, memX, freq, t, waveSpeed);
|
|
|
|
|
+ const bow = memVal * amp * 1.2;
|
|
|
|
|
+ const mTop = topCy - 40, mBot = topCy + 40;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.amber; ctx.lineWidth = 3;
|
|
|
|
|
+ ctx.shadowColor = C.amber; ctx.shadowBlur = 10;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ const SEG = 24;
|
|
|
|
|
+ for(let i=0;i<=SEG;i++){
|
|
|
|
|
+ const p = i/SEG;
|
|
|
|
|
+ const y = mTop + (mBot-mTop)*p;
|
|
|
|
|
+ const shape = Math.sin(Math.PI*p);
|
|
|
|
|
+ const x = memX + bow*shape;
|
|
|
|
|
+ if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ // Шток
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(200,120,80,.55)';
|
|
|
|
|
+ ctx.lineWidth = 2;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(memX + bow, topCy);
|
|
|
|
|
+ ctx.lineTo(coilX - 26, topCy);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ // Магнитное поле — силовые линии между полюсами
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ for(let i=0;i<8;i++){
|
|
|
|
|
+ const y = topCy - magH/2 + 12 + i * ((magH - 24) / 7);
|
|
|
|
|
+ // Линия
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(138,180,220,.5)';
|
|
|
|
|
+ ctx.lineWidth = 1.4;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(magX1 + magW, y);
|
|
|
|
|
+ ctx.lineTo(magX2 - magW, y);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+
|
|
|
|
|
+ // Бегущая стрелка
|
|
|
|
|
+ const arrowX = magX1 + magW + ((t * 55 + i * 22) % (magX2 - magX1 - magW * 2));
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(138,180,220,.9)';
|
|
|
|
|
+ ctx.lineWidth = 1.6;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(arrowX, y);
|
|
|
|
|
+ ctx.lineTo(arrowX - 7, y - 4);
|
|
|
|
|
+ ctx.moveTo(arrowX, y);
|
|
|
|
|
+ ctx.lineTo(arrowX - 7, y + 4);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ // Полюс N
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle='rgba(184,104,72,.24)'; ctx.strokeStyle=C.rust; ctx.lineWidth=2.4;
|
|
|
|
|
+ ctx.shadowColor=C.rust; ctx.shadowBlur=10;
|
|
|
|
|
+ ctx.fillRect(magX1, topCy-magH/2, magW, magH);
|
|
|
|
|
+ ctx.strokeRect(magX1, topCy-magH/2, magW, magH);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ label(ctx,'N', magX1 + magW/2, topCy, 18, C.rust);
|
|
|
|
|
+
|
|
|
|
|
+ // Полюс S
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle='rgba(138,152,96,.24)'; ctx.strokeStyle=C.olive; ctx.lineWidth=2.4;
|
|
|
|
|
+ ctx.shadowColor=C.olive; ctx.shadowBlur=10;
|
|
|
|
|
+ ctx.fillRect(magX2 - magW, topCy-magH/2, magW, magH);
|
|
|
|
|
+ ctx.strokeRect(magX2 - magW, topCy-magH/2, magW, magH);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ label(ctx,'S', magX2 - magW/2, topCy, 18, C.olive);
|
|
|
|
|
+
|
|
|
|
|
+ // Катушка — движение ровно как у мембраны, без ускорения
|
|
|
|
|
+ // ограничение, чтобы не выходила за пределы поля
|
|
|
|
|
+ const maxMotion = (magX2 - magX1 - magW * 2) / 2 - 30;
|
|
|
|
|
+ const coilMotion = Math.max(-maxMotion, Math.min(maxMotion, bow));
|
|
|
|
|
+ const coilCx = coilX + coilMotion;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.gold; ctx.lineWidth = 2.6;
|
|
|
|
|
+ ctx.shadowColor = C.gold; ctx.shadowBlur = 14;
|
|
|
|
|
+ for(let i=0;i<5;i++){
|
|
|
|
|
+ const lx = coilCx - 20 + i * (40/4);
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.ellipse(lx, topCy, 5, 42, 0, 0, Math.PI*2);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ // Подписи верхнего ряда
|
|
|
|
|
+ dimText(ctx, 'ДИНАМИК', speakerX, topCy + 80, 12);
|
|
|
|
|
+ dimText(ctx, 'ВОЛНА', (speakerX + memX) / 2, topCy + 80, 12);
|
|
|
|
|
+ dimText(ctx, 'МЕМБРАНА', memX, topCy + 80, 12);
|
|
|
|
|
+ dimText(ctx, 'КАТУШКА В МАГНИТЕ', coilX, topCy + 80, 12);
|
|
|
|
|
+
|
|
|
|
|
+ // ============ СТРЕЛКА ВНИЗ ============
|
|
|
|
|
+ const arrowTop = topCy + 96;
|
|
|
|
|
+ const arrowBottom = H * 0.63;
|
|
|
|
|
+ const arrowX = coilX;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.terra;
|
|
|
|
|
+ ctx.lineWidth = 2.4;
|
|
|
|
|
+ ctx.shadowColor = C.terra; ctx.shadowBlur = 10;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(arrowX, arrowTop);
|
|
|
|
|
+ ctx.lineTo(arrowX, arrowBottom - 14);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(arrowX, arrowBottom);
|
|
|
|
|
+ ctx.lineTo(arrowX - 9, arrowBottom - 14);
|
|
|
|
|
+ ctx.moveTo(arrowX, arrowBottom);
|
|
|
|
|
+ ctx.lineTo(arrowX + 9, arrowBottom - 14);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ label(ctx, 'ЭДС', arrowX + 34, (arrowTop + arrowBottom)/2, 13, C.terra);
|
|
|
|
|
+
|
|
|
|
|
+ // ============ НИЖНИЙ УРОВЕНЬ: ЭЛЕКТРИКА ============
|
|
|
|
|
+ const botCy = H * 0.83;
|
|
|
|
|
+
|
|
|
|
|
+ const g1X = 60, g1W = 220, g1H = 90;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(168,120,138,.4)';
|
|
|
|
|
+ ctx.lineWidth = 1;
|
|
|
|
|
+ ctx.strokeRect(g1X, botCy - g1H/2, g1W, g1H);
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(g1X, botCy);
|
|
|
|
|
+ ctx.lineTo(g1X + g1W, botCy);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.khaki;
|
|
|
|
|
+ ctx.lineWidth = 1.8;
|
|
|
|
|
+ ctx.shadowColor = C.khaki; ctx.shadowBlur = 6;
|
|
|
|
|
+ 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.restore();
|
|
|
|
|
+
|
|
|
|
|
+ dimText(ctx, 'ЭДС с катушки', g1X + g1W/2, botCy - g1H/2 - 14, 12);
|
|
|
|
|
+
|
|
|
|
|
+ const ampX = 350, ampY = botCy;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.terra;
|
|
|
|
|
+ ctx.lineWidth = 2.8;
|
|
|
|
|
+ ctx.fillStyle = 'rgba(200,120,80,.15)';
|
|
|
|
|
+ ctx.shadowColor = C.terra; ctx.shadowBlur = 14;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(ampX - 26, ampY - 34);
|
|
|
|
|
+ ctx.lineTo(ampX + 26, ampY);
|
|
|
|
|
+ ctx.lineTo(ampX - 26, ampY + 34);
|
|
|
|
|
+ ctx.closePath();
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ label(ctx, 'УСИЛ', ampX - 6, ampY, 14, C.terra);
|
|
|
|
|
+ dimText(ctx, '×' + (0.2 + gain * 1.5).toFixed(1), ampX, ampY + 50, 13);
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.terra;
|
|
|
|
|
+ ctx.lineWidth = 2.2;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(g1X + g1W + 8, botCy);
|
|
|
|
|
+ ctx.lineTo(ampX - 30, botCy);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(ampX - 30, botCy);
|
|
|
|
|
+ ctx.lineTo(ampX - 42, botCy - 6);
|
|
|
|
|
+ ctx.moveTo(ampX - 30, botCy);
|
|
|
|
|
+ ctx.lineTo(ampX - 42, botCy + 6);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ const g2X = 410, g2W = W - 410 - 60, g2H = 110;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.terra;
|
|
|
|
|
+ ctx.lineWidth = 2.2;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(ampX + 30, botCy);
|
|
|
|
|
+ ctx.lineTo(g2X - 8, botCy);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(g2X - 8, botCy);
|
|
|
|
|
+ ctx.lineTo(g2X - 20, botCy - 6);
|
|
|
|
|
+ ctx.moveTo(g2X - 8, botCy);
|
|
|
|
|
+ ctx.lineTo(g2X - 20, botCy + 6);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ const gainMul = 0.2 + gain * 1.5;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(168,120,138,.4)';
|
|
|
|
|
+ ctx.lineWidth = 1;
|
|
|
|
|
+ ctx.strokeRect(g2X, botCy - g2H/2, g2W, g2H);
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(g2X, botCy);
|
|
|
|
|
+ ctx.lineTo(g2X + g2W, botCy);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.gold;
|
|
|
|
|
+ ctx.lineWidth = 2.6;
|
|
|
|
|
+ ctx.shadowColor = C.gold; ctx.shadowBlur = 14;
|
|
|
|
|
+ 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();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ dimText(ctx, 'ВЫХОДНОЙ СИГНАЛ', g2X + g2W/2, botCy - g2H/2 - 14, 12);
|
|
|
|
|
+ dimText(ctx, '×' + gainMul.toFixed(1), g2X + g2W/2, botCy + g2H/2 + 16, 12);
|
|
|
|
|
+
|
|
|
|
|
+ // Заголовок
|
|
|
|
|
+ label(ctx, 'ПОЛНАЯ СБОРКА', W/2, 22, 15, C.amber);
|
|
|
|
|
+ dimText(ctx, 'сверху — акустика, снизу — электрика', W/2, 42, 11);
|
|
|
|
|
+}, { height: 380 });
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ ГЛАВА 6: ЛАБОРАТОРИЯ
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+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 });
|
|
|
|
|
+spectroCtx.fillStyle = '#0a0603';
|
|
|
|
|
+spectroCtx.fillRect(0, 0, SPEC_W, SPEC_H);
|
|
|
|
|
+let spectroAccum = 0;
|
|
|
|
|
+const SPECTRO_INTERVAL = 1/30;
|
|
|
|
|
+
|
|
|
|
|
+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];
|
|
|
|
|
+ return `rgb(${Math.round(a[0]+(b[0]-a[0])*t)},${Math.round(a[1]+(b[1]-a[1])*t)},${Math.round(a[2]+(b[2]-a[2])*t)})`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return 'rgb(255,248,224)';
|
|
|
|
|
+}
|
|
|
|
|
+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 wavePhase = 0;
|
|
|
|
|
+
|
|
|
|
|
+function drawLabScheme(ctx, W, t, sig){
|
|
|
|
|
+ const cy = 145;
|
|
|
|
|
+ const amp = 4 + sig.amp * 22;
|
|
|
|
|
+ const waveSpeed = 2.5;
|
|
|
|
|
+
|
|
|
|
|
+ const speakerX = 70;
|
|
|
|
|
+ const memX = 360;
|
|
|
|
|
+ const magX1 = 400;
|
|
|
|
|
+ const magX2 = 500;
|
|
|
|
|
+ const coilX = (magX1 + magX2) / 2;
|
|
|
|
|
+ const graphX = 560;
|
|
|
|
|
+ const graphW = 280;
|
|
|
|
|
+
|
|
|
|
|
+ const syllable = (i)=> 0.55 + 0.45 * Math.abs(Math.sin(state.syllablePhase + i * 0.5));
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.amber; ctx.lineWidth = 2;
|
|
|
|
|
+ ctx.shadowColor = C.amber; ctx.shadowBlur = sig.energy * 12 + 3;
|
|
|
|
|
+ ctx.fillStyle = 'rgba(212,168,96,.07)';
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(30, cy-35); ctx.lineTo(55, cy-35);
|
|
|
|
|
+ ctx.lineTo(75, cy-55); ctx.lineTo(75, cy+55);
|
|
|
|
|
+ ctx.lineTo(55, cy+35); ctx.lineTo(30, cy+35);
|
|
|
|
|
+ ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ const N = 26;
|
|
|
|
|
+ for(let i = 0; i < N; i++){
|
|
|
|
|
+ const u = i / (N - 1);
|
|
|
|
|
+ const baseX = speakerX + (memX - speakerX) * u;
|
|
|
|
|
+ const v = waveValue(baseX, speakerX, memX, 4, t, waveSpeed);
|
|
|
|
|
+ const disp = amp * v * syllable(i);
|
|
|
|
|
+ const x = baseX + disp;
|
|
|
|
|
+ const y = cy + Math.sin(i * 1.5) * 2;
|
|
|
|
|
+ const bright = (v + 1) / 2 * (0.25 + 0.75 * sig.energy);
|
|
|
|
|
+ ctx.fillStyle = `rgb(${Math.round(110 + bright*120)}, ${Math.round(110 + bright*110)}, ${Math.round(80 + bright*80)})`;
|
|
|
|
|
+ ctx.shadowColor = 'rgba(200,160,80,' + bright + ')';
|
|
|
|
|
+ ctx.shadowBlur = 4 * bright;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.arc(x, y, 1.5 + bright * 1.5, 0, Math.PI*2);
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.shadowBlur = 0;
|
|
|
|
|
+
|
|
|
|
|
+ const memVal = waveValue(memX, speakerX, memX, 4, t, waveSpeed);
|
|
|
|
|
+ const bow = memVal * amp * 1.2;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.amber; ctx.lineWidth = 2.5;
|
|
|
|
|
+ ctx.shadowColor = C.amber; ctx.shadowBlur = sig.energy * 14 + 3;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ const SEG = 24;
|
|
|
|
|
+ for(let i=0;i<=SEG;i++){
|
|
|
|
|
+ const p = i/SEG;
|
|
|
|
|
+ const y = cy - 45 + 90*p;
|
|
|
|
|
+ const shape = Math.sin(Math.PI*p);
|
|
|
|
|
+ const x = memX + bow*shape;
|
|
|
|
|
+ if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(200,120,80,.5)';
|
|
|
|
|
+ ctx.lineWidth = 1.5;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(memX + bow, cy);
|
|
|
|
|
+ ctx.lineTo(coilX - 22, cy);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle='rgba(184,104,72,.16)'; ctx.strokeStyle=C.rust; ctx.lineWidth=1.5;
|
|
|
|
|
+ ctx.fillRect(magX1, cy-50, 20, 100);
|
|
|
|
|
+ ctx.strokeRect(magX1, cy-50, 20, 100);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ label(ctx,'N', magX1+10, cy, 14, C.rust);
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle='rgba(138,152,96,.16)'; ctx.strokeStyle=C.olive; ctx.lineWidth=1.5;
|
|
|
|
|
+ ctx.fillRect(magX2-20, cy-50, 20, 100);
|
|
|
|
|
+ ctx.strokeRect(magX2-20, cy-50, 20, 100);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ label(ctx,'S', magX2-10, cy, 14, C.olive);
|
|
|
|
|
+
|
|
|
|
|
+ const coilCx = coilX + bow * 0.6;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.gold; ctx.lineWidth = 2;
|
|
|
|
|
+ ctx.shadowColor = C.gold; ctx.shadowBlur = sig.energy * 10 + 3;
|
|
|
|
|
+ for(let i=0;i<4;i++){
|
|
|
|
|
+ const lx = coilCx - 18 + i * (36/3);
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.ellipse(lx, cy, 4, 40, 0, 0, Math.PI*2);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(168,120,138,.3)';
|
|
|
|
|
+ ctx.lineWidth = 1;
|
|
|
|
|
+ ctx.strokeRect(graphX, cy - 45, graphW, 90);
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(graphX, cy);
|
|
|
|
|
+ ctx.lineTo(graphX + graphW, cy);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = C.khaki;
|
|
|
|
|
+ ctx.lineWidth = 1.8;
|
|
|
|
|
+ ctx.shadowColor = C.khaki; ctx.shadowBlur = sig.energy > 0.05 ? 8 : 0;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ for(let i=0;i<=graphW;i++){
|
|
|
|
|
+ const u = i/graphW;
|
|
|
|
|
+ const syllableMod = 0.6 + 0.4 * Math.sin(state.syllablePhase + u * 6);
|
|
|
|
|
+ const y = cy - Math.cos((t*2.2 + u*3.6*Math.PI)) * (3 + sig.amp * 30) * syllableMod;
|
|
|
|
|
+ if(i===0) ctx.moveTo(graphX + i, y); else ctx.lineTo(graphX + i, y);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ dimText(ctx, 'ДИНАМИК', speakerX, cy + 78, 10);
|
|
|
|
|
+ dimText(ctx, 'ВОЛНА', (speakerX + memX) / 2, cy + 78, 10);
|
|
|
|
|
+ dimText(ctx, 'МЕМБРАНА', memX, cy + 78, 10);
|
|
|
|
|
+ dimText(ctx, 'КАТУШКА', coilX, cy + 78, 10);
|
|
|
|
|
+ dimText(ctx, 'ЭДС', graphX + graphW/2, cy + 78, 10);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const labScene = makeScene('cv-lab', (ctx, W, H, t, dt) => {
|
|
|
|
|
+ clearScreen(ctx, W, H); grid(ctx, W, H);
|
|
|
|
|
+ const mode = state.lab.mode;
|
|
|
|
|
+
|
|
|
|
|
+ let sig = { amp:0, energy:0 };
|
|
|
|
|
+
|
|
|
|
|
+ if(mode === 'mic' && state.micOn && state.analyser && state.timeData){
|
|
|
|
|
+ state.analyser.getByteTimeDomainData(state.timeData);
|
|
|
|
|
+ let sum = 0;
|
|
|
|
|
+ for(let i=0;i<state.timeData.length;i++){
|
|
|
|
|
+ const d = (state.timeData[i]-128)/128;
|
|
|
|
|
+ sum += d*d;
|
|
|
|
|
+ }
|
|
|
|
|
+ const rms = Math.sqrt(sum / state.timeData.length);
|
|
|
|
|
+ sig = {
|
|
|
|
|
+ amp: Math.min(1, rms * 3.5),
|
|
|
|
|
+ energy: Math.min(1, rms * 4)
|
|
|
|
|
+ };
|
|
|
|
|
+ } else if(mode === 'imitation'){
|
|
|
|
|
+ sig = {
|
|
|
|
|
+ amp: state.imit.amp * 0.8,
|
|
|
|
|
+ energy: state.imit.amp * 0.9
|
|
|
|
|
+ };
|
|
|
|
|
+ } else if(mode === 'text'){
|
|
|
|
|
+ const targetEnergy = currentlySpeaking ? 1 : 0;
|
|
|
|
|
+ state.speechEnergy += (targetEnergy - state.speechEnergy) * (targetEnergy ? 0.08 : 0.18);
|
|
|
|
|
+ sig = {
|
|
|
|
|
+ amp: state.speechEnergy * 0.8,
|
|
|
|
|
+ energy: state.speechEnergy
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ state.syllablePhase += dt * 8;
|
|
|
|
|
+
|
|
|
|
|
+ let title = '', sub = '';
|
|
|
|
|
+ if(mode === 'imitation'){
|
|
|
|
|
+ title = 'ГЕНЕРАТОР СИГНАЛОВ';
|
|
|
|
|
+ sub = 'ползунки меняют сигнал — схема, осциллограф и спектр реагируют';
|
|
|
|
|
+ } else if(mode === 'mic'){
|
|
|
|
|
+ title = state.micOn ? 'ЖИВОЙ СИГНАЛ С МИКРОФОНА' : 'МИКРОФОН НЕ АКТИВЕН';
|
|
|
|
|
+ sub = state.micOn ? 'говорите — схема работает от голоса' : 'нажмите вкладку «Микрофон»';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ title = currentlySpeaking ? 'ОЗВУЧИВАНИЕ ТЕКСТА' : 'РЕЖИМ: ТЕКСТ';
|
|
|
|
|
+ sub = currentlySpeaking ? 'схема работает от вашей речи'
|
|
|
|
|
+ : (currentLabText ? 'пауза — схема в покое' : 'напишите фразу ниже');
|
|
|
|
|
+ }
|
|
|
|
|
+ label(ctx, title, W/2, 24, 15, C.amber);
|
|
|
|
|
+ dimText(ctx, sub, W/2, 44, 10);
|
|
|
|
|
+
|
|
|
|
|
+ drawLabScheme(ctx, W, t, sig);
|
|
|
|
|
+
|
|
|
|
|
+ const oscTop = 250;
|
|
|
|
|
+ const oscH = 120;
|
|
|
|
|
+ const oscCy = oscTop + oscH/2;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(212,168,96,.28)'; ctx.lineWidth = 1.5;
|
|
|
|
|
+ ctx.strokeRect(60, oscTop, W-120, oscH);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ const spanX = W - 120;
|
|
|
|
|
+ const scaleY = oscH * 0.42;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
|
|
|
|
+
|
|
|
|
|
+ if(mode === 'mic' && state.micOn && state.analyser && state.timeData){
|
|
|
|
|
+ ctx.strokeStyle = C.khaki; ctx.shadowColor = C.khaki; ctx.shadowBlur = 10;
|
|
|
|
|
+ const n = state.timeData.length;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ for(let i=0;i<n;i++){
|
|
|
|
|
+ const x = 60 + (i/(n-1))*spanX;
|
|
|
|
|
+ const y = oscCy - ((state.timeData[i]-128)/128) * scaleY * 1.3;
|
|
|
|
|
+ if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ } else if(mode === 'imitation'){
|
|
|
|
|
+ ctx.strokeStyle = C.amber; ctx.shadowColor = C.amber; ctx.shadowBlur = 10;
|
|
|
|
|
+ wavePhase += dt * (0.6 + state.imit.freq / 90);
|
|
|
|
|
+ const cycles = 3 + (state.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, state.imit.wave) * state.imit.amp;
|
|
|
|
|
+ const y = oscCy - v * scaleY;
|
|
|
|
|
+ const x = 60 + px;
|
|
|
|
|
+ if(px===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ } else if(mode === 'text' && currentlySpeaking){
|
|
|
|
|
+ ctx.strokeStyle = C.gold; ctx.shadowColor = C.gold; ctx.shadowBlur = 10;
|
|
|
|
|
+ wavePhase += dt * 22;
|
|
|
|
|
+ const cycles = 10;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ for(let px=0; px<=spanX; px++){
|
|
|
|
|
+ const u = px / spanX;
|
|
|
|
|
+ const phase = u*cycles + wavePhase*0.18;
|
|
|
|
|
+ let v = waveSample(phase, 'voice');
|
|
|
|
|
+ const syllable = 0.45 + 0.55*Math.abs(Math.sin(t*7 + u*2));
|
|
|
|
|
+ const wordEnv = 0.7 + 0.3*Math.sin(t*1.4);
|
|
|
|
|
+ v *= syllable * wordEnv * 0.8;
|
|
|
|
|
+ const y = oscCy - v * scaleY;
|
|
|
|
|
+ const x = 60 + px;
|
|
|
|
|
+ if(px===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(212,168,96,.3)';
|
|
|
|
|
+ ctx.shadowColor = C.amber; ctx.shadowBlur = 4;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ for(let px=0; px<=spanX; px++){
|
|
|
|
|
+ const u = px / spanX;
|
|
|
|
|
+ const v = 0.006*Math.sin(u*40 + t*3) + (Math.random()-0.5)*0.006;
|
|
|
|
|
+ const y = oscCy - v * scaleY;
|
|
|
|
|
+ const x = 60 + px;
|
|
|
|
|
+ if(px===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ dimText(ctx, 'осциллограф', 70, oscTop - 8, 10, 'left');
|
|
|
|
|
+
|
|
|
|
|
+ const specTop = oscTop + oscH + 12;
|
|
|
|
|
+ const specX = 60, specW = W - 120;
|
|
|
|
|
+
|
|
|
|
|
+ if(mode === 'mic' && state.micOn && state.analyser) state.analyser.getByteFrequencyData(state.freqData);
|
|
|
|
|
+
|
|
|
|
|
+ const barValues = new Array(SPEC_BARS);
|
|
|
|
|
+ for(let i=0;i<SPEC_BARS;i++){
|
|
|
|
|
+ let v = 0;
|
|
|
|
|
+ if(mode === 'mic' && state.micOn && state.analyser && state.freqData){
|
|
|
|
|
+ v = state.freqData[Math.floor((i/SPEC_BARS)*(state.freqData.length*0.6))] / 255;
|
|
|
|
|
+ } else if(mode === 'imitation'){
|
|
|
|
|
+ const center = (state.imit.freq/400) * SPEC_BARS;
|
|
|
|
|
+ const d = Math.abs(i - center);
|
|
|
|
|
+ v = Math.exp(-d*d/30) * state.imit.amp * (0.85 + 0.15*Math.sin(t*8 + i*0.3)) + Math.random()*0.08;
|
|
|
|
|
+ } else if(mode === 'text' && currentlySpeaking){
|
|
|
|
|
+ v = Math.abs(Math.sin(i*0.28 + t*9)) * 0.55 + Math.random()*0.2;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ v = Math.random()*0.02;
|
|
|
|
|
+ }
|
|
|
|
|
+ v = Math.max(v, 0.012 + Math.random()*0.015);
|
|
|
|
|
+ barValues[i] = Math.max(0, Math.min(1, v));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ spectroAccum += dt;
|
|
|
|
|
+ if(spectroAccum >= SPECTRO_INTERVAL){
|
|
|
|
|
+ spectroAccum = 0;
|
|
|
|
|
+ updateSpectrogram(barValues);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.imageSmoothingEnabled = false;
|
|
|
|
|
+ ctx.drawImage(spectroCanvas, specX, specTop, specW, SPEC_H);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(212,168,96,.45)'; ctx.lineWidth = 1;
|
|
|
|
|
+ ctx.strokeRect(specX, specTop, specW, SPEC_H);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+
|
|
|
|
|
+ dimText(ctx,'спектрограмма · X — частота · Y — время ↓', W/2, specTop + SPEC_H + 12, 10);
|
|
|
|
|
+}, { height: 500 });
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ ПЕРЕКЛЮЧЕНИЕ ВКЛАДОК
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+document.querySelectorAll('.lab-tab').forEach(tab=>{
|
|
|
|
|
+ tab.onclick = ()=>{
|
|
|
|
|
+ document.querySelectorAll('.lab-tab').forEach(t=>t.classList.remove('active'));
|
|
|
|
|
+ tab.classList.add('active');
|
|
|
|
|
+ state.lab.mode = tab.dataset.mode;
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById('panel-imitation').classList.toggle('on', state.lab.mode==='imitation');
|
|
|
|
|
+ document.getElementById('panel-mic').classList.toggle('on', state.lab.mode==='mic');
|
|
|
|
|
+ document.getElementById('panel-text').classList.toggle('on', state.lab.mode==='text');
|
|
|
|
|
+
|
|
|
|
|
+ if(state.lab.mode !== 'mic'){ disableMic(); stopRecording(); }
|
|
|
|
|
+ if(state.lab.mode === 'mic'){ enableMic(); }
|
|
|
|
|
+ if(state.lab.mode === 'text'){
|
|
|
|
|
+ setTimeout(()=>document.getElementById('textInput').focus(), 80);
|
|
|
|
|
+ }
|
|
|
|
|
+ stopSpeech();
|
|
|
|
|
+ currentLabText = '';
|
|
|
|
|
+ state.speechEnergy = 0;
|
|
|
|
|
+ };
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ МИКРОФОН
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+async function enableMic(){
|
|
|
|
|
+ if(state.micOn) return;
|
|
|
|
|
+ if(state.isFileProtocol){
|
|
|
|
|
+ alert('Микрофон недоступен со страницы-файла. Используйте localhost или https.');
|
|
|
|
|
+ document.querySelector('.lab-tab[data-mode="imitation"]').click();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
|
|
|
|
|
+ alert('Микрофон не поддерживается');
|
|
|
|
|
+ document.querySelector('.lab-tab[data-mode="imitation"]').click();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try{
|
|
|
|
|
+ state.micStream = await navigator.mediaDevices.getUserMedia({ audio:true });
|
|
|
|
|
+ state.audioCtx = state.audioCtx || new (window.AudioContext||window.webkitAudioContext)();
|
|
|
|
|
+ if(state.audioCtx.state === 'suspended') await state.audioCtx.resume();
|
|
|
|
|
+ const src = state.audioCtx.createMediaStreamSource(state.micStream);
|
|
|
|
|
+ state.analyser = state.audioCtx.createAnalyser();
|
|
|
|
|
+ state.analyser.fftSize = 2048;
|
|
|
|
|
+ state.analyser.smoothingTimeConstant = .55;
|
|
|
|
|
+ src.connect(state.analyser);
|
|
|
|
|
+ state.timeData = new Uint8Array(state.analyser.fftSize);
|
|
|
|
|
+ state.freqData = new Uint8Array(state.analyser.frequencyBinCount);
|
|
|
|
|
+ state.micOn = true;
|
|
|
|
|
+ }catch(e){
|
|
|
|
|
+ alert('Микрофон недоступен: ' + e.message);
|
|
|
|
|
+ document.querySelector('.lab-tab[data-mode="imitation"]').click();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+function disableMic(){
|
|
|
|
|
+ if(state.micStream) state.micStream.getTracks().forEach(x=>x.stop());
|
|
|
|
|
+ state.micStream = null; state.analyser = null; state.micOn = false;
|
|
|
|
|
+ document.getElementById('levelVal').textContent = '0%';
|
|
|
|
|
+}
|
|
|
|
|
+function startRecording(){
|
|
|
|
|
+ if(!state.micStream) return;
|
|
|
|
|
+ try{
|
|
|
|
|
+ state.recordedChunks = [];
|
|
|
|
|
+ const mime = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' :
|
|
|
|
|
+ MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' : '';
|
|
|
|
|
+ state.mediaRecorder = new MediaRecorder(state.micStream, mime ? { mimeType:mime } : undefined);
|
|
|
|
|
+ state.mediaRecorder.ondataavailable = e => { if(e.data.size) state.recordedChunks.push(e.data); };
|
|
|
|
|
+ state.mediaRecorder.onstop = () => {
|
|
|
|
|
+ const blob = new Blob(state.recordedChunks, { type: state.recordedChunks[0]?.type || 'audio/webm' });
|
|
|
|
|
+ if(state.recordedUrl) URL.revokeObjectURL(state.recordedUrl);
|
|
|
|
|
+ state.recordedUrl = URL.createObjectURL(blob);
|
|
|
|
|
+ document.getElementById('playBtn').disabled = false;
|
|
|
|
|
+ };
|
|
|
|
|
+ state.mediaRecorder.start();
|
|
|
|
|
+ document.getElementById('recBtn').classList.add('on');
|
|
|
|
|
+ document.getElementById('recBtn').textContent = '■ СТОП';
|
|
|
|
|
+ }catch(e){}
|
|
|
|
|
+}
|
|
|
|
|
+function stopRecording(){
|
|
|
|
|
+ if(state.mediaRecorder && state.mediaRecorder.state !== 'inactive'){
|
|
|
|
|
+ state.mediaRecorder.stop();
|
|
|
|
|
+ document.getElementById('recBtn').classList.remove('on');
|
|
|
|
|
+ document.getElementById('recBtn').textContent = '● ЗАПИСАТЬ';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+document.getElementById('recBtn').onclick = ()=>{
|
|
|
|
|
+ if(state.mediaRecorder && state.mediaRecorder.state === 'recording') stopRecording();
|
|
|
|
|
+ else startRecording();
|
|
|
|
|
+};
|
|
|
|
|
+document.getElementById('playBtn').onclick = ()=>{
|
|
|
|
|
+ if(!state.recordedUrl) return;
|
|
|
|
|
+ new Audio(state.recordedUrl).play();
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ ПОЛЗУНКИ
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+const chainFreq = document.getElementById('chainFreq');
|
|
|
|
|
+const chainVol = document.getElementById('chainVol');
|
|
|
|
|
+chainFreq.oninput = ()=>{
|
|
|
|
|
+ state.chain.freq = +chainFreq.value;
|
|
|
|
|
+ document.getElementById('chainFreqVal').textContent = chainFreq.value;
|
|
|
|
|
+};
|
|
|
|
|
+chainVol.oninput = ()=>{
|
|
|
|
|
+ state.chain.volume = +chainVol.value / 100;
|
|
|
|
|
+ document.getElementById('chainVolVal').textContent = chainVol.value + '%';
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const asmFreq = document.getElementById('asmFreq');
|
|
|
|
|
+const asmVol = document.getElementById('asmVol');
|
|
|
|
|
+const asmGain = document.getElementById('asmGain');
|
|
|
|
|
+asmFreq.oninput = ()=>{
|
|
|
|
|
+ state.asm.freq = +asmFreq.value;
|
|
|
|
|
+ document.getElementById('asmFreqVal').textContent = asmFreq.value;
|
|
|
|
|
+};
|
|
|
|
|
+asmVol.oninput = ()=>{
|
|
|
|
|
+ state.asm.volume = +asmVol.value / 100;
|
|
|
|
|
+ document.getElementById('asmVolVal').textContent = asmVol.value + '%';
|
|
|
|
|
+};
|
|
|
|
|
+asmGain.oninput = ()=>{
|
|
|
|
|
+ state.asm.gain = +asmGain.value / 100;
|
|
|
|
|
+ document.getElementById('asmGainVal').textContent = asmGain.value + '%';
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const labFreqRange = document.getElementById('labFreqRange');
|
|
|
|
|
+const labAmpRange = document.getElementById('labAmpRange');
|
|
|
|
|
+const labWaveSelect= document.getElementById('labWaveSelect');
|
|
|
|
|
+labFreqRange.oninput = ()=>{
|
|
|
|
|
+ state.imit.freq = +labFreqRange.value;
|
|
|
|
|
+ document.getElementById('labFreqVal').textContent = labFreqRange.value;
|
|
|
|
|
+};
|
|
|
|
|
+labAmpRange.oninput = ()=>{
|
|
|
|
|
+ state.imit.amp = +labAmpRange.value / 100;
|
|
|
|
|
+ document.getElementById('labAmpVal').textContent = labAmpRange.value + '%';
|
|
|
|
|
+};
|
|
|
|
|
+labWaveSelect.onchange = ()=>{ state.imit.wave = labWaveSelect.value; };
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ ВВОД ТЕКСТА
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+const textInput = document.getElementById('textInput');
|
|
|
|
|
+const speakInputBtn = document.getElementById('speakInputBtn');
|
|
|
|
|
+function speakUserPhrase(){
|
|
|
|
|
+ const txt = textInput.value.trim();
|
|
|
|
|
+ if(!txt) return;
|
|
|
|
|
+ stopSpeech();
|
|
|
|
|
+ state.speechEnergy = 0;
|
|
|
|
|
+ currentLabText = txt;
|
|
|
|
|
+ speakText(txt, ()=>{});
|
|
|
|
|
+}
|
|
|
|
|
+speakInputBtn.onclick = speakUserPhrase;
|
|
|
|
|
+textInput.addEventListener('keydown', e=>{
|
|
|
|
|
+ if(e.key === 'Enter'){ e.preventDefault(); speakUserPhrase(); }
|
|
|
|
|
+});
|
|
|
|
|
+document.querySelectorAll('#panel-text .chip').forEach(ch=>{
|
|
|
|
|
+ ch.onclick = ()=>{ textInput.value = ch.dataset.phrase; speakUserPhrase(); };
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ ШАПКА
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+const voiceToggle = document.getElementById('voiceToggle');
|
|
|
|
|
+voiceToggle.onclick = ()=>{
|
|
|
|
|
+ voiceOn = !voiceOn;
|
|
|
|
|
+ voiceToggle.textContent = voiceOn ? '🔊 ГОЛОС' : '🔇 ВЫКЛ';
|
|
|
|
|
+ voiceToggle.classList.toggle('active', voiceOn);
|
|
|
|
|
+ if(!voiceOn){ stopSpeech(); state.speechEnergy = 0; }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const stopBtn = document.getElementById('stopBtn');
|
|
|
|
|
+stopBtn.onclick = ()=>{
|
|
|
|
|
+ stopSpeech();
|
|
|
|
|
+ currentLabText = '';
|
|
|
|
|
+ state.speechEnergy = 0;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const readSceneBtn = document.getElementById('readSceneBtn');
|
|
|
|
|
+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);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/* =========================================================
|
|
|
|
|
+ ПРОГРЕСС + ПОЯВЛЕНИЕ
|
|
|
|
|
+ ========================================================= */
|
|
|
|
|
+const scrollProgress = document.getElementById('scrollProgress');
|
|
|
|
|
+const scrollTopBtn = document.getElementById('scrollTopBtn');
|
|
|
|
|
+window.addEventListener('scroll', ()=>{
|
|
|
|
|
+ const h = document.documentElement;
|
|
|
|
|
+ const scrolled = h.scrollTop / (h.scrollHeight - h.clientHeight);
|
|
|
|
|
+ scrollProgress.style.width = (scrolled * 100) + '%';
|
|
|
|
|
+}, { passive:true });
|
|
|
|
|
+scrollTopBtn.onclick = ()=>window.scrollTo({top:0, behavior:'smooth'});
|
|
|
|
|
+
|
|
|
|
|
+const sectionObserver = new IntersectionObserver(entries => {
|
|
|
|
|
+ for(const e of entries){
|
|
|
|
|
+ if(e.isIntersecting) e.target.classList.add('in-view');
|
|
|
|
|
+ }
|
|
|
|
|
+}, { threshold: 0.15 });
|
|
|
|
|
+document.querySelectorAll('.scene').forEach(s => sectionObserver.observe(s));
|
|
|
|
|
+document.getElementById('hero').classList.add('in-view');
|
|
|
|
|
+
|
|
|
|
|
+window.addEventListener('load', ()=>{
|
|
|
|
|
+ pickVoice();
|
|
|
|
|
+ [chainScene, coilScene, faradayScene, signalScene, assemblyScene, labScene]
|
|
|
|
|
+ .forEach(s => s && s.resize());
|
|
|
|
|
+});
|
|
|
|
|
+</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|