MICROPHONE_2.0.html 71 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946
  1. <!DOCTYPE html>
  2. <html lang="ru">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>Акустоэлектрическое преобразование — интерактивная лаборатория</title>
  7. <style>
  8. :root{
  9. --bg-1:#1a1005; --bg-2:#0a0603;
  10. --amber:#d4a860; --gold:#f0d890; --terra:#c87850;
  11. --khaki:#b8c878; --cream:#e8d0a0; --rust:#b86848; --olive:#8a9860;
  12. }
  13. *{box-sizing:border-box}
  14. html{scroll-behavior:smooth}
  15. body{
  16. margin:0;
  17. background:
  18. radial-gradient(circle at 20% 0%, #2a1a0e 0%, transparent 55%),
  19. radial-gradient(circle at 80% 100%, #1a2218 0%, transparent 55%),
  20. #0a0603;
  21. color:var(--cream);
  22. font-family:"Courier New","JetBrains Mono",monospace;
  23. font-size:15px; line-height:1.7; overflow-x:hidden;
  24. }
  25. body::before{
  26. content:""; position:fixed; inset:0;
  27. background-image:
  28. linear-gradient(rgba(212,168,96,.03) 1px, transparent 1px),
  29. linear-gradient(90deg, rgba(212,168,96,.03) 1px, transparent 1px);
  30. background-size:40px 40px; pointer-events:none; z-index:0;
  31. }
  32. .topbar{
  33. position:fixed; top:0; left:0; right:0;
  34. background:linear-gradient(180deg, rgba(20,12,6,.95), rgba(20,12,6,.75));
  35. backdrop-filter:blur(8px);
  36. border-bottom:1px solid rgba(212,168,96,.25);
  37. padding:8px 20px;
  38. display:flex; justify-content:space-between; align-items:center;
  39. z-index:100; font-size:12.5px; letter-spacing:1px;
  40. gap:12px; flex-wrap:wrap;
  41. }
  42. .topbar .brand{
  43. color:var(--amber); font-weight:bold;
  44. text-shadow:0 0 10px rgba(212,168,96,.6);
  45. flex-shrink:0;
  46. }
  47. .topbar .controls{display:flex; gap:8px; align-items:center; flex-wrap:wrap}
  48. .topbar button{
  49. background:rgba(212,168,96,.1); color:var(--amber);
  50. border:1px solid rgba(212,168,96,.4);
  51. padding:5px 12px; font-family:inherit; font-size:11.5px;
  52. letter-spacing:1px; cursor:pointer;
  53. transition:background .2s; border-radius:2px;
  54. }
  55. .topbar button:hover{background:rgba(212,168,96,.22)}
  56. .topbar button.active{background:var(--amber); color:#1a1005}
  57. .scroll-progress{
  58. position:absolute; bottom:-1px; left:0; height:2px;
  59. background:linear-gradient(90deg,var(--terra),var(--amber),var(--gold));
  60. width:0%; box-shadow:0 0 8px var(--amber);
  61. transition:width .1s linear;
  62. }
  63. .scene{
  64. position:relative; z-index:1;
  65. max-width:980px; margin:0 auto; padding:80px 24px;
  66. opacity:0; transform:translateY(40px);
  67. transition:opacity .9s cubic-bezier(.2,.8,.3,1), transform .9s cubic-bezier(.2,.8,.3,1);
  68. }
  69. .scene.in-view{opacity:1; transform:translateY(0)}
  70. .scene-header{
  71. display:flex; align-items:baseline; gap:20px;
  72. margin-bottom:24px; padding-bottom:14px;
  73. border-bottom:1px solid rgba(212,168,96,.2);
  74. }
  75. .scene-num{
  76. font-size:56px; color:var(--terra); font-weight:bold;
  77. line-height:1; text-shadow:0 0 24px rgba(200,120,80,.5);
  78. letter-spacing:-2px;
  79. }
  80. .scene-title{
  81. font-size:24px; color:var(--amber);
  82. text-shadow:0 0 16px rgba(212,168,96,.5);
  83. letter-spacing:1px; margin:0; font-weight:bold;
  84. }
  85. .scene-text{
  86. font-size:15.5px; color:var(--cream);
  87. margin:0 0 24px 0; max-width:720px;
  88. }
  89. .scene-text p{margin:0 0 14px 0}
  90. .scene-text strong{color:var(--gold); font-weight:bold}
  91. .scene-text em{color:var(--khaki); font-style:normal}
  92. .screen-frame{
  93. background:#000;
  94. border:2px solid rgba(212,168,96,.35);
  95. border-radius:3px; overflow:hidden; position:relative;
  96. box-shadow:0 0 0 1px rgba(0,0,0,.8), 0 0 40px rgba(212,168,96,.1);
  97. }
  98. .screen-frame::after{
  99. content:""; position:absolute; inset:0;
  100. background:repeating-linear-gradient(to bottom,
  101. transparent 0, transparent 2px,
  102. rgba(0,0,0,.18) 3px, rgba(0,0,0,.18) 4px);
  103. pointer-events:none; z-index:2;
  104. }
  105. .screen-frame::before{
  106. content:""; position:absolute; inset:0;
  107. background:radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.5) 100%);
  108. pointer-events:none; z-index:3;
  109. }
  110. canvas{display:block; width:100%; height:auto; position:relative; z-index:1}
  111. .interact-hint{
  112. margin-top:10px; padding:8px 14px;
  113. background:rgba(212,168,96,.06);
  114. border-left:2px solid var(--terra);
  115. color:rgba(232,208,160,.85);
  116. font-size:12.5px; letter-spacing:.5px;
  117. }
  118. .interact-hint::before{content:"▸ "; color:var(--terra); font-weight:bold}
  119. .formula{
  120. display:inline-block;
  121. background:rgba(212,168,96,.08);
  122. border:1px solid rgba(212,168,96,.35);
  123. padding:10px 20px; margin:14px 0;
  124. color:var(--gold); font-size:20px;
  125. letter-spacing:2px; border-radius:2px;
  126. text-shadow:0 0 12px rgba(240,216,144,.5);
  127. }
  128. .formula small{
  129. display:block; font-size:12px;
  130. color:rgba(232,208,160,.7);
  131. letter-spacing:1px; margin-top:6px; text-shadow:none;
  132. }
  133. .sliders-row{
  134. display:flex; flex-wrap:wrap; gap:20px;
  135. padding:14px 18px; margin-top:12px;
  136. background:rgba(20,12,6,.6);
  137. border:1px solid rgba(212,168,96,.25);
  138. border-radius:2px;
  139. align-items:center;
  140. }
  141. .sliders-row label{
  142. font-size:12.5px; color:var(--cream);
  143. display:flex; align-items:center; gap:10px; letter-spacing:.5px;
  144. }
  145. .sliders-row input[type=range]{width:180px; accent-color:var(--terra)}
  146. .sliders-row .value{
  147. color:var(--amber); font-family:"Courier New",monospace;
  148. font-size:12px; min-width:52px; text-align:right;
  149. }
  150. .hero{
  151. min-height:100vh; display:flex; flex-direction:column;
  152. justify-content:center; align-items:center;
  153. text-align:center; padding:80px 24px;
  154. position:relative; z-index:1;
  155. opacity:1; transform:none;
  156. }
  157. .hero h1{
  158. font-size:clamp(28px, 5vw, 52px);
  159. color:var(--amber); margin:0 0 20px 0;
  160. letter-spacing:3px;
  161. text-shadow:0 0 20px rgba(212,168,96,.7), 0 0 60px rgba(212,168,96,.3);
  162. line-height:1.1;
  163. }
  164. .hero h2{
  165. font-size:clamp(14px, 2vw, 18px);
  166. color:var(--khaki); letter-spacing:2px;
  167. font-weight:normal; margin:0 0 40px 0; opacity:.85;
  168. }
  169. .hero .divider{
  170. width:120px; height:1px;
  171. background:linear-gradient(90deg, transparent, var(--amber), transparent);
  172. margin:24px 0; box-shadow:0 0 12px var(--amber);
  173. }
  174. .hero .meta{
  175. display:flex; gap:36px; flex-wrap:wrap; justify-content:center;
  176. color:rgba(232,208,160,.6);
  177. font-size:12px; letter-spacing:1.5px; margin-top:32px;
  178. }
  179. .hero .meta span{display:flex; gap:8px; align-items:center}
  180. .hero .meta b{color:var(--amber)}
  181. .hero .howto{
  182. margin-top:48px; color:rgba(232,208,160,.55);
  183. font-size:12.5px; letter-spacing:1px; max-width:560px;
  184. }
  185. .hero .howto b{color:var(--terra)}
  186. .scroll-hint{
  187. position:absolute; bottom:36px;
  188. left:50%; transform:translateX(-50%);
  189. color:var(--amber); font-size:11px; letter-spacing:2px;
  190. opacity:.7; animation:bob 2s ease-in-out infinite;
  191. }
  192. @keyframes bob{
  193. 0%,100%{transform:translate(-50%, 0)}
  194. 50%{transform:translate(-50%, 10px)}
  195. }
  196. .scroll-hint::after{
  197. content:"▼"; display:block; text-align:center; margin-top:6px;
  198. color:var(--terra); font-size:14px;
  199. }
  200. .lab-tabs{display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap}
  201. .lab-tab{
  202. background:rgba(212,168,96,.08); color:var(--amber);
  203. border:1px solid rgba(212,168,96,.3);
  204. padding:8px 18px; font-family:inherit; font-size:13px;
  205. letter-spacing:1px; cursor:pointer;
  206. transition:all .2s; border-radius:2px;
  207. }
  208. .lab-tab:hover{background:rgba(212,168,96,.18)}
  209. .lab-tab.active{
  210. background:var(--amber); color:#1a1005;
  211. box-shadow:0 0 20px rgba(212,168,96,.5);
  212. }
  213. .lab-panel{
  214. display:none; padding:14px 18px;
  215. background:rgba(20,12,6,.6);
  216. border:1px solid rgba(212,168,96,.25);
  217. margin-top:12px; border-radius:2px;
  218. flex-wrap:wrap; gap:14px; align-items:center;
  219. }
  220. .lab-panel.on{display:flex}
  221. .lab-panel label{
  222. font-size:12.5px; color:var(--cream);
  223. display:flex; align-items:center; gap:8px; letter-spacing:.5px;
  224. }
  225. .lab-panel input[type=range]{width:130px; accent-color:var(--terra)}
  226. .lab-panel select, .lab-panel input[type=text]{
  227. background:rgba(0,0,0,.5); color:var(--gold);
  228. border:1px solid rgba(212,168,96,.4);
  229. padding:5px 10px; font-family:inherit; font-size:13px;
  230. border-radius:2px; outline:none;
  231. }
  232. .lab-panel select option{background:#1a1005}
  233. .lab-panel .value{
  234. color:var(--amber); font-family:"Courier New",monospace;
  235. font-size:12px; min-width:52px; text-align:right;
  236. }
  237. .lab-btn{
  238. background:rgba(212,168,96,.1); color:var(--amber);
  239. border:1px solid rgba(212,168,96,.4);
  240. padding:7px 16px; font-family:inherit; font-size:12.5px;
  241. letter-spacing:1px; cursor:pointer;
  242. border-radius:2px; transition:all .2s;
  243. }
  244. .lab-btn:hover{background:rgba(212,168,96,.22)}
  245. .lab-btn.primary{background:var(--terra); color:#f0f8e0; border-color:var(--terra)}
  246. .lab-btn.primary:hover{background:#d88858}
  247. .lab-btn.rec{background:rgba(200,72,72,.5); color:#fff; border-color:rgba(200,72,72,.7)}
  248. .lab-btn.rec.on{background:#c84848; animation:recPulse 1s infinite}
  249. @keyframes recPulse{50%{background:#8a2828}}
  250. .lab-btn:disabled{opacity:.4; cursor:default}
  251. .chip-row{display:flex; flex-wrap:wrap; gap:6px; margin-top:10px}
  252. .chip{
  253. background:rgba(212,168,96,.06);
  254. color:rgba(232,208,160,.85);
  255. border:1px solid rgba(212,168,96,.25);
  256. padding:5px 12px; font-family:inherit; font-size:11.5px;
  257. cursor:pointer; border-radius:2px; transition:all .2s;
  258. }
  259. .chip:hover{background:rgba(212,168,96,.18); color:var(--gold)}
  260. footer{
  261. position:relative; z-index:1;
  262. text-align:center; padding:80px 24px 100px;
  263. border-top:1px solid rgba(212,168,96,.2);
  264. margin-top:80px;
  265. }
  266. footer h3{
  267. color:var(--amber); font-size:22px; letter-spacing:2px;
  268. text-shadow:0 0 16px rgba(212,168,96,.5);
  269. margin:0 0 24px 0;
  270. }
  271. footer ul{list-style:none; padding:0; max-width:640px; margin:0 auto 32px; text-align:left}
  272. footer li{
  273. padding:10px 0 10px 28px; position:relative;
  274. color:var(--cream); font-size:14px;
  275. }
  276. footer li::before{content:"▸"; position:absolute; left:0; top:10px; color:var(--terra)}
  277. footer .sig{color:rgba(232,208,160,.4); font-size:11.5px; letter-spacing:1.5px; margin-top:24px}
  278. @media (max-width:640px){
  279. .topbar{padding:6px 12px; font-size:11px}
  280. .topbar .brand{font-size:10.5px; letter-spacing:1px; max-width:60%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
  281. .scene{padding:56px 16px}
  282. .scene-num{font-size:40px}
  283. .scene-title{font-size:19px}
  284. .scene-text{font-size:14px}
  285. .lab-tab{padding:6px 12px; font-size:11.5px}
  286. .lab-panel{padding:10px 12px; gap:10px}
  287. .lab-panel input[type=range]{width:90px}
  288. .sliders-row{padding:10px 12px; gap:12px}
  289. .sliders-row input[type=range]{width:120px}
  290. }
  291. </style>
  292. </head>
  293. <body>
  294. <div class="topbar">
  295. <div class="brand">◤ ACOUSTO-LAB</div>
  296. <div class="controls">
  297. <button id="readSceneBtn" title="Озвучить текущую главу">📖 ЧИТАТЬ ГЛАВУ</button>
  298. <button id="voiceToggle" class="active">🔊 ГОЛОС</button>
  299. <button id="stopBtn" title="Стоп">■</button>
  300. <button id="scrollTopBtn" title="Наверх">↑</button>
  301. </div>
  302. <div class="scroll-progress" id="scrollProgress"></div>
  303. </div>
  304. <header class="hero" id="hero">
  305. <h1>АКУСТОЭЛЕКТРИЧЕСКОЕ<br>ПРЕОБРАЗОВАНИЕ</h1>
  306. <div class="divider"></div>
  307. <h2>ИНТЕРАКТИВНАЯ ЛАБОРАТОРИЯ · КАК ЗВУК СТАНОВИТСЯ ЭЛЕКТРИЧЕСТВОМ</h2>
  308. <div class="meta">
  309. <span><b>01</b> Динамик и мембрана</span>
  310. <span><b>02</b> Катушка</span>
  311. <span><b>03</b> ЭДС</span>
  312. <span><b>04</b> Сигнал</span>
  313. <span><b>05</b> Полная сборка</span>
  314. <span><b>06</b> Лаборатория</span>
  315. </div>
  316. <div class="howto">
  317. В каждой главе — <b>свой интерактив</b>. Кнопка <b>«📖 ЧИТАТЬ ГЛАВУ»</b>
  318. в шапке озвучит текст той главы, что сейчас на экране.
  319. </div>
  320. <div class="scroll-hint">СКРОЛЛ</div>
  321. </header>
  322. <section class="scene" id="scene-chain">
  323. <div class="scene-header">
  324. <div class="scene-num">01</div>
  325. <h2 class="scene-title">Динамик → волна → мембрана</h2>
  326. </div>
  327. <div class="scene-text">
  328. <p>Динамик создаёт <strong>колебания давления воздуха</strong>. Молекулы передают толчок друг другу — от источника к приёмнику. Это и есть звуковая волна.</p>
  329. <p>Справа — <strong>мембрана микрофона</strong>. Она отклоняется ровно на ту волну, которая до неё дошла. Форма мембраны в точности повторяет форму волны — <em>без искажений</em>.</p>
  330. </div>
  331. <div class="screen-frame">
  332. <canvas id="cv-chain" data-h="340"></canvas>
  333. </div>
  334. <div class="sliders-row">
  335. <label>Частота
  336. <input type="range" id="chainFreq" min="1" max="10" value="4">
  337. <span class="value" id="chainFreqVal">4</span>
  338. </label>
  339. <label>Громкость
  340. <input type="range" id="chainVol" min="10" max="100" value="60">
  341. <span class="value" id="chainVolVal">60%</span>
  342. </label>
  343. </div>
  344. <div class="interact-hint">Ползунок «Частота» меняет длину волны, «Громкость» — её амплитуду и размах мембраны.</div>
  345. </section>
  346. <section class="scene" id="scene-coil">
  347. <div class="scene-header">
  348. <div class="scene-num">02</div>
  349. <h2 class="scene-title">Катушка в магнитном поле</h2>
  350. </div>
  351. <div class="scene-text">
  352. <p>К мембране жёстко прикреплена <strong>катушка</strong> из тонкого провода. Она находится в зазоре между полюсами постоянного магнита <em>N</em> и <em>S</em>.</p>
  353. <p>Когда мембрана колеблется — катушка движется в магнитном поле. Движение проводника в поле создаёт в нём <strong>электрический ток</strong>.</p>
  354. </div>
  355. <div class="screen-frame">
  356. <canvas id="cv-coil" data-h="360"></canvas>
  357. </div>
  358. <div class="interact-hint">Потяни катушку мышью влево-вправо — стрелка гальванометра отреагирует на движение.</div>
  359. </section>
  360. <section class="scene" id="scene-faraday">
  361. <div class="scene-header">
  362. <div class="scene-num">03</div>
  363. <h2 class="scene-title">Закон Фарадея</h2>
  364. </div>
  365. <div class="scene-text">
  366. <p>Величина ЭДС пропорциональна <strong>скорости изменения магнитного потока</strong>. Быстрее движется катушка — сильнее ток. Стоит на месте — тока нет.</p>
  367. <div class="formula">
  368. ε = − dΦ / dt
  369. <small>ЭДС индукции = минус скорость изменения магнитного потока</small>
  370. </div>
  371. </div>
  372. <div class="screen-frame">
  373. <canvas id="cv-faraday" data-h="340"></canvas>
  374. </div>
  375. <div class="interact-hint">Нажми на кнопку «МАГНИТ: ВКЛ / ВЫКЛ» прямо на экране — увидишь, как гаснет ЭДС без поля.</div>
  376. </section>
  377. <section class="scene" id="scene-signal">
  378. <div class="scene-header">
  379. <div class="scene-num">04</div>
  380. <h2 class="scene-title">Электрический сигнал</h2>
  381. </div>
  382. <div class="scene-text">
  383. <p>На выходе микрофона — <strong>переменное напряжение</strong>, форма которого повторяет звуковую волну. Это и есть акустоэлектрическое преобразование.</p>
  384. <p>Сигнал слабый — <em>милливольты</em>. Его усиливают и передают дальше: в усилитель, в колонки, в запись.</p>
  385. </div>
  386. <div class="screen-frame">
  387. <canvas id="cv-signal" data-h="280"></canvas>
  388. </div>
  389. <div class="interact-hint">Двигай ползунок усиления. Кнопка «ШУМ» добавит помехи.</div>
  390. </section>
  391. <section class="scene" id="scene-assembly">
  392. <div class="scene-header">
  393. <div class="scene-num">05</div>
  394. <h2 class="scene-title">Полная сборка</h2>
  395. </div>
  396. <div class="scene-text">
  397. <p>Сверху — <strong>акустическая часть</strong>: динамик, волна, мембрана, катушка в магнитах. Снизу — <strong>электрическая</strong>: слабая ЭДС с катушки, усилитель и готовый сигнал на выходе.</p>
  398. <p>Ползунок «Усиление» показывает, во сколько раз усилитель увеличивает слабый сигнал.</p>
  399. </div>
  400. <div class="screen-frame">
  401. <canvas id="cv-assembly" data-h="380"></canvas>
  402. </div>
  403. <div class="sliders-row">
  404. <label>Частота
  405. <input type="range" id="asmFreq" min="1" max="10" value="4">
  406. <span class="value" id="asmFreqVal">4</span>
  407. </label>
  408. <label>Громкость
  409. <input type="range" id="asmVol" min="10" max="100" value="60">
  410. <span class="value" id="asmVolVal">60%</span>
  411. </label>
  412. <label>Усиление
  413. <input type="range" id="asmGain" min="0" max="100" value="50">
  414. <span class="value" id="asmGainVal">50%</span>
  415. </label>
  416. </div>
  417. <div class="interact-hint">Меняй усиление — правый график растёт, левый остаётся слабым. Так работает усилитель.</div>
  418. </section>
  419. <section class="scene" id="scene-lab">
  420. <div class="scene-header">
  421. <div class="scene-num">06</div>
  422. <h2 class="scene-title">Лаборатория</h2>
  423. </div>
  424. <div class="scene-text">
  425. <p>Три режима: <strong>генератор</strong>, <strong>микрофон</strong> и <strong>текст</strong>. Схема сверху реагирует только на реальный сигнал — когда ассистент делает паузу между фразами, схема замирает.</p>
  426. </div>
  427. <div class="lab-tabs">
  428. <button class="lab-tab active" data-mode="imitation">◉ Генератор</button>
  429. <button class="lab-tab" data-mode="mic">🎤 Микрофон</button>
  430. <button class="lab-tab" data-mode="text">⌨ Текст</button>
  431. </div>
  432. <div class="screen-frame">
  433. <canvas id="cv-lab" data-h="500"></canvas>
  434. </div>
  435. <div class="lab-panel on" id="panel-imitation">
  436. <label>Частота <input type="range" id="labFreqRange" min="60" max="400" value="140"><span class="value" id="labFreqVal">140</span></label>
  437. <label>Амплитуда <input type="range" id="labAmpRange" min="10" max="100" value="60"><span class="value" id="labAmpVal">60%</span></label>
  438. <label>Форма
  439. <select id="labWaveSelect">
  440. <option value="sine">синус</option>
  441. <option value="saw">пила</option>
  442. <option value="square">квадрат</option>
  443. <option value="noise">шум</option>
  444. <option value="voice">голос</option>
  445. </select>
  446. </label>
  447. </div>
  448. <div class="lab-panel" id="panel-mic">
  449. <button class="lab-btn rec" id="recBtn">● ЗАПИСАТЬ</button>
  450. <button class="lab-btn" id="playBtn" disabled>▶ ВОСПРОИЗВЕСТИ</button>
  451. <span style="color:var(--cream); font-size:12px">Уровень: <span id="levelVal" class="value">0%</span></span>
  452. </div>
  453. <div class="lab-panel" id="panel-text">
  454. <div style="width:100%; display:flex; gap:10px; flex-wrap:wrap">
  455. <input type="text" id="textInput" placeholder="Напиши фразу и нажми Enter…" style="flex:1; min-width:200px">
  456. <button class="lab-btn primary" id="speakInputBtn">▶ ОЗВУЧИТЬ</button>
  457. </div>
  458. <div class="chip-row" style="width:100%">
  459. <button class="chip" data-phrase="Звук — это колебания давления воздуха.">звук</button>
  460. <button class="chip" data-phrase="Катушка движется в магнитном поле, и возникает ток.">индукция</button>
  461. <button class="chip" data-phrase="Так микрофон превращает голос в электрический сигнал.">микрофон</button>
  462. <button class="chip" data-phrase="Проверка, раз, два, три.">проверка</button>
  463. </div>
  464. </div>
  465. </section>
  466. <footer>
  467. <h3>ИТОГИ</h3>
  468. <ul>
  469. <li>Звук — это колебания давления воздуха, распространяющиеся как волна.</li>
  470. <li>Диафрагма микрофона механически повторяет эти колебания.</li>
  471. <li>Катушка, прикреплённая к диафрагме, движется в магнитном поле.</li>
  472. <li>По закону Фарадея возникает ЭДС индукции — переменное напряжение.</li>
  473. <li>Усилитель увеличивает слабый сигнал до пригодного для передачи.</li>
  474. <li>Тот же принцип работает в телефонах, диктофонах, гидрофонах и слуховых аппаратах.</li>
  475. </ul>
  476. <div class="sig">ACOUSTO-LAB.EXE · интерактивный проект · 2025</div>
  477. </footer>
  478. <script>
  479. const C = {
  480. amber:'#d4a860', gold:'#f0d890', terra:'#c87850',
  481. khaki:'#b8c878', cream:'#e8d0a0', rust:'#b86848',
  482. olive:'#8a9860', bg:'#1a1005'
  483. };
  484. function clearScreen(ctx,W,H){ ctx.fillStyle=C.bg; ctx.fillRect(0,0,W,H); }
  485. function grid(ctx,W,H){
  486. ctx.save();
  487. ctx.strokeStyle='rgba(212,168,96,.08)'; ctx.lineWidth=1;
  488. for(let x=0;x<W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
  489. for(let y=0;y<H;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
  490. ctx.restore();
  491. }
  492. function label(ctx,text,x,y,size,color,align='center'){
  493. ctx.save();
  494. ctx.font=`bold ${size}px 'Courier New',monospace`;
  495. ctx.textAlign=align; ctx.textBaseline='middle';
  496. ctx.shadowColor=color; ctx.shadowBlur=8;
  497. ctx.fillStyle=color; ctx.fillText(text,x,y);
  498. ctx.restore();
  499. }
  500. function dimText(ctx,text,x,y,size,align='center'){
  501. ctx.save();
  502. ctx.font=`${size}px 'Courier New',monospace`;
  503. ctx.textAlign=align; ctx.textBaseline='middle';
  504. ctx.fillStyle='rgba(232,208,160,.6)';
  505. ctx.fillText(text,x,y);
  506. ctx.restore();
  507. }
  508. function rr(ctx,x,y,w,h,r){
  509. ctx.beginPath();
  510. ctx.moveTo(x+r,y);
  511. ctx.arcTo(x+w,y,x+w,y+h,r);
  512. ctx.arcTo(x+w,y+h,x,y+h,r);
  513. ctx.arcTo(x,y+h,x,y,r);
  514. ctx.arcTo(x,y,x+w,y,r);
  515. ctx.closePath();
  516. }
  517. function waveSample(phase, type){
  518. const p = phase - Math.floor(phase);
  519. switch(type){
  520. case 'sine': return Math.sin(p*Math.PI*2);
  521. case 'saw': return 2*p - 1;
  522. case 'square': return p < 0.5 ? 1 : -1;
  523. case 'noise': return Math.random()*2 - 1;
  524. case 'voice':
  525. return Math.sin(p*Math.PI*2)*0.5
  526. + Math.sin(p*Math.PI*4)*0.25
  527. + Math.sin(p*Math.PI*6)*0.12
  528. + (Math.random()*2-1)*0.1;
  529. }
  530. return 0;
  531. }
  532. function waveValue(x, xStart, xEnd, freq, t, speed){
  533. const cycles = freq;
  534. const span = xEnd - xStart;
  535. const rel = (x - xStart) / span;
  536. const phase = rel * cycles - t * speed;
  537. return Math.sin(phase * Math.PI * 2);
  538. }
  539. const state = {
  540. chain: { freq: 4, volume: 0.6 },
  541. asm: { freq: 4, volume: 0.6, gain: 0.5 },
  542. lab: { mode: 'imitation' },
  543. speechEnergy: 0,
  544. syllablePhase: 0,
  545. imit: { freq: 140, amp: 0.6, wave: 'sine' },
  546. magnetOn: true,
  547. gain: 0.5,
  548. noise: false,
  549. noiseMix: 0,
  550. micOn: false,
  551. analyser: null,
  552. timeData: null,
  553. freqData: null,
  554. micStream: null,
  555. mediaRecorder: null,
  556. recordedChunks: [],
  557. recordedUrl: null,
  558. audioCtx: null,
  559. isFileProtocol: location.protocol === 'file:'
  560. };
  561. let ruVoice = null;
  562. let voiceOn = true;
  563. let currentlySpeaking = false;
  564. let speakToken = 0;
  565. let currentLabText = '';
  566. function pickVoice(){
  567. if(!('speechSynthesis' in window)) return;
  568. const list = speechSynthesis.getVoices();
  569. if(!list.length) return;
  570. ruVoice =
  571. list.find(v=>/ru[-_]RU/i.test(v.lang) && /yuri|dmitri|pavel|male|мужск/i.test(v.name)) ||
  572. list.find(v=>/ru[-_]RU/i.test(v.lang)) ||
  573. list.find(v=>/^ru/i.test(v.lang)) || null;
  574. }
  575. if('speechSynthesis' in window){
  576. pickVoice();
  577. speechSynthesis.onvoiceschanged = pickVoice;
  578. }
  579. function setSpeaking(on){ currentlySpeaking = on; }
  580. function splitSentences(text){
  581. const parts = text.match(/[^.!?…]+[.!?…]+/g);
  582. if(!parts) return [text.trim()];
  583. const rest = text.slice(parts.join('').length).trim();
  584. if(rest) parts.push(rest);
  585. return parts.map(s=>s.trim()).filter(Boolean);
  586. }
  587. function speakText(text, onDone){
  588. if(!voiceOn || !('speechSynthesis' in window)){
  589. setSpeaking(false);
  590. if(onDone) setTimeout(onDone, 200);
  591. return;
  592. }
  593. try{
  594. speechSynthesis.cancel();
  595. const myToken = ++speakToken;
  596. const sentences = splitSentences(text);
  597. let i = 0;
  598. const step = ()=>{
  599. if(myToken !== speakToken){ setSpeaking(false); return; }
  600. if(i >= sentences.length){ setSpeaking(false); if(onDone) onDone(); return; }
  601. setSpeaking(true);
  602. const u = new SpeechSynthesisUtterance(sentences[i]);
  603. u.lang = 'ru-RU';
  604. if(ruVoice) u.voice = ruVoice;
  605. u.pitch = 0.75; u.rate = 0.9; u.volume = 1;
  606. u.onend = ()=>{
  607. if(myToken !== speakToken){ setSpeaking(false); return; }
  608. setSpeaking(false);
  609. i++;
  610. setTimeout(()=>{
  611. if(myToken !== speakToken){ setSpeaking(false); return; }
  612. step();
  613. }, 200);
  614. };
  615. u.onerror = ()=>{
  616. if(myToken !== speakToken){ setSpeaking(false); return; }
  617. setSpeaking(false);
  618. i++;
  619. setTimeout(()=>{
  620. if(myToken !== speakToken){ setSpeaking(false); return; }
  621. step();
  622. }, 200);
  623. };
  624. speechSynthesis.speak(u);
  625. };
  626. step();
  627. }catch(e){ setSpeaking(false); if(onDone) onDone(); }
  628. }
  629. function stopSpeech(){
  630. speakToken++;
  631. try{ speechSynthesis.cancel(); }catch(e){}
  632. setSpeaking(false);
  633. }
  634. function makeScene(canvasId, drawFn, opts = {}){
  635. const cv = document.getElementById(canvasId);
  636. if(!cv) return null;
  637. const ctx = cv.getContext('2d');
  638. const BASE_W = 900;
  639. const BASE_H = opts.height || parseInt(cv.dataset.h || 300, 10);
  640. let visible = false;
  641. let rafId = null;
  642. let time = 0, last = 0;
  643. const mouse = { x:-1, y:-1, inside:false, down:false };
  644. function resize(){
  645. const rect = cv.getBoundingClientRect();
  646. const dpr = window.devicePixelRatio || 1;
  647. const displayW = rect.width || BASE_W;
  648. const displayH = displayW * BASE_H / BASE_W;
  649. const targetW = Math.max(1, Math.round(displayW * dpr));
  650. const targetH = Math.max(1, Math.round(displayH * dpr));
  651. if(cv.width !== targetW || cv.height !== targetH){
  652. cv.width = targetW; cv.height = targetH;
  653. }
  654. ctx.setTransform(targetW/BASE_W, 0, 0, targetH/BASE_H, 0, 0);
  655. ctx.imageSmoothingQuality = 'high';
  656. if(!visible) drawFn(ctx, BASE_W, BASE_H, 0, 0.016, mouse);
  657. }
  658. function loop(ts){
  659. if(!visible) return;
  660. if(!last) last = ts;
  661. const dt = Math.min(.05, (ts - last)/1000);
  662. last = ts;
  663. time += dt;
  664. try{ drawFn(ctx, BASE_W, BASE_H, time, dt, mouse); }catch(e){ console.error(e); }
  665. rafId = requestAnimationFrame(loop);
  666. }
  667. cv.addEventListener('mousemove', e => {
  668. const r = cv.getBoundingClientRect();
  669. mouse.x = (e.clientX - r.left) * (BASE_W / r.width);
  670. mouse.y = (e.clientY - r.top) * (BASE_H / r.height);
  671. mouse.inside = true;
  672. if(opts.onMouseMove) opts.onMouseMove(mouse, ctx, BASE_W, BASE_H);
  673. });
  674. cv.addEventListener('mouseleave', ()=>{ mouse.inside = false; });
  675. cv.addEventListener('mousedown', e => {
  676. mouse.down = true;
  677. if(opts.onMouseDown) opts.onMouseDown(mouse, ctx, BASE_W, BASE_H);
  678. });
  679. window.addEventListener('mouseup', ()=>{ mouse.down = false; if(opts.onMouseUp) opts.onMouseUp(mouse); });
  680. cv.addEventListener('touchstart', e => {
  681. if(e.touches.length){
  682. const r = cv.getBoundingClientRect();
  683. mouse.x = (e.touches[0].clientX - r.left) * (BASE_W / r.width);
  684. mouse.y = (e.touches[0].clientY - r.top) * (BASE_H / r.height);
  685. mouse.inside = true; mouse.down = true;
  686. if(opts.onMouseDown) opts.onMouseDown(mouse, ctx, BASE_W, BASE_H);
  687. }
  688. }, {passive:true});
  689. cv.addEventListener('touchmove', e => {
  690. if(e.touches.length){
  691. const r = cv.getBoundingClientRect();
  692. mouse.x = (e.touches[0].clientX - r.left) * (BASE_W / r.width);
  693. mouse.y = (e.touches[0].clientY - r.top) * (BASE_H / r.height);
  694. if(opts.onMouseMove) opts.onMouseMove(mouse, ctx, BASE_W, BASE_H);
  695. }
  696. }, {passive:true});
  697. cv.addEventListener('touchend', ()=>{
  698. mouse.down = false;
  699. if(opts.onMouseUp) opts.onMouseUp(mouse);
  700. });
  701. const observer = new IntersectionObserver(entries => {
  702. for(const e of entries){
  703. if(e.isIntersecting && !visible){
  704. visible = true; last = 0; resize();
  705. rafId = requestAnimationFrame(loop);
  706. } else if(!e.isIntersecting && visible){
  707. visible = false;
  708. if(rafId) cancelAnimationFrame(rafId);
  709. }
  710. }
  711. }, { threshold: 0.1 });
  712. window.addEventListener('resize', resize);
  713. resize();
  714. observer.observe(cv);
  715. return { cv, ctx, BASE_W, BASE_H, mouse, get visible(){ return visible; }, resize };
  716. }
  717. /* =========================================================
  718. ГЛАВА 1
  719. ========================================================= */
  720. const chainScene = makeScene('cv-chain', (ctx, W, H, t) => {
  721. clearScreen(ctx, W, H); grid(ctx, W, H);
  722. const cy = H / 2;
  723. const speakerX = 100;
  724. const membraneX = W - 110;
  725. const freq = state.chain.freq;
  726. const vol = state.chain.volume;
  727. const amp = 6 + vol * 32;
  728. const waveSpeed = 2.5;
  729. ctx.save();
  730. ctx.strokeStyle = C.amber; ctx.lineWidth = 3;
  731. ctx.shadowColor = C.amber; ctx.shadowBlur = 12;
  732. ctx.fillStyle = 'rgba(212,168,96,.07)';
  733. ctx.beginPath();
  734. ctx.moveTo(40, cy-60); ctx.lineTo(80, cy-60);
  735. ctx.lineTo(115, cy-100);ctx.lineTo(115, cy+100);
  736. ctx.lineTo(80, cy+60); ctx.lineTo(40, cy+60);
  737. ctx.closePath(); ctx.fill(); ctx.stroke();
  738. ctx.restore();
  739. const N = 60;
  740. for(let i = 0; i < N; i++){
  741. const u = i / (N - 1);
  742. const baseX = speakerX + (membraneX - speakerX) * u;
  743. const v = waveValue(baseX, speakerX, membraneX, freq, t, waveSpeed);
  744. const disp = amp * v;
  745. const x = baseX + disp;
  746. const y = cy + Math.sin(i * 1.4) * 3;
  747. const bright = (v + 1) / 2;
  748. const g = Math.round(140 + bright * 100);
  749. const col = `rgb(${Math.round(180 + bright*60)}, ${g}, ${Math.round(110 + bright*80)})`;
  750. const radius = 2.5 + bright * 1.6;
  751. ctx.fillStyle = col;
  752. ctx.shadowColor = col;
  753. ctx.shadowBlur = 6 + bright * 6;
  754. ctx.beginPath();
  755. ctx.arc(x, y, radius, 0, Math.PI*2);
  756. ctx.fill();
  757. }
  758. ctx.shadowBlur = 0;
  759. ctx.save();
  760. ctx.strokeStyle = 'rgba(200,120,80,.35)';
  761. ctx.lineWidth = 1.5;
  762. ctx.beginPath();
  763. const arrowY = cy + 90;
  764. ctx.moveTo(speakerX + 60, arrowY);
  765. ctx.lineTo(membraneX - 60, arrowY);
  766. ctx.stroke();
  767. ctx.beginPath();
  768. ctx.moveTo(membraneX - 60, arrowY);
  769. ctx.lineTo(membraneX - 72, arrowY - 6);
  770. ctx.moveTo(membraneX - 60, arrowY);
  771. ctx.lineTo(membraneX - 72, arrowY + 6);
  772. ctx.stroke();
  773. ctx.restore();
  774. dimText(ctx, 'направление волны', (speakerX + membraneX) / 2, arrowY + 18, 10);
  775. const memVal = waveValue(membraneX, speakerX, membraneX, freq, t, waveSpeed);
  776. const bow = memVal * amp * 1.4;
  777. const top = cy - 100;
  778. const bot = cy + 100;
  779. ctx.save();
  780. ctx.strokeStyle = C.amber; ctx.lineWidth = 4;
  781. ctx.shadowColor = C.amber; ctx.shadowBlur = 16;
  782. ctx.beginPath();
  783. const SEG = 44;
  784. for(let i = 0; i <= SEG; i++){
  785. const p = i / SEG;
  786. const y = top + (bot - top) * p;
  787. const shape = Math.sin(Math.PI * p);
  788. const x = membraneX + bow * shape;
  789. if(i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
  790. }
  791. ctx.stroke();
  792. ctx.restore();
  793. ctx.strokeStyle = 'rgba(168,120,138,.55)';
  794. ctx.lineWidth = 3;
  795. ctx.beginPath(); ctx.moveTo(membraneX, top); ctx.lineTo(membraneX - 60, top - 30); ctx.stroke();
  796. ctx.beginPath(); ctx.moveTo(membraneX, bot); ctx.lineTo(membraneX - 60, bot + 30); ctx.stroke();
  797. ctx.beginPath(); ctx.moveTo(membraneX, top); ctx.lineTo(membraneX + 60, top - 30); ctx.stroke();
  798. ctx.beginPath(); ctx.moveTo(membraneX, bot); ctx.lineTo(membraneX + 60, bot + 30); ctx.stroke();
  799. label(ctx, 'ДИНАМИК', speakerX - 10, 30, 14, C.amber);
  800. label(ctx, 'МОЛЕКУЛЫ ВОЗДУХА', (speakerX + membraneX) / 2, 30, 13, C.khaki);
  801. label(ctx, 'МЕМБРАНА', membraneX, 30, 14, C.amber);
  802. dimText(ctx, 'мембрана повторяет форму волны', membraneX, H - 22, 11);
  803. }, { height: 340 });
  804. /* =========================================================
  805. ГЛАВА 2
  806. ========================================================= */
  807. const coilState = { x: 0, v: 0, dragging: false, dragOffset: 0 };
  808. const coilScene = makeScene('cv-coil', (ctx, W, H, t, dt, mouse) => {
  809. clearScreen(ctx, W, H); grid(ctx, W, H);
  810. const cy = H/2 - 20;
  811. if(coilState.dragging){
  812. const targetX = Math.max(-80, Math.min(80, mouse.x - W/2 - coilState.dragOffset));
  813. coilState.x += (targetX - coilState.x) * Math.min(1, dt * 16);
  814. coilState.v = (targetX - coilState.x) * 0.2;
  815. } else {
  816. if(Math.abs(coilState.v) < 0.01 && Math.abs(coilState.x) < 0.5){
  817. coilState.x = Math.sin(t*2.2) * 60;
  818. coilState.v = Math.cos(t*2.2) * 60 * 2.2;
  819. } else {
  820. const k = 25, damp = 1.8;
  821. coilState.v += (-k*coilState.x - damp*coilState.v) * dt;
  822. coilState.x += coilState.v * dt;
  823. }
  824. }
  825. const cur = Math.max(-1, Math.min(1, coilState.v * 0.08));
  826. ctx.save();
  827. ctx.fillStyle='rgba(184,104,72,.16)'; ctx.strokeStyle=C.rust; ctx.lineWidth=3;
  828. ctx.shadowColor=C.rust; ctx.shadowBlur=12;
  829. ctx.fillRect(150,cy-100,65,200); ctx.strokeRect(150,cy-100,65,200);
  830. ctx.restore();
  831. label(ctx,'N',182,cy,42,C.rust);
  832. ctx.save();
  833. ctx.fillStyle='rgba(138,152,96,.16)'; ctx.strokeStyle=C.olive; ctx.lineWidth=3;
  834. ctx.shadowColor=C.olive; ctx.shadowBlur=12;
  835. ctx.fillRect(W-215,cy-100,65,200); ctx.strokeRect(W-215,cy-100,65,200);
  836. ctx.restore();
  837. label(ctx,'S',W-182,cy,42,C.olive);
  838. ctx.save();
  839. ctx.strokeStyle='rgba(212,168,96,.22)'; ctx.lineWidth=1.5;
  840. for(let i=0;i<8;i++){
  841. const y = cy-88+i*25;
  842. ctx.beginPath(); ctx.moveTo(215,y); ctx.lineTo(W-215,y); ctx.stroke();
  843. }
  844. ctx.restore();
  845. const cx = W/2 + coilState.x;
  846. ctx.save();
  847. ctx.strokeStyle = C.gold; ctx.lineWidth = 3;
  848. ctx.shadowColor = C.gold; ctx.shadowBlur = coilState.dragging ? 20 : 12;
  849. for(let i=0;i<7;i++){
  850. const lx = cx - 55 + i*(110/6);
  851. ctx.beginPath(); ctx.ellipse(lx, cy, 8, 72, 0, 0, Math.PI*2); ctx.stroke();
  852. }
  853. ctx.restore();
  854. const gx = W - 130, gy = cy + 130;
  855. ctx.save();
  856. ctx.fillStyle='#1a1005'; ctx.strokeStyle='rgba(232,208,160,.3)'; ctx.lineWidth=2;
  857. ctx.beginPath(); ctx.arc(gx,gy,38,0,Math.PI*2); ctx.fill(); ctx.stroke();
  858. const needle = cur * 1.15;
  859. ctx.strokeStyle = Math.abs(cur) > .02 ? C.khaki : '#5a5040';
  860. ctx.shadowColor = C.khaki; ctx.shadowBlur = Math.abs(cur) * 16;
  861. ctx.lineWidth = 3;
  862. ctx.beginPath();
  863. ctx.moveTo(gx, gy + 22);
  864. ctx.lineTo(gx + Math.sin(needle)*32, gy + 22 - Math.cos(needle)*40);
  865. ctx.stroke();
  866. ctx.restore();
  867. label(ctx,'КАТУШКА В МАГНИТНОМ ПОЛЕ', W/2, 30, 17, C.amber);
  868. dimText(ctx,'тяни катушку мышью — отпусти, чтобы качнулась', W/2, 52, 11);
  869. if(mouse.inside && Math.abs(mouse.x - cx) < 90 && Math.abs(mouse.y - cy) < 100){
  870. ctx.canvas.style.cursor = coilState.dragging ? 'grabbing' : 'grab';
  871. } else {
  872. ctx.canvas.style.cursor = 'default';
  873. }
  874. }, {
  875. height: 360,
  876. onMouseDown: (mouse)=>{
  877. const cx = 900/2 + coilState.x;
  878. if(Math.abs(mouse.x - cx) < 90 && Math.abs(mouse.y - (360/2 - 20)) < 100){
  879. coilState.dragging = true;
  880. coilState.dragOffset = mouse.x - (900/2 + coilState.x);
  881. }
  882. },
  883. onMouseUp: ()=>{ coilState.dragging = false; }
  884. });
  885. /* =========================================================
  886. ГЛАВА 3
  887. ========================================================= */
  888. const faradayScene = makeScene('cv-faraday', (ctx, W, H, t, dt, mouse) => {
  889. clearScreen(ctx, W, H); grid(ctx, W, H);
  890. const cy = H * 0.32;
  891. const on = state.magnetOn;
  892. const coilX = on ? Math.sin(t*2.2) * 60 : 0;
  893. ctx.save();
  894. ctx.fillStyle = on ? 'rgba(184,104,72,.14)' : 'rgba(184,104,72,.05)';
  895. ctx.strokeStyle = on ? C.rust : 'rgba(184,104,72,.3)';
  896. ctx.lineWidth=3;
  897. ctx.shadowColor=C.rust; ctx.shadowBlur = on ? 10 : 0;
  898. ctx.fillRect(160,cy-60,50,120); ctx.strokeRect(160,cy-60,50,120);
  899. ctx.restore();
  900. label(ctx,'N',185,cy,26, on ? C.rust : 'rgba(184,104,72,.4)');
  901. ctx.save();
  902. ctx.fillStyle = on ? 'rgba(138,152,96,.14)' : 'rgba(138,152,96,.05)';
  903. ctx.strokeStyle = on ? C.olive : 'rgba(138,152,96,.3)';
  904. ctx.lineWidth=3;
  905. ctx.shadowColor=C.olive; ctx.shadowBlur = on ? 10 : 0;
  906. ctx.fillRect(W-210,cy-60,50,120); ctx.strokeRect(W-210,cy-60,50,120);
  907. ctx.restore();
  908. label(ctx,'S',W-185,cy,26, on ? C.olive : 'rgba(138,152,96,.4)');
  909. const cx = W/2 + coilX;
  910. ctx.save();
  911. ctx.strokeStyle=C.gold; ctx.lineWidth=3;
  912. ctx.shadowColor=C.gold; ctx.shadowBlur=10;
  913. for(let i=0;i<6;i++){
  914. const lx = cx - 45 + i*(90/5);
  915. ctx.beginPath(); ctx.ellipse(lx, cy, 7, 46, 0, 0, Math.PI*2); ctx.stroke();
  916. }
  917. ctx.restore();
  918. const gTop = H*.62, gBot = H - 40, gMid = (gTop+gBot)/2;
  919. ctx.save();
  920. ctx.strokeStyle='rgba(168,120,138,.28)'; ctx.lineWidth=1.5;
  921. ctx.strokeRect(80, gTop, W-160, gBot-gTop);
  922. ctx.beginPath(); ctx.moveTo(80,gMid); ctx.lineTo(W-80,gMid); ctx.stroke();
  923. ctx.restore();
  924. ctx.save();
  925. ctx.strokeStyle = on ? C.khaki : 'rgba(184,200,120,.25)';
  926. ctx.lineWidth = 2.4;
  927. ctx.shadowColor = C.khaki; ctx.shadowBlur = on ? 10 : 0;
  928. ctx.beginPath();
  929. for(let x=0;x<=W-160;x++){
  930. const u = x/(W-160);
  931. const amp = on ? 40 : 3;
  932. const y = gMid - Math.cos((t*2.2 + u*3.6*Math.PI))*amp;
  933. if(x===0) ctx.moveTo(80+x, y); else ctx.lineTo(80+x, y);
  934. }
  935. ctx.stroke();
  936. ctx.restore();
  937. dimText(ctx,'ЭДС индукции', 92, gTop+16, 11, 'left');
  938. dimText(ctx,'время →', W-92, gBot-8, 11, 'right');
  939. label(ctx,'ε = − dΦ / dt', W-150, gTop+20, 18, C.terra);
  940. label(ctx,'ЗАКОН ЭЛЕКТРОМАГНИТНОЙ ИНДУКЦИИ', W/2, 30, 17, C.amber);
  941. const btnW = 180, btnH = 36;
  942. const bx = W/2 - btnW/2, by = H - 66;
  943. const hover = mouse.inside && mouse.x > bx && mouse.x < bx+btnW && mouse.y > by && mouse.y < by+btnH;
  944. ctx.save();
  945. ctx.fillStyle = on ? C.rust : 'rgba(184,104,72,.4)';
  946. if(hover) ctx.fillStyle = on ? '#d88858' : 'rgba(184,104,72,.55)';
  947. rr(ctx, bx, by, btnW, btnH, 4);
  948. ctx.fill();
  949. ctx.strokeStyle = '#1a1005'; ctx.lineWidth = 2; ctx.stroke();
  950. ctx.fillStyle = '#f0f8e0';
  951. ctx.font = "bold 13px 'Courier New',monospace";
  952. ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  953. ctx.fillText(on ? 'МАГНИТ: ВКЛ' : 'МАГНИТ: ВЫКЛ', W/2, by + btnH/2);
  954. ctx.restore();
  955. ctx.canvas.style.cursor = hover ? 'pointer' : 'default';
  956. }, {
  957. height: 340,
  958. onMouseDown: (mouse)=>{
  959. const btnW = 180, btnH = 36;
  960. const bx = 900/2 - btnW/2, by = 340 - 66;
  961. if(mouse.x > bx && mouse.x < bx+btnW && mouse.y > by && mouse.y < by+btnH){
  962. state.magnetOn = !state.magnetOn;
  963. }
  964. }
  965. });
  966. /* =========================================================
  967. ГЛАВА 4
  968. ========================================================= */
  969. const signalScene = makeScene('cv-signal', (ctx, W, H, t, dt, mouse) => {
  970. clearScreen(ctx, W, H); grid(ctx, W, H);
  971. const cy = H/2 - 10;
  972. ctx.save();
  973. ctx.strokeStyle='rgba(212,168,96,.35)'; ctx.lineWidth=1.5;
  974. ctx.beginPath(); ctx.moveTo(50,cy); ctx.lineTo(W-50,cy); ctx.stroke();
  975. ctx.restore();
  976. const targetMix = state.noise ? 1 : 0;
  977. state.noiseMix += (targetMix - state.noiseMix) * 0.1;
  978. const gain = 20 + state.gain * 100;
  979. ctx.save();
  980. ctx.strokeStyle = C.khaki; ctx.lineWidth = 2.4;
  981. ctx.shadowColor = C.khaki; ctx.shadowBlur = 12;
  982. ctx.beginPath();
  983. for(let x=50;x<=W-50;x++){
  984. const u = (x-50)/(W-100);
  985. const env = Math.exp(-Math.pow((u-.5)*2.4, 2));
  986. let v = env*(
  987. Math.sin(u*32 - t*9)*.55 +
  988. Math.sin(u*74 - t*14)*.28 +
  989. Math.sin(u*14 - t*5)*.45);
  990. v += (Math.random()-0.5) * 0.8 * state.noiseMix;
  991. const y = cy - gain * v;
  992. if(x===50) ctx.moveTo(x,y); else ctx.lineTo(x,y);
  993. }
  994. ctx.stroke();
  995. ctx.restore();
  996. label(ctx,'ЭЛЕКТРИЧЕСКИЙ СИГНАЛ', W/2, 30, 18, C.amber);
  997. dimText(ctx, '~ милливольты · точная копия голоса', W/2, 52, 11);
  998. const sbX = 100, sbY = H - 55, sbW = 280, sbH = 8;
  999. ctx.save();
  1000. ctx.fillStyle = 'rgba(212,168,96,.15)';
  1001. ctx.fillRect(sbX, sbY, sbW, sbH);
  1002. ctx.fillStyle = C.khaki;
  1003. ctx.fillRect(sbX, sbY, sbW * state.gain, sbH);
  1004. const hx = sbX + sbW * state.gain;
  1005. ctx.fillStyle = C.gold;
  1006. ctx.beginPath(); ctx.arc(hx, sbY + sbH/2, 9, 0, Math.PI*2); ctx.fill();
  1007. ctx.strokeStyle = '#1a1005'; ctx.lineWidth = 2; ctx.stroke();
  1008. ctx.restore();
  1009. dimText(ctx, 'усиление', sbX + sbW/2, sbY + 26, 11);
  1010. const nbtnW = 100, nbtnH = 32;
  1011. const nbx = W - 200, nby = H - 66;
  1012. const hoverN = mouse.inside && mouse.x > nbx && mouse.x < nbx+nbtnW && mouse.y > nby && mouse.y < nby+nbtnH;
  1013. ctx.save();
  1014. ctx.fillStyle = state.noise ? C.terra : 'rgba(212,168,96,.15)';
  1015. if(hoverN) ctx.fillStyle = state.noise ? '#d88858' : 'rgba(212,168,96,.28)';
  1016. rr(ctx, nbx, nby, nbtnW, nbtnH, 4);
  1017. ctx.fill();
  1018. ctx.strokeStyle = '#1a1005'; ctx.lineWidth = 2; ctx.stroke();
  1019. ctx.fillStyle = state.noise ? '#f0f8e0' : C.amber;
  1020. ctx.font = "bold 12px 'Courier New',monospace";
  1021. ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  1022. ctx.fillText('ШУМ: ' + (state.noise ? 'ВКЛ' : 'ВЫКЛ'), nbx + nbtnW/2, nby + nbtnH/2);
  1023. ctx.restore();
  1024. const overSlider = mouse.inside && mouse.y > sbY - 14 && mouse.y < sbY + 20;
  1025. if(overSlider){
  1026. ctx.canvas.style.cursor = 'ew-resize';
  1027. if(mouse.down){
  1028. state.gain = Math.max(0, Math.min(1, (mouse.x - sbX) / sbW));
  1029. }
  1030. } else if(hoverN){
  1031. ctx.canvas.style.cursor = 'pointer';
  1032. } else {
  1033. ctx.canvas.style.cursor = 'default';
  1034. }
  1035. }, {
  1036. height: 280,
  1037. onMouseDown: (mouse)=>{
  1038. const nbtnW = 100, nbtnH = 32;
  1039. const nbx = 900 - 200, nby = 280 - 66;
  1040. if(mouse.x > nbx && mouse.x < nbx+nbtnW && mouse.y > nby && mouse.y < nby+nbtnH){
  1041. state.noise = !state.noise;
  1042. }
  1043. }
  1044. });
  1045. /* =========================================================
  1046. ГЛАВА 5 — магниты разнесены, катушка движется синхронно с мембраной
  1047. ========================================================= */
  1048. const assemblyScene = makeScene('cv-assembly', (ctx, W, H, t) => {
  1049. clearScreen(ctx, W, H); grid(ctx, W, H);
  1050. const freq = state.asm.freq;
  1051. const vol = state.asm.volume;
  1052. const gain = state.asm.gain;
  1053. const amp = 5 + vol * 18;
  1054. const waveSpeed = 2.5;
  1055. // ============ ВЕРХНИЙ УРОВЕНЬ: АКУСТИКА ============
  1056. const topCy = H * 0.30;
  1057. const speakerX = 70;
  1058. const memX = 260;
  1059. const magX1 = 340;
  1060. const magX2 = 560;
  1061. const coilX = (magX1 + magX2) / 2;
  1062. const magW = 28; // ширина полюсного блока
  1063. const magH = 130; // высота полюсного блока
  1064. // Динамик
  1065. ctx.save();
  1066. ctx.strokeStyle = C.amber; ctx.lineWidth = 2.5;
  1067. ctx.shadowColor = C.amber; ctx.shadowBlur = 8;
  1068. ctx.fillStyle = 'rgba(212,168,96,.07)';
  1069. ctx.beginPath();
  1070. ctx.moveTo(30, topCy-35); ctx.lineTo(55, topCy-35);
  1071. ctx.lineTo(75, topCy-55); ctx.lineTo(75, topCy+55);
  1072. ctx.lineTo(55, topCy+35); ctx.lineTo(30, topCy+35);
  1073. ctx.closePath(); ctx.fill(); ctx.stroke();
  1074. ctx.restore();
  1075. // Молекулы
  1076. const N = 20;
  1077. for(let i = 0; i < N; i++){
  1078. const u = i / (N - 1);
  1079. const baseX = speakerX + (memX - speakerX) * u;
  1080. const v = waveValue(baseX, speakerX, memX, freq, t, waveSpeed);
  1081. const disp = amp * v;
  1082. const x = baseX + disp;
  1083. const y = topCy + Math.sin(i * 1.5) * 2;
  1084. const bright = (v + 1) / 2;
  1085. const col = `rgb(${Math.round(180 + bright*60)}, ${Math.round(140 + bright*100)}, ${Math.round(110 + bright*80)})`;
  1086. ctx.fillStyle = col;
  1087. ctx.shadowColor = col;
  1088. ctx.shadowBlur = 5 + bright * 5;
  1089. ctx.beginPath();
  1090. ctx.arc(x, y, 2 + bright * 1.5, 0, Math.PI*2);
  1091. ctx.fill();
  1092. }
  1093. ctx.shadowBlur = 0;
  1094. // Мембрана
  1095. const memVal = waveValue(memX, speakerX, memX, freq, t, waveSpeed);
  1096. const bow = memVal * amp * 1.2;
  1097. const mTop = topCy - 40, mBot = topCy + 40;
  1098. ctx.save();
  1099. ctx.strokeStyle = C.amber; ctx.lineWidth = 3;
  1100. ctx.shadowColor = C.amber; ctx.shadowBlur = 10;
  1101. ctx.beginPath();
  1102. const SEG = 24;
  1103. for(let i=0;i<=SEG;i++){
  1104. const p = i/SEG;
  1105. const y = mTop + (mBot-mTop)*p;
  1106. const shape = Math.sin(Math.PI*p);
  1107. const x = memX + bow*shape;
  1108. if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
  1109. }
  1110. ctx.stroke();
  1111. ctx.restore();
  1112. // Шток
  1113. ctx.save();
  1114. ctx.strokeStyle = 'rgba(200,120,80,.55)';
  1115. ctx.lineWidth = 2;
  1116. ctx.beginPath();
  1117. ctx.moveTo(memX + bow, topCy);
  1118. ctx.lineTo(coilX - 26, topCy);
  1119. ctx.stroke();
  1120. ctx.restore();
  1121. // Магнитное поле — силовые линии между полюсами
  1122. ctx.save();
  1123. for(let i=0;i<8;i++){
  1124. const y = topCy - magH/2 + 12 + i * ((magH - 24) / 7);
  1125. // Линия
  1126. ctx.strokeStyle = 'rgba(138,180,220,.5)';
  1127. ctx.lineWidth = 1.4;
  1128. ctx.beginPath();
  1129. ctx.moveTo(magX1 + magW, y);
  1130. ctx.lineTo(magX2 - magW, y);
  1131. ctx.stroke();
  1132. // Бегущая стрелка
  1133. const arrowX = magX1 + magW + ((t * 55 + i * 22) % (magX2 - magX1 - magW * 2));
  1134. ctx.strokeStyle = 'rgba(138,180,220,.9)';
  1135. ctx.lineWidth = 1.6;
  1136. ctx.beginPath();
  1137. ctx.moveTo(arrowX, y);
  1138. ctx.lineTo(arrowX - 7, y - 4);
  1139. ctx.moveTo(arrowX, y);
  1140. ctx.lineTo(arrowX - 7, y + 4);
  1141. ctx.stroke();
  1142. }
  1143. ctx.restore();
  1144. // Полюс N
  1145. ctx.save();
  1146. ctx.fillStyle='rgba(184,104,72,.24)'; ctx.strokeStyle=C.rust; ctx.lineWidth=2.4;
  1147. ctx.shadowColor=C.rust; ctx.shadowBlur=10;
  1148. ctx.fillRect(magX1, topCy-magH/2, magW, magH);
  1149. ctx.strokeRect(magX1, topCy-magH/2, magW, magH);
  1150. ctx.restore();
  1151. label(ctx,'N', magX1 + magW/2, topCy, 18, C.rust);
  1152. // Полюс S
  1153. ctx.save();
  1154. ctx.fillStyle='rgba(138,152,96,.24)'; ctx.strokeStyle=C.olive; ctx.lineWidth=2.4;
  1155. ctx.shadowColor=C.olive; ctx.shadowBlur=10;
  1156. ctx.fillRect(magX2 - magW, topCy-magH/2, magW, magH);
  1157. ctx.strokeRect(magX2 - magW, topCy-magH/2, magW, magH);
  1158. ctx.restore();
  1159. label(ctx,'S', magX2 - magW/2, topCy, 18, C.olive);
  1160. // Катушка — движение ровно как у мембраны, без ускорения
  1161. // ограничение, чтобы не выходила за пределы поля
  1162. const maxMotion = (magX2 - magX1 - magW * 2) / 2 - 30;
  1163. const coilMotion = Math.max(-maxMotion, Math.min(maxMotion, bow));
  1164. const coilCx = coilX + coilMotion;
  1165. ctx.save();
  1166. ctx.strokeStyle = C.gold; ctx.lineWidth = 2.6;
  1167. ctx.shadowColor = C.gold; ctx.shadowBlur = 14;
  1168. for(let i=0;i<5;i++){
  1169. const lx = coilCx - 20 + i * (40/4);
  1170. ctx.beginPath();
  1171. ctx.ellipse(lx, topCy, 5, 42, 0, 0, Math.PI*2);
  1172. ctx.stroke();
  1173. }
  1174. ctx.restore();
  1175. // Подписи верхнего ряда
  1176. dimText(ctx, 'ДИНАМИК', speakerX, topCy + 80, 12);
  1177. dimText(ctx, 'ВОЛНА', (speakerX + memX) / 2, topCy + 80, 12);
  1178. dimText(ctx, 'МЕМБРАНА', memX, topCy + 80, 12);
  1179. dimText(ctx, 'КАТУШКА В МАГНИТЕ', coilX, topCy + 80, 12);
  1180. // ============ СТРЕЛКА ВНИЗ ============
  1181. const arrowTop = topCy + 96;
  1182. const arrowBottom = H * 0.63;
  1183. const arrowX = coilX;
  1184. ctx.save();
  1185. ctx.strokeStyle = C.terra;
  1186. ctx.lineWidth = 2.4;
  1187. ctx.shadowColor = C.terra; ctx.shadowBlur = 10;
  1188. ctx.beginPath();
  1189. ctx.moveTo(arrowX, arrowTop);
  1190. ctx.lineTo(arrowX, arrowBottom - 14);
  1191. ctx.stroke();
  1192. ctx.beginPath();
  1193. ctx.moveTo(arrowX, arrowBottom);
  1194. ctx.lineTo(arrowX - 9, arrowBottom - 14);
  1195. ctx.moveTo(arrowX, arrowBottom);
  1196. ctx.lineTo(arrowX + 9, arrowBottom - 14);
  1197. ctx.stroke();
  1198. ctx.restore();
  1199. label(ctx, 'ЭДС', arrowX + 34, (arrowTop + arrowBottom)/2, 13, C.terra);
  1200. // ============ НИЖНИЙ УРОВЕНЬ: ЭЛЕКТРИКА ============
  1201. const botCy = H * 0.83;
  1202. const g1X = 60, g1W = 220, g1H = 90;
  1203. ctx.save();
  1204. ctx.strokeStyle = 'rgba(168,120,138,.4)';
  1205. ctx.lineWidth = 1;
  1206. ctx.strokeRect(g1X, botCy - g1H/2, g1W, g1H);
  1207. ctx.beginPath();
  1208. ctx.moveTo(g1X, botCy);
  1209. ctx.lineTo(g1X + g1W, botCy);
  1210. ctx.stroke();
  1211. ctx.restore();
  1212. ctx.save();
  1213. ctx.strokeStyle = C.khaki;
  1214. ctx.lineWidth = 1.8;
  1215. ctx.shadowColor = C.khaki; ctx.shadowBlur = 6;
  1216. ctx.beginPath();
  1217. for(let i=0;i<=g1W;i++){
  1218. const u = i / g1W;
  1219. const y = botCy - Math.cos((t*2.2 + u*3.6*Math.PI)) * (5 + vol * 8);
  1220. if(i===0) ctx.moveTo(g1X + i, y); else ctx.lineTo(g1X + i, y);
  1221. }
  1222. ctx.stroke();
  1223. ctx.restore();
  1224. dimText(ctx, 'ЭДС с катушки', g1X + g1W/2, botCy - g1H/2 - 14, 12);
  1225. const ampX = 350, ampY = botCy;
  1226. ctx.save();
  1227. ctx.strokeStyle = C.terra;
  1228. ctx.lineWidth = 2.8;
  1229. ctx.fillStyle = 'rgba(200,120,80,.15)';
  1230. ctx.shadowColor = C.terra; ctx.shadowBlur = 14;
  1231. ctx.beginPath();
  1232. ctx.moveTo(ampX - 26, ampY - 34);
  1233. ctx.lineTo(ampX + 26, ampY);
  1234. ctx.lineTo(ampX - 26, ampY + 34);
  1235. ctx.closePath();
  1236. ctx.fill();
  1237. ctx.stroke();
  1238. ctx.restore();
  1239. label(ctx, 'УСИЛ', ampX - 6, ampY, 14, C.terra);
  1240. dimText(ctx, '×' + (0.2 + gain * 1.5).toFixed(1), ampX, ampY + 50, 13);
  1241. ctx.save();
  1242. ctx.strokeStyle = C.terra;
  1243. ctx.lineWidth = 2.2;
  1244. ctx.beginPath();
  1245. ctx.moveTo(g1X + g1W + 8, botCy);
  1246. ctx.lineTo(ampX - 30, botCy);
  1247. ctx.stroke();
  1248. ctx.beginPath();
  1249. ctx.moveTo(ampX - 30, botCy);
  1250. ctx.lineTo(ampX - 42, botCy - 6);
  1251. ctx.moveTo(ampX - 30, botCy);
  1252. ctx.lineTo(ampX - 42, botCy + 6);
  1253. ctx.stroke();
  1254. ctx.restore();
  1255. const g2X = 410, g2W = W - 410 - 60, g2H = 110;
  1256. ctx.save();
  1257. ctx.strokeStyle = C.terra;
  1258. ctx.lineWidth = 2.2;
  1259. ctx.beginPath();
  1260. ctx.moveTo(ampX + 30, botCy);
  1261. ctx.lineTo(g2X - 8, botCy);
  1262. ctx.stroke();
  1263. ctx.beginPath();
  1264. ctx.moveTo(g2X - 8, botCy);
  1265. ctx.lineTo(g2X - 20, botCy - 6);
  1266. ctx.moveTo(g2X - 8, botCy);
  1267. ctx.lineTo(g2X - 20, botCy + 6);
  1268. ctx.stroke();
  1269. ctx.restore();
  1270. const gainMul = 0.2 + gain * 1.5;
  1271. ctx.save();
  1272. ctx.strokeStyle = 'rgba(168,120,138,.4)';
  1273. ctx.lineWidth = 1;
  1274. ctx.strokeRect(g2X, botCy - g2H/2, g2W, g2H);
  1275. ctx.beginPath();
  1276. ctx.moveTo(g2X, botCy);
  1277. ctx.lineTo(g2X + g2W, botCy);
  1278. ctx.stroke();
  1279. ctx.restore();
  1280. ctx.save();
  1281. ctx.strokeStyle = C.gold;
  1282. ctx.lineWidth = 2.6;
  1283. ctx.shadowColor = C.gold; ctx.shadowBlur = 14;
  1284. ctx.beginPath();
  1285. for(let i=0;i<=g2W;i++){
  1286. const u = i / g2W;
  1287. const y = botCy - Math.cos((t*2.2 + u*3.6*Math.PI)) * (7 + vol * 14) * gainMul;
  1288. if(i===0) ctx.moveTo(g2X + i, y); else ctx.lineTo(g2X + i, y);
  1289. }
  1290. ctx.stroke();
  1291. ctx.restore();
  1292. dimText(ctx, 'ВЫХОДНОЙ СИГНАЛ', g2X + g2W/2, botCy - g2H/2 - 14, 12);
  1293. dimText(ctx, '×' + gainMul.toFixed(1), g2X + g2W/2, botCy + g2H/2 + 16, 12);
  1294. // Заголовок
  1295. label(ctx, 'ПОЛНАЯ СБОРКА', W/2, 22, 15, C.amber);
  1296. dimText(ctx, 'сверху — акустика, снизу — электрика', W/2, 42, 11);
  1297. }, { height: 380 });
  1298. /* =========================================================
  1299. ГЛАВА 6: ЛАБОРАТОРИЯ
  1300. ========================================================= */
  1301. const SPEC_BARS = 96;
  1302. const SPEC_W = 780;
  1303. const SPEC_H = 70;
  1304. const spectroCanvas = document.createElement('canvas');
  1305. spectroCanvas.width = SPEC_W;
  1306. spectroCanvas.height = SPEC_H;
  1307. const spectroCtx = spectroCanvas.getContext('2d', { willReadFrequently: true });
  1308. spectroCtx.fillStyle = '#0a0603';
  1309. spectroCtx.fillRect(0, 0, SPEC_W, SPEC_H);
  1310. let spectroAccum = 0;
  1311. const SPECTRO_INTERVAL = 1/30;
  1312. function spectroColor(v){
  1313. v = Math.max(0, Math.min(1, v));
  1314. const stops = [
  1315. [0.00,[10,6,3]],[0.10,[26,10,5]],[0.25,[74,32,16]],
  1316. [0.45,[138,64,32]],[0.65,[200,120,80]],[0.80,[212,168,96]],
  1317. [0.92,[240,216,144]],[1.00,[255,248,224]]
  1318. ];
  1319. for(let i=1;i<stops.length;i++){
  1320. if(v <= stops[i][0]){
  1321. const t = (v - stops[i-1][0]) / (stops[i][0] - stops[i-1][0]);
  1322. const a = stops[i-1][1], b = stops[i][1];
  1323. 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)})`;
  1324. }
  1325. }
  1326. return 'rgb(255,248,224)';
  1327. }
  1328. function updateSpectrogram(barValues){
  1329. const img = spectroCtx.getImageData(0, 0, SPEC_W, SPEC_H - 1);
  1330. spectroCtx.putImageData(img, 0, 1);
  1331. const barW = SPEC_W / barValues.length;
  1332. for(let i = 0; i < barValues.length; i++){
  1333. spectroCtx.fillStyle = spectroColor(barValues[i]);
  1334. spectroCtx.fillRect(Math.floor(i*barW), 0, Math.ceil(barW)+1, 1);
  1335. }
  1336. }
  1337. let wavePhase = 0;
  1338. function drawLabScheme(ctx, W, t, sig){
  1339. const cy = 145;
  1340. const amp = 4 + sig.amp * 22;
  1341. const waveSpeed = 2.5;
  1342. const speakerX = 70;
  1343. const memX = 360;
  1344. const magX1 = 400;
  1345. const magX2 = 500;
  1346. const coilX = (magX1 + magX2) / 2;
  1347. const graphX = 560;
  1348. const graphW = 280;
  1349. const syllable = (i)=> 0.55 + 0.45 * Math.abs(Math.sin(state.syllablePhase + i * 0.5));
  1350. ctx.save();
  1351. ctx.strokeStyle = C.amber; ctx.lineWidth = 2;
  1352. ctx.shadowColor = C.amber; ctx.shadowBlur = sig.energy * 12 + 3;
  1353. ctx.fillStyle = 'rgba(212,168,96,.07)';
  1354. ctx.beginPath();
  1355. ctx.moveTo(30, cy-35); ctx.lineTo(55, cy-35);
  1356. ctx.lineTo(75, cy-55); ctx.lineTo(75, cy+55);
  1357. ctx.lineTo(55, cy+35); ctx.lineTo(30, cy+35);
  1358. ctx.closePath(); ctx.fill(); ctx.stroke();
  1359. ctx.restore();
  1360. const N = 26;
  1361. for(let i = 0; i < N; i++){
  1362. const u = i / (N - 1);
  1363. const baseX = speakerX + (memX - speakerX) * u;
  1364. const v = waveValue(baseX, speakerX, memX, 4, t, waveSpeed);
  1365. const disp = amp * v * syllable(i);
  1366. const x = baseX + disp;
  1367. const y = cy + Math.sin(i * 1.5) * 2;
  1368. const bright = (v + 1) / 2 * (0.25 + 0.75 * sig.energy);
  1369. ctx.fillStyle = `rgb(${Math.round(110 + bright*120)}, ${Math.round(110 + bright*110)}, ${Math.round(80 + bright*80)})`;
  1370. ctx.shadowColor = 'rgba(200,160,80,' + bright + ')';
  1371. ctx.shadowBlur = 4 * bright;
  1372. ctx.beginPath();
  1373. ctx.arc(x, y, 1.5 + bright * 1.5, 0, Math.PI*2);
  1374. ctx.fill();
  1375. }
  1376. ctx.shadowBlur = 0;
  1377. const memVal = waveValue(memX, speakerX, memX, 4, t, waveSpeed);
  1378. const bow = memVal * amp * 1.2;
  1379. ctx.save();
  1380. ctx.strokeStyle = C.amber; ctx.lineWidth = 2.5;
  1381. ctx.shadowColor = C.amber; ctx.shadowBlur = sig.energy * 14 + 3;
  1382. ctx.beginPath();
  1383. const SEG = 24;
  1384. for(let i=0;i<=SEG;i++){
  1385. const p = i/SEG;
  1386. const y = cy - 45 + 90*p;
  1387. const shape = Math.sin(Math.PI*p);
  1388. const x = memX + bow*shape;
  1389. if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
  1390. }
  1391. ctx.stroke();
  1392. ctx.restore();
  1393. ctx.save();
  1394. ctx.strokeStyle = 'rgba(200,120,80,.5)';
  1395. ctx.lineWidth = 1.5;
  1396. ctx.beginPath();
  1397. ctx.moveTo(memX + bow, cy);
  1398. ctx.lineTo(coilX - 22, cy);
  1399. ctx.stroke();
  1400. ctx.restore();
  1401. ctx.save();
  1402. ctx.fillStyle='rgba(184,104,72,.16)'; ctx.strokeStyle=C.rust; ctx.lineWidth=1.5;
  1403. ctx.fillRect(magX1, cy-50, 20, 100);
  1404. ctx.strokeRect(magX1, cy-50, 20, 100);
  1405. ctx.restore();
  1406. label(ctx,'N', magX1+10, cy, 14, C.rust);
  1407. ctx.save();
  1408. ctx.fillStyle='rgba(138,152,96,.16)'; ctx.strokeStyle=C.olive; ctx.lineWidth=1.5;
  1409. ctx.fillRect(magX2-20, cy-50, 20, 100);
  1410. ctx.strokeRect(magX2-20, cy-50, 20, 100);
  1411. ctx.restore();
  1412. label(ctx,'S', magX2-10, cy, 14, C.olive);
  1413. const coilCx = coilX + bow * 0.6;
  1414. ctx.save();
  1415. ctx.strokeStyle = C.gold; ctx.lineWidth = 2;
  1416. ctx.shadowColor = C.gold; ctx.shadowBlur = sig.energy * 10 + 3;
  1417. for(let i=0;i<4;i++){
  1418. const lx = coilCx - 18 + i * (36/3);
  1419. ctx.beginPath();
  1420. ctx.ellipse(lx, cy, 4, 40, 0, 0, Math.PI*2);
  1421. ctx.stroke();
  1422. }
  1423. ctx.restore();
  1424. ctx.save();
  1425. ctx.strokeStyle = 'rgba(168,120,138,.3)';
  1426. ctx.lineWidth = 1;
  1427. ctx.strokeRect(graphX, cy - 45, graphW, 90);
  1428. ctx.beginPath();
  1429. ctx.moveTo(graphX, cy);
  1430. ctx.lineTo(graphX + graphW, cy);
  1431. ctx.stroke();
  1432. ctx.restore();
  1433. ctx.save();
  1434. ctx.strokeStyle = C.khaki;
  1435. ctx.lineWidth = 1.8;
  1436. ctx.shadowColor = C.khaki; ctx.shadowBlur = sig.energy > 0.05 ? 8 : 0;
  1437. ctx.beginPath();
  1438. for(let i=0;i<=graphW;i++){
  1439. const u = i/graphW;
  1440. const syllableMod = 0.6 + 0.4 * Math.sin(state.syllablePhase + u * 6);
  1441. const y = cy - Math.cos((t*2.2 + u*3.6*Math.PI)) * (3 + sig.amp * 30) * syllableMod;
  1442. if(i===0) ctx.moveTo(graphX + i, y); else ctx.lineTo(graphX + i, y);
  1443. }
  1444. ctx.stroke();
  1445. ctx.restore();
  1446. dimText(ctx, 'ДИНАМИК', speakerX, cy + 78, 10);
  1447. dimText(ctx, 'ВОЛНА', (speakerX + memX) / 2, cy + 78, 10);
  1448. dimText(ctx, 'МЕМБРАНА', memX, cy + 78, 10);
  1449. dimText(ctx, 'КАТУШКА', coilX, cy + 78, 10);
  1450. dimText(ctx, 'ЭДС', graphX + graphW/2, cy + 78, 10);
  1451. }
  1452. const labScene = makeScene('cv-lab', (ctx, W, H, t, dt) => {
  1453. clearScreen(ctx, W, H); grid(ctx, W, H);
  1454. const mode = state.lab.mode;
  1455. let sig = { amp:0, energy:0 };
  1456. if(mode === 'mic' && state.micOn && state.analyser && state.timeData){
  1457. state.analyser.getByteTimeDomainData(state.timeData);
  1458. let sum = 0;
  1459. for(let i=0;i<state.timeData.length;i++){
  1460. const d = (state.timeData[i]-128)/128;
  1461. sum += d*d;
  1462. }
  1463. const rms = Math.sqrt(sum / state.timeData.length);
  1464. sig = {
  1465. amp: Math.min(1, rms * 3.5),
  1466. energy: Math.min(1, rms * 4)
  1467. };
  1468. } else if(mode === 'imitation'){
  1469. sig = {
  1470. amp: state.imit.amp * 0.8,
  1471. energy: state.imit.amp * 0.9
  1472. };
  1473. } else if(mode === 'text'){
  1474. const targetEnergy = currentlySpeaking ? 1 : 0;
  1475. state.speechEnergy += (targetEnergy - state.speechEnergy) * (targetEnergy ? 0.08 : 0.18);
  1476. sig = {
  1477. amp: state.speechEnergy * 0.8,
  1478. energy: state.speechEnergy
  1479. };
  1480. }
  1481. state.syllablePhase += dt * 8;
  1482. let title = '', sub = '';
  1483. if(mode === 'imitation'){
  1484. title = 'ГЕНЕРАТОР СИГНАЛОВ';
  1485. sub = 'ползунки меняют сигнал — схема, осциллограф и спектр реагируют';
  1486. } else if(mode === 'mic'){
  1487. title = state.micOn ? 'ЖИВОЙ СИГНАЛ С МИКРОФОНА' : 'МИКРОФОН НЕ АКТИВЕН';
  1488. sub = state.micOn ? 'говорите — схема работает от голоса' : 'нажмите вкладку «Микрофон»';
  1489. } else {
  1490. title = currentlySpeaking ? 'ОЗВУЧИВАНИЕ ТЕКСТА' : 'РЕЖИМ: ТЕКСТ';
  1491. sub = currentlySpeaking ? 'схема работает от вашей речи'
  1492. : (currentLabText ? 'пауза — схема в покое' : 'напишите фразу ниже');
  1493. }
  1494. label(ctx, title, W/2, 24, 15, C.amber);
  1495. dimText(ctx, sub, W/2, 44, 10);
  1496. drawLabScheme(ctx, W, t, sig);
  1497. const oscTop = 250;
  1498. const oscH = 120;
  1499. const oscCy = oscTop + oscH/2;
  1500. ctx.save();
  1501. ctx.strokeStyle = 'rgba(212,168,96,.28)'; ctx.lineWidth = 1.5;
  1502. ctx.strokeRect(60, oscTop, W-120, oscH);
  1503. ctx.restore();
  1504. const spanX = W - 120;
  1505. const scaleY = oscH * 0.42;
  1506. ctx.save();
  1507. ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
  1508. if(mode === 'mic' && state.micOn && state.analyser && state.timeData){
  1509. ctx.strokeStyle = C.khaki; ctx.shadowColor = C.khaki; ctx.shadowBlur = 10;
  1510. const n = state.timeData.length;
  1511. ctx.beginPath();
  1512. for(let i=0;i<n;i++){
  1513. const x = 60 + (i/(n-1))*spanX;
  1514. const y = oscCy - ((state.timeData[i]-128)/128) * scaleY * 1.3;
  1515. if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
  1516. }
  1517. ctx.stroke();
  1518. } else if(mode === 'imitation'){
  1519. ctx.strokeStyle = C.amber; ctx.shadowColor = C.amber; ctx.shadowBlur = 10;
  1520. wavePhase += dt * (0.6 + state.imit.freq / 90);
  1521. const cycles = 3 + (state.imit.freq / 200);
  1522. ctx.beginPath();
  1523. for(let px=0; px<=spanX; px++){
  1524. const u = px / spanX;
  1525. const phase = u*cycles + wavePhase;
  1526. let v = waveSample(phase, state.imit.wave) * state.imit.amp;
  1527. const y = oscCy - v * scaleY;
  1528. const x = 60 + px;
  1529. if(px===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
  1530. }
  1531. ctx.stroke();
  1532. } else if(mode === 'text' && currentlySpeaking){
  1533. ctx.strokeStyle = C.gold; ctx.shadowColor = C.gold; ctx.shadowBlur = 10;
  1534. wavePhase += dt * 22;
  1535. const cycles = 10;
  1536. ctx.beginPath();
  1537. for(let px=0; px<=spanX; px++){
  1538. const u = px / spanX;
  1539. const phase = u*cycles + wavePhase*0.18;
  1540. let v = waveSample(phase, 'voice');
  1541. const syllable = 0.45 + 0.55*Math.abs(Math.sin(t*7 + u*2));
  1542. const wordEnv = 0.7 + 0.3*Math.sin(t*1.4);
  1543. v *= syllable * wordEnv * 0.8;
  1544. const y = oscCy - v * scaleY;
  1545. const x = 60 + px;
  1546. if(px===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
  1547. }
  1548. ctx.stroke();
  1549. } else {
  1550. ctx.strokeStyle = 'rgba(212,168,96,.3)';
  1551. ctx.shadowColor = C.amber; ctx.shadowBlur = 4;
  1552. ctx.beginPath();
  1553. for(let px=0; px<=spanX; px++){
  1554. const u = px / spanX;
  1555. const v = 0.006*Math.sin(u*40 + t*3) + (Math.random()-0.5)*0.006;
  1556. const y = oscCy - v * scaleY;
  1557. const x = 60 + px;
  1558. if(px===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
  1559. }
  1560. ctx.stroke();
  1561. }
  1562. ctx.restore();
  1563. dimText(ctx, 'осциллограф', 70, oscTop - 8, 10, 'left');
  1564. const specTop = oscTop + oscH + 12;
  1565. const specX = 60, specW = W - 120;
  1566. if(mode === 'mic' && state.micOn && state.analyser) state.analyser.getByteFrequencyData(state.freqData);
  1567. const barValues = new Array(SPEC_BARS);
  1568. for(let i=0;i<SPEC_BARS;i++){
  1569. let v = 0;
  1570. if(mode === 'mic' && state.micOn && state.analyser && state.freqData){
  1571. v = state.freqData[Math.floor((i/SPEC_BARS)*(state.freqData.length*0.6))] / 255;
  1572. } else if(mode === 'imitation'){
  1573. const center = (state.imit.freq/400) * SPEC_BARS;
  1574. const d = Math.abs(i - center);
  1575. v = Math.exp(-d*d/30) * state.imit.amp * (0.85 + 0.15*Math.sin(t*8 + i*0.3)) + Math.random()*0.08;
  1576. } else if(mode === 'text' && currentlySpeaking){
  1577. v = Math.abs(Math.sin(i*0.28 + t*9)) * 0.55 + Math.random()*0.2;
  1578. } else {
  1579. v = Math.random()*0.02;
  1580. }
  1581. v = Math.max(v, 0.012 + Math.random()*0.015);
  1582. barValues[i] = Math.max(0, Math.min(1, v));
  1583. }
  1584. spectroAccum += dt;
  1585. if(spectroAccum >= SPECTRO_INTERVAL){
  1586. spectroAccum = 0;
  1587. updateSpectrogram(barValues);
  1588. }
  1589. ctx.save();
  1590. ctx.imageSmoothingEnabled = false;
  1591. ctx.drawImage(spectroCanvas, specX, specTop, specW, SPEC_H);
  1592. ctx.restore();
  1593. ctx.save();
  1594. ctx.strokeStyle = 'rgba(212,168,96,.45)'; ctx.lineWidth = 1;
  1595. ctx.strokeRect(specX, specTop, specW, SPEC_H);
  1596. ctx.restore();
  1597. dimText(ctx,'спектрограмма · X — частота · Y — время ↓', W/2, specTop + SPEC_H + 12, 10);
  1598. }, { height: 500 });
  1599. /* =========================================================
  1600. ПЕРЕКЛЮЧЕНИЕ ВКЛАДОК
  1601. ========================================================= */
  1602. document.querySelectorAll('.lab-tab').forEach(tab=>{
  1603. tab.onclick = ()=>{
  1604. document.querySelectorAll('.lab-tab').forEach(t=>t.classList.remove('active'));
  1605. tab.classList.add('active');
  1606. state.lab.mode = tab.dataset.mode;
  1607. document.getElementById('panel-imitation').classList.toggle('on', state.lab.mode==='imitation');
  1608. document.getElementById('panel-mic').classList.toggle('on', state.lab.mode==='mic');
  1609. document.getElementById('panel-text').classList.toggle('on', state.lab.mode==='text');
  1610. if(state.lab.mode !== 'mic'){ disableMic(); stopRecording(); }
  1611. if(state.lab.mode === 'mic'){ enableMic(); }
  1612. if(state.lab.mode === 'text'){
  1613. setTimeout(()=>document.getElementById('textInput').focus(), 80);
  1614. }
  1615. stopSpeech();
  1616. currentLabText = '';
  1617. state.speechEnergy = 0;
  1618. };
  1619. });
  1620. /* =========================================================
  1621. МИКРОФОН
  1622. ========================================================= */
  1623. async function enableMic(){
  1624. if(state.micOn) return;
  1625. if(state.isFileProtocol){
  1626. alert('Микрофон недоступен со страницы-файла. Используйте localhost или https.');
  1627. document.querySelector('.lab-tab[data-mode="imitation"]').click();
  1628. return;
  1629. }
  1630. if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
  1631. alert('Микрофон не поддерживается');
  1632. document.querySelector('.lab-tab[data-mode="imitation"]').click();
  1633. return;
  1634. }
  1635. try{
  1636. state.micStream = await navigator.mediaDevices.getUserMedia({ audio:true });
  1637. state.audioCtx = state.audioCtx || new (window.AudioContext||window.webkitAudioContext)();
  1638. if(state.audioCtx.state === 'suspended') await state.audioCtx.resume();
  1639. const src = state.audioCtx.createMediaStreamSource(state.micStream);
  1640. state.analyser = state.audioCtx.createAnalyser();
  1641. state.analyser.fftSize = 2048;
  1642. state.analyser.smoothingTimeConstant = .55;
  1643. src.connect(state.analyser);
  1644. state.timeData = new Uint8Array(state.analyser.fftSize);
  1645. state.freqData = new Uint8Array(state.analyser.frequencyBinCount);
  1646. state.micOn = true;
  1647. }catch(e){
  1648. alert('Микрофон недоступен: ' + e.message);
  1649. document.querySelector('.lab-tab[data-mode="imitation"]').click();
  1650. }
  1651. }
  1652. function disableMic(){
  1653. if(state.micStream) state.micStream.getTracks().forEach(x=>x.stop());
  1654. state.micStream = null; state.analyser = null; state.micOn = false;
  1655. document.getElementById('levelVal').textContent = '0%';
  1656. }
  1657. function startRecording(){
  1658. if(!state.micStream) return;
  1659. try{
  1660. state.recordedChunks = [];
  1661. const mime = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' :
  1662. MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' : '';
  1663. state.mediaRecorder = new MediaRecorder(state.micStream, mime ? { mimeType:mime } : undefined);
  1664. state.mediaRecorder.ondataavailable = e => { if(e.data.size) state.recordedChunks.push(e.data); };
  1665. state.mediaRecorder.onstop = () => {
  1666. const blob = new Blob(state.recordedChunks, { type: state.recordedChunks[0]?.type || 'audio/webm' });
  1667. if(state.recordedUrl) URL.revokeObjectURL(state.recordedUrl);
  1668. state.recordedUrl = URL.createObjectURL(blob);
  1669. document.getElementById('playBtn').disabled = false;
  1670. };
  1671. state.mediaRecorder.start();
  1672. document.getElementById('recBtn').classList.add('on');
  1673. document.getElementById('recBtn').textContent = '■ СТОП';
  1674. }catch(e){}
  1675. }
  1676. function stopRecording(){
  1677. if(state.mediaRecorder && state.mediaRecorder.state !== 'inactive'){
  1678. state.mediaRecorder.stop();
  1679. document.getElementById('recBtn').classList.remove('on');
  1680. document.getElementById('recBtn').textContent = '● ЗАПИСАТЬ';
  1681. }
  1682. }
  1683. document.getElementById('recBtn').onclick = ()=>{
  1684. if(state.mediaRecorder && state.mediaRecorder.state === 'recording') stopRecording();
  1685. else startRecording();
  1686. };
  1687. document.getElementById('playBtn').onclick = ()=>{
  1688. if(!state.recordedUrl) return;
  1689. new Audio(state.recordedUrl).play();
  1690. };
  1691. /* =========================================================
  1692. ПОЛЗУНКИ
  1693. ========================================================= */
  1694. const chainFreq = document.getElementById('chainFreq');
  1695. const chainVol = document.getElementById('chainVol');
  1696. chainFreq.oninput = ()=>{
  1697. state.chain.freq = +chainFreq.value;
  1698. document.getElementById('chainFreqVal').textContent = chainFreq.value;
  1699. };
  1700. chainVol.oninput = ()=>{
  1701. state.chain.volume = +chainVol.value / 100;
  1702. document.getElementById('chainVolVal').textContent = chainVol.value + '%';
  1703. };
  1704. const asmFreq = document.getElementById('asmFreq');
  1705. const asmVol = document.getElementById('asmVol');
  1706. const asmGain = document.getElementById('asmGain');
  1707. asmFreq.oninput = ()=>{
  1708. state.asm.freq = +asmFreq.value;
  1709. document.getElementById('asmFreqVal').textContent = asmFreq.value;
  1710. };
  1711. asmVol.oninput = ()=>{
  1712. state.asm.volume = +asmVol.value / 100;
  1713. document.getElementById('asmVolVal').textContent = asmVol.value + '%';
  1714. };
  1715. asmGain.oninput = ()=>{
  1716. state.asm.gain = +asmGain.value / 100;
  1717. document.getElementById('asmGainVal').textContent = asmGain.value + '%';
  1718. };
  1719. const labFreqRange = document.getElementById('labFreqRange');
  1720. const labAmpRange = document.getElementById('labAmpRange');
  1721. const labWaveSelect= document.getElementById('labWaveSelect');
  1722. labFreqRange.oninput = ()=>{
  1723. state.imit.freq = +labFreqRange.value;
  1724. document.getElementById('labFreqVal').textContent = labFreqRange.value;
  1725. };
  1726. labAmpRange.oninput = ()=>{
  1727. state.imit.amp = +labAmpRange.value / 100;
  1728. document.getElementById('labAmpVal').textContent = labAmpRange.value + '%';
  1729. };
  1730. labWaveSelect.onchange = ()=>{ state.imit.wave = labWaveSelect.value; };
  1731. /* =========================================================
  1732. ВВОД ТЕКСТА
  1733. ========================================================= */
  1734. const textInput = document.getElementById('textInput');
  1735. const speakInputBtn = document.getElementById('speakInputBtn');
  1736. function speakUserPhrase(){
  1737. const txt = textInput.value.trim();
  1738. if(!txt) return;
  1739. stopSpeech();
  1740. state.speechEnergy = 0;
  1741. currentLabText = txt;
  1742. speakText(txt, ()=>{});
  1743. }
  1744. speakInputBtn.onclick = speakUserPhrase;
  1745. textInput.addEventListener('keydown', e=>{
  1746. if(e.key === 'Enter'){ e.preventDefault(); speakUserPhrase(); }
  1747. });
  1748. document.querySelectorAll('#panel-text .chip').forEach(ch=>{
  1749. ch.onclick = ()=>{ textInput.value = ch.dataset.phrase; speakUserPhrase(); };
  1750. });
  1751. /* =========================================================
  1752. ШАПКА
  1753. ========================================================= */
  1754. const voiceToggle = document.getElementById('voiceToggle');
  1755. voiceToggle.onclick = ()=>{
  1756. voiceOn = !voiceOn;
  1757. voiceToggle.textContent = voiceOn ? '🔊 ГОЛОС' : '🔇 ВЫКЛ';
  1758. voiceToggle.classList.toggle('active', voiceOn);
  1759. if(!voiceOn){ stopSpeech(); state.speechEnergy = 0; }
  1760. };
  1761. const stopBtn = document.getElementById('stopBtn');
  1762. stopBtn.onclick = ()=>{
  1763. stopSpeech();
  1764. currentLabText = '';
  1765. state.speechEnergy = 0;
  1766. };
  1767. const readSceneBtn = document.getElementById('readSceneBtn');
  1768. function getCurrentScene(){
  1769. const scenes = document.querySelectorAll('.hero, .scene');
  1770. const marker = window.scrollY + window.innerHeight * 0.4;
  1771. let current = scenes[0];
  1772. for(const s of scenes){
  1773. if(s.offsetTop <= marker) current = s;
  1774. }
  1775. return current;
  1776. }
  1777. readSceneBtn.onclick = ()=>{
  1778. const scene = getCurrentScene();
  1779. if(!scene) return;
  1780. const parts = [];
  1781. const title = scene.querySelector('h1, .scene-title');
  1782. if(title) parts.push(title.textContent.trim());
  1783. scene.querySelectorAll('.scene-text p, .hero .howto').forEach(p=>{
  1784. parts.push(p.textContent.trim());
  1785. });
  1786. const text = parts.join(' ');
  1787. if(!text) return;
  1788. stopSpeech();
  1789. speakText(text);
  1790. };
  1791. /* =========================================================
  1792. ПРОГРЕСС + ПОЯВЛЕНИЕ
  1793. ========================================================= */
  1794. const scrollProgress = document.getElementById('scrollProgress');
  1795. const scrollTopBtn = document.getElementById('scrollTopBtn');
  1796. window.addEventListener('scroll', ()=>{
  1797. const h = document.documentElement;
  1798. const scrolled = h.scrollTop / (h.scrollHeight - h.clientHeight);
  1799. scrollProgress.style.width = (scrolled * 100) + '%';
  1800. }, { passive:true });
  1801. scrollTopBtn.onclick = ()=>window.scrollTo({top:0, behavior:'smooth'});
  1802. const sectionObserver = new IntersectionObserver(entries => {
  1803. for(const e of entries){
  1804. if(e.isIntersecting) e.target.classList.add('in-view');
  1805. }
  1806. }, { threshold: 0.15 });
  1807. document.querySelectorAll('.scene').forEach(s => sectionObserver.observe(s));
  1808. document.getElementById('hero').classList.add('in-view');
  1809. window.addEventListener('load', ()=>{
  1810. pickVoice();
  1811. [chainScene, coilScene, faradayScene, signalScene, assemblyScene, labScene]
  1812. .forEach(s => s && s.resize());
  1813. });
  1814. </script>
  1815. </body>
  1816. </html>