Когда-то смотрел я один фильм, и отпечатался в памяти момент, когда один с главных героев фильма тактично поднимает ногу и руку в ритм мелодии. Так вот: примечателен не столько герой, сколько набор мониторов, стоящих за ним.
Недавно у меня, как и у многих, сильно увеличилось количество времени, проведенного в четырех стенах, и пришла идея: "А какой максимально странный способ реализовать такую сцену?".
Забегая вперёд, скажу, что выбор пал на использование веб-браузера, а именно WebRTC и WebAudio API.
Вот подумал я, и тут сразу пошли варианты, от простых (поднять радио и найти плеер который имеет такую визуализацию), до длинных (сделать клиент-серверное приложение, которое будет слать информацию о цвете через сокет). А потом поймал себя на мысли: "сейчас браузер имеет все необходимые для реализации компоненты", так попробую же с помощью него и сделать.
WebRTC — способ передачи данных от браузера к браузеру (peer-to-peer), а значит мне не прийдется делать сервер, сначала подумал. Но был немного не прав. Для того, чтобы сделать RTCPeerConnection, нужно два сервера: сигнальный и ICE. Для второго можно использовать готовое решение (STUN или TURN сервера есть в репозиториях многих linux дистрибутивов). С первым надо что-то делать.
Документация гласит, что сигнальным может выступить произвольный двусторонний протокол взаимодействия, и тут сразу WebSockets, Long pooling или сделать что-то своё. Как мне кажется, самый простой вариант — взять hello world с документации какой-то библиотеки. И вот имеется такой незамысловатый сигнальный сервер:
import os from aiohttp import web, WSMsgType routes = web.RouteTableDef() routes.static("/def", os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') ) @routes.post('/broadcast') async def word(request): for conn in list(ws_client_connections): data = await request.text() await conn.send_str(data) return web.Response(text="Hello, world") ws_client_connections = set() async def websocket_handler(request): ws = web.WebSocketResponse(autoping=True) await ws.prepare(request) ws_client_connections.add(ws) async for msg in ws: if msg.type == WSMsgType.TEXT: if msg.data == 'close': await ws.close() # del ws_client_connections[user_id] else: continue elif msg.type == WSMsgType.ERROR: print('conn lost') # del ws_client_connections[user_id] return ws if __name__ == '__main__': app = web.Application() app.add_routes(routes) app.router.add_get('/ws', websocket_handler) web.run_app(app)
Я даже не стал реализовывать обработку клиентских WebSockets сообщений, а просто сделал POST endpoint, который рассылает сообщение всем. Подход, скопировать с документации — мне по душе.
Дальше, для установки WebRTC соединения между браузерами, происходит незамысловатое привет-привет, а ты можешь — а я могу. На диаграмме очень наглядно видно:

(Диаграма взята с страницы)
Сначала нужно создать само соединение:
function openConnection() { const servers = { iceServers: [ { urls: [`turn:${window.location.hostname}`], username: 'rtc', credential: 'demo' }, { urls: [`stun:${window.location.hostname}`] } ]}; let localConnection = new RTCPeerConnection(servers); console.log('Created local peer connection object localConnection'); dataChannelSend.placeholder = ''; localConnection.ondatachannel = receiveChannelCallback; localConnection.ontrack = e => { consumeRemoteStream(localConnection, e); } let sendChannel = localConnection.createDataChannel('sendDataChannel'); console.log('Created send data channel'); sendChannel.onopen = onSendChannelStateChange; sendChannel.onclose = onSendChannelStateChange; return localConnection; }
Тут важно, что до установки самого соединения, нужно задать назначение соединения. Вызовы createDataChannel() и, позже, addTrack() как раз и делают это.
function createConnection() { if(!iAmHost){ alert('became host') return 0; } for (let listener of streamListeners){ streamListenersConnections[listener] = openConnection() streamListenersConnections[listener].onicecandidate = e => { onIceCandidate(streamListenersConnections[listener], e, listener); }; audioStreamDestination.stream.getTracks().forEach(track => { streamListenersConnections[listener].addTrack(track.clone()) }) // localConnection.getStats().then(it => console.log(it)) streamListenersConnections[listener].createOffer().then((offer) => gotDescription1(offer, listener), onCreateSessionDescriptionError ).then( () => { startButton.disabled = true; closeButton.disabled = false; }); } }
На этом этапе уже пора вспомнить про WebAudio API. Так как браузер имеет возможность загружать в память бинарный файл, и даже декодировать его, если формат поддерживается, нужно использовать эту возможность.
function createAudioBufferFromFile(){ let fileToProcess = new FileReader(); fileToProcess.readAsArrayBuffer(selectedFile.files[0]); fileToProcess.onload = () => { let audioCont = new AudioContext(); audioCont.decodeAudioData(fileToProcess.result).then(res => { //TODO: stream to webrtcnode let source = audioCont.createBufferSource() audioSource = source; source.buffer = res; let dest = audioCont.createMediaStreamDestination() source.connect(dest) audioStreamDestination =dest; source.loop = false; // source.start(0) iAmHost = true; }); } }
В этой функции считывается в память, предположим, mp3 файл. После считывания создается AudioContext, дальше декодируется и превращается в MediaStream. Вуаля, у нас есть аудио поток, который можно передать через метод addTrack() WebRTC соединения.
Немного раньше, на каждое соединение был вызван createOffer() и отослан соответствующему клиенту. Клиент, в свою очередь, принимает офер и уведомляет инициатора:
function acceptDescription2(desc, broadcaster) { return localConnection.setRemoteDescription(desc) .then( () => { return localConnection.createAnswer(); }) .then(answ => { return localConnection.setLocalDescription(answ); }).then(() => { postData(JSON.stringify({type: "accept", user:username.value, to:broadcaster, descr:localConnection.currentLocalDescription})); }) }
Дальше инициатор обрабатывает ответ и инициирует обмен IceCandiates:
function finalizeCandidate(val, listener) { console.log('accepting connection') const a = new RTCSessionDescription(val); streamListenersConnections[listener].setRemoteDescription(a).then(() => { dataChannelSend.disabled = false; dataChannelSend.focus(); sendButton.disabled = false; processIceCandiates(listener) }); }
Выглядит предельно просто:
let conn = localConnection? localConnection: streamListenersConnections[data.user] conn.addIceCandidate(data.candidate).then( onAddIceCandidateSuccess, onAddIceCandidateError); processIceCandiates(data.user)
В результате каждое соединение узнаёт через addIceCandidate() возможные транспорты противоположного конца соединения.
Вот на этом месте уже есть один главный браузер с кучей браузеров, готовых его слушать.
Осталось только принять аудио поток и воспроизвести/(вывести необходимую часть спектра в цвет экрана).
function consumeRemoteStream(localConnection, event) { console.log('consume remote stream') const styles = { position: 'absolute', height: '100%', width: '100%', top: '0px', left: '0px', 'z-index': 1000 }; Object.keys(styles).map(i => { canvas.style[i] = styles[i]; }) let audioCont = new AudioContext(); audioContext = audioCont; let stream = new MediaStream(); stream.addTrack(event.track) let source = audioCont.createMediaStreamSource(stream) let analyser = audioCont.createAnalyser(); analyser.smoothingTimeConstant = 0; analyser.fftSize = 2048; source.connect(analyser); audioAnalizer = analyser; audioSource = source; analyser.connect(audioCont.destination) render() }
Немного манипуляций со стилями, чтобы заполнить всю страницу канвой. Потом создаём AudioContext который пришёл по соединению, и добавляем две ноды потребителя AudioNode.
У одной с них есть возможность получить спектрограмму, не зря метод createAnalyser() называется.
Последний штрих:
function render(){ var freq = new Uint8Array(audioAnalizer.frequencyBinCount); audioAnalizer.getByteFrequencyData(freq); let band = freq.slice( Math.floor(freqFrom.value/audioContext.sampleRate*2*freq.length), Math.floor(freqTo.value/audioContext.sampleRate*2*freq.length)); let avg = band.reduce((a,b) => a+b,0)/band.length; context.fillStyle = `rgb( ${Math.floor(255 - avg)}, 0, ${Math.floor(avg)})`; context.fillRect(0,0,200,200); requestAnimationFrame(render.bind(gl)); }
Со всего спектра выделяется полоса, которую будет представлять отдельно взятый браузер, и усредняя, получается амплитуда. Дальше просто делаем цвет между красным и синим, в зависимости от той самой амплитуды. Зарисовываем канву.
Как-то так выглядит результат использования плода воображения:
ссылка на оригинал статьи https://habr.com/ru/post/497072/
Добавить комментарий