Я дауншифтер. Так получилось, что последние три года мы с женой и младшим ребёнком наслаждаемся сельским пейзажем за окном, свежим воздухом и пением птиц. Удобства в доме, оптический интернет от местного провайдера, мощный бесперебойник и нагрянувший ковид неожиданно сделали идею переезда из мегаполиса не такой уж странной.
Пока я увлеченно занимался веб разработкой, где-то на фоне жена периодически жаловалась на проблемы выбора школы для ребёнка. И тут (вдруг) ребёнок подрос и школьный вопрос встал ребром. Ладно, значит, время пришло. Давайте вместе разберёмся, что же все-таки не так с системой образования в бывшей 1/6 части суши, и что мы с вами можем с этим сделать?
Традиционные методы очного обучения я оставлю за рамками этой статьи. Скажу только, что у обычных школ есть как неоспоримые преимущества, так и серьезные недостатки, к которым, кстати, в последнее время добавилась вынужденная самоизоляция. Здесь мы рассмотрим варианты дистанционного и семейного образования, которые, по целому ряду причин, в последнее время привлекают все больше родителей.
Внесу ясность: дистанционное обучение подразумевает занятия в обычной школе с помощью дистанционных образовательных технологий (ДОТ), а семейное означает добровольный уход из школы и обучение только силами семьи (по сути, это старый добрый экстернат). Впрочем, в любом случае ребёнка нужно прикрепить к какой-либо из доступных школ, как минимум, для сдачи промежуточных аттестаций.
А теперь немного наблюдений из жизни. С вынужденным переводом на дистанционку детей, уже учившихся в обычной школе, все грустно. Школьники воспринимают этот подарок судьбы как своего рода каникулы, родители не привыкли следить за дисциплиной во время занятий и в результате общая успеваемость неизбежно падает.
С первоклашками, особенно в случае семейной формы, у родителей, пожалуй, появляется шанс поставить ребёнка на рельсы, используя естественный интерес и эффект новизны. Лично для меня добиться самостоятельности главная задача. Сидеть и делать с ребёнком домашку я считаю верхом глупости. Конечно, если вы хотите, чтобы ваши дети чего-то добились в жизни и не висели у вас на шее. Я хочу, поэтому моя цель научить ребёнка учиться, правильно задавать вопросы и вообще, думать своей головой.
Ближе к делу. Выбираем государственную школу
Пожалуй, семейное образование мне нравится больше из-за возможности выбрать программу и график обучения. Да и физически посещать школу можно реже. Но выбрать государственную школу, поговорить с директором о прикреплении ребёнка и получить приказ о зачислении в первый класс нужно уже в конце зимы, чтобы в сентябре не было сюрпризов. Хотя, с юридической точки зрения, закон об образовании вроде бы не требует ежегодных аттестаций, дедлайны, по моему опыту, отлично мотивируют, поэтому пусть будут аттестации. Вряд ли любая школа примет нас с распростертыми объятьями, но найти достойный вариант в ближайшем городе мы сможем, я уверен.
Выбираем учебную программу
Именно выбираем. Пытаться составить программу самостоятельно, не имея профильного образования, не разумно. Хотя существуют государственные образовательные ресурсы, такие как Российская Электронная Школа (РЭШ) и Московская Электронная Школа (МЭШ), которых в теории могло было бы хватить, но Оба варианта предоставляют планы уроков, видеозаписи, тесты и учебные пособия. Вот чего мне не удалось найти, так это самих учебников, даже по обязательной программе.
И тут нет самого главного: общения. Обучить ребёнка, показывая ему бесконечные видеоролики и заставляя ставить галочки в тестах, не получится. Значит, нужно либо проводить уроки полностью самостоятельно, либо выбрать одну из онлайн школ.
Выбираем онлайн школу
Мы почти вернулись к тому, с чего начали. Дистанционка? Ладно, присмотримся к ней повнимательней. Как вообще можно организовать учебный процесс удаленно? Тут возникает много вопросов, я подниму только ключевые:
* Живое общение. Что предлагают школы? Скайп, в лучшем случае Тимс. Уроки по Скайпу? Серьёзно? Если я не ошибаюсь, на дворе 2020-й. Открыть перед первоклашкой несколько окон с красивыми разноцветными кнопочками и ждать, что он на них не нажмет, а будет пол-дня послушно слушать скучного дядю или тетю? Ни разу таких детей не видел. А вы?
* Домашка. Точнее, как она попадает к учителю на проверку? На самом деле, это действительно сложный вопрос, возможно, даже не решаемый в принципе. Существующие варианты:
- Написать в тетрадке, сфоткать и отправить учителю. Бр-р-р, не
хочу заставлять учителей ломать глаза в попытках прочесть мутные
фотки с мобильников, сделанные, как правило, по какому-то
неписанному закону в темноте.
- Отправить скан. Полумера, в общем случае невозможная из-за
отсутствия у родителей нужного оборудования.
- Оцифровать рукописный ввод с помощью дигитайзера или планшета.
Так себе вариант, но об этом чуть позже.
- Напечатать текст. В принципе, допустимо, но вот как ребёнок
введёт с клавиатуры, например, математическую или химическую
формулу? Никак. Плюс, для более продвинутых деток, проблема с
плагиатом.
- Выполнить онлайн тест. Это, безусловно, самый популярный
вариант. Полагаю, большинство школ, включая РЭШ и МЭШ,
ориентируются на него. На практике это означает скорее дрессировку,
чем обучение. Дети учатся ставить галочки в правильном месте. За
бортом остаются предметы, требующие любой формы творчества,
например, сочинения, а также диктанты и непопулярное теперь по
неведомой мне причине чистописание. Сюда же можно отнести умение
отстаивать своё мнение.
* Оценки. Очевидно, выставленные на уроке и при проверке домашних заданий оценки должны попадать в электронный дневник, доступный родителям. И они туда попадают. Вот только не сразу. Я поинтересовался у старших детей, закончивших один из престижных лицеев златоглавой (по иронии судьбы, с информационным уклоном), почему так? Ответ, честно сказать, меня удивил. Оказывается, учителя записывают оценки на бумажку, а после уроков вбивают их в этот самый электронный дневник на государственном портале. И это в то время, как Теслы Илона Маска бороздят просторы космоса
Ладно, пора провести небольшое техническое исследование и проверить, может существуют объективные причины такого положения дел?
Давайте определим требования к гипотетической идеальной платформе для обучения. На самом деле, все просто: дети должны оставаться на уроке, сосредоточившись на том, что говорит и показывает учитель, при необходимости отвечая на вопросы и при желании поднимая руку. По сути, нам нужно окно на полный экран с потоком с учительской камеры, презентацией или интерактивной доской. Самый простой способ добиться этого использовать технологию WebRTC (real-time communications, коммуникации в реальном времени). Эта штука работает в любом более-менее современном браузере, не требует покупки дополнительного оборудования и, к тому же, обеспечивает хорошее качество связи. И да, этот стандарт требует асинхронного программирования как минимум потому, что необходимый JS метод navigator.mediaDevices.getUserMedia() возвращает промис. Вроде все понятно, приступаю к реализации.
// Выбрать элементelement = $(selector);element = document.querySelector(selector);// Выбрать элемент внутри элементаelement2 = element.find(selector2);element2 = element.querySelector(selector2);// Скрыть элементelement.hide(); // добавляет стиль display: noneelement.classList.add('hidden');
Тут нужно пояснить, что CSS классу hidden, при желании, можно прописать свойства opacity и transition, что даст эффект fadeIn/fadeOut на чистом CSS. Отлично, давно хотел отказаться от JS анимации!
// Слушать событие onClickelement.click(e => { ... });element.onclick = (e) => { ... }// Переключить классelement.toggleClass(class_name);element.classList.toggle(class_name);// Создать divdiv = $("<div>");div = document.createElement("div");// Вставить созданный div в element// (это не опечатка, можно писать одинаково)element.append(div);element.append(div);
И т.д и т.п. В целом, код на чистом JS получается немного более многословным, но такова цена повышения производительности и снижения трафика. Нет, я никого не агитирую, но для эксперимента использую нативный JS с удовольствием!
WebRTC предназначен для связи между браузерами напрямую, по технологии точка-точка (p2p). Однако, чтобы установить эту связь, браузеры должны сообщить друг другу о своем намерении общаться. Для этого понадобится сервер сигнализации.
'use strict';(function () { const selfView = document.querySelector('#self-view'), remoteMaster = document.querySelector('#remote-master'), remoteSlaves = document.querySelector('#remote-slaves'); let localStream, selfStream = null, socket = null, selfId = null, connections = {}; // *********************** // UserMedia & DOM methods // *********************** const init = async () => { try { let stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: { max: 640 }, height: { max: 480 } } }); localStream = stream; selfStream = new MediaStream(); stream.getVideoTracks().forEach(track => { selfStream.addTrack(track, stream); // track.kind == 'video' }); selfView.querySelector('video').srcObject = selfStream; } catch (e) { document.querySelector('#self-view').innerHTML = '<i>Веб камера и микрофон не найдены</i>'; console.error('Local stream not found: ', e); } wsInit(); } const createRemoteView = (id, username) => { let iDiv = document.querySelector('#pc' + id); if (!iDiv) { iDiv = document.createElement('div'); iDiv.className = 'remote-view'; iDiv.id = 'pc' + id; let iVideo = document.createElement('video'); iVideo.setAttribute('autoplay', 'true'); iVideo.setAttribute('playsinline', 'true'); let iLabel = document.createElement('span'); iDiv.append(iVideo); iDiv.append(iLabel); if (!remoteMaster.querySelector('video')) { remoteMaster.append(iDiv); iLabel.textContent = 'Ведущий'; } else { remoteSlaves.append(iDiv); iLabel.textContent = username; } remoteMaster.style.removeProperty('display'); } } // ******************************* // Signaling (Web Socket) methods // ******************************* const wsInit = () => { socket = new WebSocket(SIGNALING_SERVER_URL); socket.onopen = function (e) { log('[socket open] Соединение установлено'); } socket.onmessage = function (event) { log('[socket message] Данные получены с сервера', event); wsHandle(event.data); } socket.onclose = function (event) { if (event.wasClean) { log('[close] Соединение закрыто чисто, ' + `код=${event.code} причина=${event.reason}`); } else { log('[socket close] Соединение прервано', event); } clearInterval(socket.timer); } socket.onerror = function (error) { logError('[socket error]', error); } socket.timer = setInterval(() => { socket.send('heartbeat'); }, 10000); } const wsHandle = async (data) => { if (!data) { return; } try { data = JSON.parse(data); } catch (e) { return; } switch (data.type) { case 'handshake': selfId = data.uid; if (!Object.keys(data.users).length) { createRemoteView(selfId, 'Ведущий'); remoteMaster.querySelector('video').srcObject = selfStream; selfView.remove(); break; } else { selfView.style.removeProperty('display'); } for (let id in data.users) { await pcCreate(id, data.users[id]); } break; case 'offer': await wsHandleOffer(data); break; case 'answer': await wsHandleAnswer(data) break; case 'candidate': await wsHandleICECandidate(data); break; default: break; } } const wsHandleOffer = async (data) => { let pc = null; if (!connections[data.src]) { await pcCreate(data.src, data.username); } pc = connections[data.src].pc; // We need to set the remote description to the received SDP offer // so that our local WebRTC layer knows how to talk to the caller. let desc = new RTCSessionDescription(data.sdp); pc.setRemoteDescription(desc).catch(error => { logError('handleOffer', error); }); await pc.setLocalDescription(await pc.createAnswer()); wsSend({ type: 'answer', target: data.src, sdp: pc.localDescription }); connections[data.src].pc = pc; // ??? } const wsHandleAnswer = async (data) => { log('*** Call recipient has accepted our call, answer:', data); let pc = connections[data.src].pc; // Configure the remote description, // which is the SDP payload in our 'answer' message. let desc = new RTCSessionDescription(data.sdp); await pc.setRemoteDescription(desc).catch((error) => { logError('handleAnswer', error); }); } const wsHandleICECandidate = async (data) => { let pc = connections[data.src].pc; let candidate = new RTCIceCandidate(data.candidate); log('*** Adding received ICE candidate', candidate); pc.addIceCandidate(candidate).catch(error => { logError('handleICECandidate', error); }); } const wsSend = (data) => { if (socket.readyState !== WebSocket.OPEN) { return; } socket.send(JSON.stringify(data)); } // *********************** // Peer Connection methods // *********************** const pcCreate = async (id, username) => { if (connections[id]) { return; } try { let pc = new RTCPeerConnection(PC_CONFIG); pc.onicecandidate = (event) => pcOnIceCandidate(event, id); pc.oniceconnectionstatechange = (event) => pcOnIceConnectionStateChange(event, id); pc.onsignalingstatechange = (event) => pcOnSignalingStateChangeEvent(event, id); pc.onnegotiationneeded = (event) => pcOnNegotiationNeeded(event, id); pc.ontrack = (event) => pcOnTrack(event, id); connections[id] = { pc: pc, username: username } if (localStream) { try { localStream.getTracks().forEach( (track) => connections[id].pc.addTransceiver(track, { streams: [localStream] }) ); } catch (err) { logError(err); } } else { // Start negotiation to listen remote stream only pcOnNegotiationNeeded(null, id); } createRemoteView(id, username); } catch (error) { logError('Peer: Connection failed', error); } } const pcOnTrack = (event, id) => { let iVideo = document.querySelector('#pc' + id + ' video'); iVideo.srcObject = event.streams[0]; } const pcOnIceCandidate = (event, id) => { let pc = connections[id].pc; if (event.candidate && pc.remoteDescription) { log('*** Outgoing ICE candidate: ' + event.candidate); wsSend({ type: 'candidate', target: id, candidate: event.candidate }); } } const pcOnNegotiationNeeded = async (event, id) => { let pc = connections[id].pc; try { const offer = await pc.createOffer(); // If the connection hasn't yet achieved the "stable" state, // return to the caller. Another negotiationneeded event // will be fired when the state stabilizes. if (pc.signalingState != 'stable') { return; } // Establish the offer as the local peer's current // description. await pc.setLocalDescription(offer); // Send the offer to the remote peer. wsSend({ type: 'offer', target: id, sdp: pc.localDescription }); } catch(err) { logError('*** The following error occurred while handling' + ' the negotiationneeded event:', err); }; } const pcOnIceConnectionStateChange = (event, id) => { let pc = connections[id].pc; switch (pc.iceConnectionState) { case 'closed': case 'failed': case 'disconnected': pcClose(id); break; } } const pcOnSignalingStateChangeEvent = (event, id) => { let pc = connections[id].pc; log('*** WebRTC signaling state changed to: ' + pc.signalingState); switch (pc.signalingState) { case 'closed': pcClose(id); break; } } const pcClose = (id) => { let remoteView = document.querySelector('#pc' + id); if (connections[id]) { let pc = connections[id].pc; pc.close(); delete connections[id]; } if (remoteView) { remoteView.remove(); } } // ******* // Helpers // ******* const log = (msg, data) => { if (!data) { data = '' } console.log(msg, data); } const logError = (msg, data) => { if (!data) { data = '' } console.error(msg, data); } init();})();
Сервер сигнализации выполнен на Python фреймвоке aiohttp и представляет собой простую вьюху, тривиально проксирующую запросы WebRTC. Соединение с сервером в этом примере выполнено на веб сокетах. Ну и, в дополнение, через канал сигнализации передаются данные простого текстового чата.
import jsonfrom aiohttp.web import WebSocketResponse, Responsefrom aiohttp import WSMsgTypefrom uuid import uuid1from lib.views import BaseViewclass WebSocket(BaseView): """ Process WS connections """ async def get(self): username = self.request['current_user'].firstname or 'Аноним' room_id = self.request.match_info.get('room_id') if room_id != 'test_room' and self.request['current_user'].is_anonymous: self.raise_error('forbidden') # @TODO: send 4000 if (self.request.headers.get('connection', '').lower() != 'upgrade' or self.request.headers.get('upgrade', '').lower() != 'websocket'): return Response(text=self.request.path) # ??? self.ws = WebSocketResponse() await self.ws.prepare(self.request) self.uid = str(uuid1()) if room_id not in self.request.app['web_sockets']: self.request.app['web_sockets'][room_id] = {} self.room = self.request.app['web_sockets'][room_id] users = {} for id, data in self.room.items(): users[id] = data['name'] ip = self.request.headers.get( 'X-FORWARDED-FOR', self.request.headers.get('X-REAL-IP', self.request.remote)) msg = { 'type': 'handshake', 'uid': str(self.uid), 'users': users, 'ip': ip} await self.ws.send_str(json.dumps(msg, ensure_ascii=False)) self.room[self.uid] = {'name': username, 'ws': self.ws} try: async for msg in self.ws: if msg.type == WSMsgType.TEXT: if msg.data == 'heartbeat': print('---heartbeat---') continue try: msg_data = json.loads(msg.data) if 'target' not in msg_data or msg_data['target'] not in self.room: continue msg_data['src'] = self.uid if 'type' in msg_data and 'target' in msg_data: if msg_data['type'] == 'offer': msg_data['username'] = username else: print('INVALID DATA:', msg_data) except Exception as e: print('INVALID JSON', e, msg) try: await self.room[msg_data['target']]['ws'].send_json( msg_data); except Exception as e: if 'target' in msg_data: self.room.pop(msg_data['target']) finally: self.room.pop(self.uid) return self.ws
Технология WebRTC, кроме видеосвязи, позволяет предоставить браузеру разрешение на захват содержимого дисплея или отдельного приложения, что может оказаться незаменимым при проведении онлайн уроков, вебинаров или презентаций. Отлично, используем.
Я так увлекся современными возможностями видеосвязи, что чуть не забыл о самом главном предмете в классе интерактивной доске. Впрочем, базовая реализация настолько тривиальна, что я не стану загромождать ею эту статью. Просто добавляем canvas, слушаем события перемещения мыши onmousemove (ontouchmove для планшетов) и отправляем полученные координаты всем подключенным точкам через тот же сервер сигнализации.
Тестируем интерактивную доску
Тут понадобится планшет, дигитайзер и живой ребёнок. Заодно проверим возможность оцифровки рукописного ввода.
Для начала я взял старенький планшет Galaxy Tab на андроиде 4.4, самодельный стилус и первые попавшиеся прописи в качестве фона для canvas. Дополнительные программы не устанавливал. Результат меня обескуражил: мой планшет абсолютно не пригоден для письма! То есть водить по нему пальцем без проблем, а вот попасть стилусом в контур буквы, даже такой огромной, как на картинке ниже, уже проблема. Плюс гаджет начинает тупить в процессе рисования, в результате чего линии становятся ломанными. Плюс мне не удалось заставить ребёнка не опирать запястье на экран, отчего под рукой остается дополнительная мазня, а сам планшет начинает тормозить еще больше. Итог: обычный планшет для письма на доске не подходит. Максимум его возможностей двигать пальцем по экрану достаточно крупные фигуры. Но предлагать это школьникам поздновато.
Ладно, у нас ведь чисто теоретическое исследование, верно? Тогда берем дигитайзер (он же графический планшет) Wacom Bamboo формата A8, и наблюдаем за ребёнком.
Замечу, что мой подопытный шести лет от роду получил ноутбук, да еще с графическим пером первый раз в жизни. На получение базовых навыков обращения с пером у нас ушло минут десять, а уже на втором уроке ребёнок пользовался планшетом вполне уверенно, самостоятельно стирал с доски, рисовал рожицы, цветочки, нашу собаку и даже начал тыкать доступные в эпсилон окрестности кнопки, попутно задавая вопросы типа А зачем в школе поднимают руку?. Вот только результат неизменно оставлял желать лучшего. Дело в том, что дизайнеры и художники для прорисовки элемента максимально увеличивают фрагмент изображения, что и делает линии точными. Здесь же мы должны видеть доску целиком, в масштабе 1:1. Тут и взрослый не попадет в линию. Вот что получилось у нас:
Итоговый вердикт: ни о каком рукописном вводе не может быть и речи. А если мы хотим поставить руку нашим детям, нужно добиваться этого самостоятельно, на бумаге, школа в этом никак не поможет.
Надо сказать, что ребёнок воспринял все мои эксперименты с восторгом и, более того, с тех пор ходит за мной хвостиком и просит включить прописи. Уже хорошо, полученный навык ему пригодится, только совсем для других целей.
Так или иначе, в результате экспериментов я фактически получил MVP минимально жизнеспособный продукт (minimum viable product), почти пригодный для проведения онлайн уроков, с видео/аудио конференцией, общим экраном, интерактивной доской, простым текстовым чатом и кнопкой поднять руку. Это на случай, если у ребёнка вдруг не окажется микрофона. Да, такое бывает, особенно у детей, не выучивших уроки.
Но в этой бочке меда, к сожалению, есть пара ложек дёгтя.
Тестируем WebRTC
Ложка 1. Поскольку наша видеосвязь использует прямые подключения между клиентами, нужно первым делом проверить масштабируемость такого решения. Для теста я взял старенький ноутбук с двухядерным i5-3230M на борту, и начал подключать к нему клиентов с отключенными веб камерами, то есть эмулируя режим один-ко-многим:
Как видите, подопытный ноут в состоянии более-менее комфортно вещать пяти клиентам (при загрузке CPU в пределах 60%). И это при условии снижения разрешения исходящего видеопотока до 720p (640x480px) и frame rate до 15 fps. В принципе, не так уж и плохо, но при подключении класса из нескольких десятков учеников от фулл меша придется отказаться в пользу каскадирования, то есть каждый из первых пяти клиентов проксирует поток следующим пяти и так далее.
Ложка 2. Для создания прямого интерактивного подключения (ICE) между клиентами им нужно обойти сетевые экраны и преобразователи NAT. Для этого WebRTC использует сервер STUN, который сообщает клиентам внешние параметры подключения. Считается, что в большинстве случаев этого достаточно. Но мне почти сразу же повезло:
Как видите, отладчик ругается на невозможность ICE соединения и требует подключения TURN сервера, то есть ретранслятора (relay). А это уже ДОРОГО. Простым сервером сигнализации тут не обойтись. Вывод придётся пропускать все потоки через медиа сервер.
Медиа сервер
Для тестирования я использовал aiortc. Интересная разработка, позволяет подключить браузер напрямую к серверу через WebRTC. Отдельная сигнализация не нужна, можно использовать канал данных самого соединения. Это работает, все мои тестовые точки подключились без проблем. Но вот с производительностью беда. Простое эхо видео/аудио потока с теми же ограничениями 720p и 15fps съели 50% моего виртуального CPU на тестовом VDS. Причём, если увеличить нагрузку до 100%, видео поток не успевает выгружаться клиентам и начинает забивать память, что в итоге приводит к остановке сервера. Очевидно, Питон, который мы любим использовать для задач обработки ввода/вывода, не очень подходит для CPU bound. Придётся поискать более специализированное решение, например, Янус или Джитси.
В любом случае, полноценное решение потребует выделенных серверов, по моим прикидкам, из расчёта 1 ядро на комнату (класс). Это уже стоит денег и выходит за рамки простого тестирования, поэтому в этом месте первую фазу моего исследования я буду считать завершённой.
Выводы
1. Мягко говоря, странно видеть на официальном портале Российской Федерации инструкции по скачиванию и ссылки на регистрацию в программе бывшего потенциального врага 1 (тут про Microsoft Teams). И это в эпоху санкций и импортозамещения.
Нет, лично я за дружбу народов и вообще всяческую толерантность, но неужели только у меня от такой интеграции встают волосы дыбом? Разве нет наших разработок?
2. Интеграция МЭШ/РЭШ со школами. Вообще-то, разработчики МЭШ молодцы, даже с Яндекс.репетитором интеграцию сделали. А как быть с выставлением оценок в реальном времени во время уроков, когда будет API? Или я чего-то не знаю?
3. Выбирая дистанционную или семейную форму обучения, нужно быть честным с собой: переложить ответственность за образование ребёнка на школу не получится. Вся работа по проведению уроков (в случае семейного образования), поддержанию дисциплины и самоорганизованности (в любом случае) полностью ложится на родителей. Нужно отдавать себе в этом отчёт и найти время на занятия. Впрочем, в больших семьях с этим проблем быть не должно.
4. Я не буду приводить здесь ссылки на выбранные нами онлайн школы, чтобы это не сочли рекламой. Скажу только, что мы остановились на частных школах среднего ценового диапазона. В любом случае, окончательный результат будет зависеть от ребёнка и получим мы его не раньше сентября.
Или есть смысл довести до логического конца начатую здесь разработку и организовать свою школу? Что вы думаете? Есть единомышленники, имеющие профильные знания и опыт в области образования?
Полезные ссылки:
Российская Электронная Школа
Московская Электронная Школа
Библиотека МЭШ
Разработчику