Kaya говорит
12:40
осталось

¿Qué has hecho hoy en el recreo?

Что ты делал сегодня на перемене?

// ============================================================ // Живой разговор: микрофон → мост → звук Kaya // // Страница живёт в СИСТЕМНОМ браузере, а не в Telegram: микрофона в его // WebView нет ни одного метода. Токен приходит во фрагменте адреса — // фрагмент не уходит на сервер в заголовке Referer и не оседает в чужих // логах, в отличие от query. // // Без токена страница остаётся показом состояний: по этому же адресу // смотрят, как урок будет выглядеть, и ломать это ради боевого режима // незачем. // ============================================================ var BRIDGE = 'wss://kaya-bot-production.up.railway.app/ws/lesson'; var call = document.getElementById('call'); var stateLabel = document.getElementById('state'); var orb = document.getElementById('orb'); var bar = document.getElementById('bar'); var timeBox = document.getElementById('time'); var scenes = { speaking: document.getElementById('scene-speaking'), listening: document.getElementById('scene-listening'), hint: document.getElementById('scene-hint'), sum: document.getElementById('scene-sum'), }; var WORD = { speaking: 'говорит', listening: 'слушает', thinking: 'думает', hint: 'помогает', sum: 'разбор' }; function scene(name) { call.dataset.state = name; stateLabel.textContent = WORD[name] || ''; var shown = name === 'thinking' ? 'listening' : name; Object.keys(scenes).forEach(function (k) { scenes[k].classList.toggle('hidden', k !== shown); }); orb.classList.toggle('hidden', name === 'sum'); } // ---------- волна ---------- (function () { var wave = document.getElementById('wave'); if (!wave) return; [7,13,20,11,24,16,9,18,26,14,10,21,13,17,9,22].forEach(function (h, i) { var b = document.createElement('i'); b.style.height = h + 'px'; b.style.animationDelay = (i * 60) + 'ms'; wave.appendChild(b); }); })(); function token() { var m = /[#&]t=([^&]+)/.exec(location.hash || ''); return m ? decodeURIComponent(m[1]) : null; } // ============================================================ // Показ состояний — когда токена нет // ============================================================ function demoMode() { var strip = document.querySelector('.demo'); if (strip) strip.hidden = false; document.querySelectorAll('.demo button').forEach(function (b) { b.addEventListener('click', function () { scene(b.dataset.scene); bar.innerHTML = b.dataset.scene === 'sum' ? 'Вернуться в Kaya' : '' + ''; document.querySelectorAll('.demo button').forEach(function (o) { if (o === b) o.setAttribute('aria-current', 'true'); else o.removeAttribute('aria-current'); }); }); }); scene('speaking'); } // ============================================================ // Настоящий разговор // ============================================================ function liveMode(jwt) { var socket = null; var audio = null; // AudioContext var mic = null; // поток микрофона var playAt = 0; // куда ставить следующий кусок Kaya var playing = []; // уже запланированные куски — их и обрываем var started = Date.now(); var ticking = null; var said = document.querySelector('#scene-speaking .said'); var gloss = document.querySelector('#scene-speaking .gloss'); var heard = document.querySelector('#scene-listening .heard__text'); function fail(text) { scene('speaking'); said.textContent = text; gloss.textContent = ''; bar.innerHTML = 'Вернуться в Kaya'; } // ---------- часы ---------- function clock() { ticking = setInterval(function () { var left = Math.max(0, 15 * 60 - Math.round((Date.now() - started) / 1000)); var m = Math.floor(left / 60), sec = left % 60; timeBox.textContent = m + ':' + String(sec).padStart(2, '0'); if (left === 0) stop('time'); }, 1000); } // ---------- воспроизведение ---------- // Куски ставятся в очередь по времени, а не играются по приходу: иначе // между ними слышны щелчки, и речь рассыпается на слоги. function speak(buffer) { if (!audio) return; // Safari отдаёт контекст усыплённым, и тогда звук просто не идёт — // молча, без единой ошибки в консоли. if (audio.state === 'suspended') audio.resume(); if (buffer.byteLength < 2) return; var pcm = new Int16Array(buffer, 0, Math.floor(buffer.byteLength / 2)); var frame = audio.createBuffer(1, pcm.length, 48000); var out = frame.getChannelData(0); for (var i = 0; i < pcm.length; i++) out[i] = pcm[i] / 32768; var node = audio.createBufferSource(); node.buffer = frame; node.connect(audio.destination); var now = audio.currentTime; if (playAt < now) playAt = now + 0.05; node.start(playAt); playAt += frame.duration; playing.push(node); node.onended = function () { var at = playing.indexOf(node); if (at >= 0) playing.splice(at, 1); }; } function hush() { // Ребёнок заговорил — обрываем очередь Kaya. Тянуть её до конца // значит перебивать его собственным голосом. // // Останавливаем сами куски, а не пересоздаём контекст: закрытие // асинхронно, новый контекст на каждое прерывание Safari отдаёт // усыплённым, и звук после первого же прерывания пропадал бы // насовсем. playing.forEach(function (node) { try { node.stop(); } catch (e) {} }); playing = []; playAt = 0; } function openAudio() { try { audio = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000 }); } catch (e) { audio = null; return; } // На телефоне звук разрешается только из жеста. Разрешение микрофона // им не считается, и без этого урок молчит при живом соединении. var wake = function () { if (audio && audio.state === 'suspended') audio.resume(); }; document.addEventListener('pointerdown', wake); document.addEventListener('touchstart', wake); } // ---------- микрофон ---------- async function openMic() { // Эхоподавление обязательно: без него распознаватель слышит саму Kaya // и отвечает на её же слова. mic = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, }); var ctx = new (window.AudioContext || window.webkitAudioContext)(); var source = ctx.createMediaStreamSource(mic); // AudioWorklet собирается из строки: страница одним файлом, отдельного // модуля рядом нет. Работа тут ровно одна — снять частоту до 16 кГц и // отдать целыми числами: 48 кГц втрое дороже по трафику, а речь целиком // помещается в 16. var code = 'class P extends AudioWorkletProcessor{' + 'constructor(){super();this.acc=[];this.step=sampleRate/16000;this.pos=0;}' + 'process(inp){const ch=inp[0]&&inp[0][0];if(!ch)return true;' + 'for(let i=this.pos;i=1024){const out=Int16Array.from(this.acc);this.acc=[];' + 'this.port.postMessage(out.buffer,[out.buffer]);}return true;}}' + 'registerProcessor("mic",P)'; var url = URL.createObjectURL(new Blob([code], { type: 'application/javascript' })); await ctx.audioWorklet.addModule(url); URL.revokeObjectURL(url); var node = new AudioWorkletNode(ctx, 'mic'); node.port.onmessage = function (e) { if (socket && socket.readyState === 1) socket.send(e.data); }; source.connect(node); // К выходу не подключаем: ребёнок не должен слышать сам себя. } // ---------- конец ---------- function stop(why) { if (ticking) clearInterval(ticking); if (socket && socket.readyState === 1) { socket.send(JSON.stringify({ type: 'bye' })); socket.close(); } if (mic) mic.getTracks().forEach(function (t) { t.stop(); }); if (audio) { try { audio.close(); } catch (e) {} audio = null; } scene('sum'); bar.innerHTML = 'Вернуться в Kaya'; } // ---------- кнопки ---------- bar.innerHTML = '' + ''; bar.addEventListener('click', function (e) { if (e.target.closest('#stop')) stop('button'); if (e.target.closest('#hint') && socket && socket.readyState === 1) { socket.send(JSON.stringify({ type: 'hint' })); } }); // ---------- поехали ---------- scene('speaking'); said.textContent = 'Разрешите микрофон, и начинаем.'; gloss.textContent = ''; openMic().then(function () { openAudio(); socket = new WebSocket(BRIDGE); socket.binaryType = 'arraybuffer'; socket.onopen = function () { socket.send(JSON.stringify({ type: 'hello', token: jwt })); }; socket.onmessage = function (e) { if (typeof e.data !== 'string') { speak(e.data); return; } var msg; try { msg = JSON.parse(e.data); } catch (err) { return; } if (msg.type === 'ready') { clock(); scene('listening'); heard.textContent = ''; } else if (msg.type === 'state') scene(msg.state); else if (msg.type === 'heard') { heard.innerHTML = msg.text + (msg.final ? '' : ''); } else if (msg.type === 'said') { said.textContent = msg.text; gloss.textContent = ''; } else if (msg.type === 'hush') hush(); }; socket.onclose = function (e) { if (e.code === 4401) fail('Ссылка устарела. Начни урок заново из Kaya.'); }; socket.onerror = function () { fail('Связь оборвалась. Попробуй ещё раз.'); }; }).catch(function () { // Отказ в микрофоне — самый обычный исход первого раза, и он не про // поломку. Говорим, что делать, а не «произошла ошибка». fail('Нужен микрофон. Разреши доступ в браузере и открой ссылку заново.'); }); } var jwt = token(); if (jwt) liveMode(jwt); else demoMode();