Как сделать цветомузыку, когда ты программист

от автора

Когда-то смотрел я один фильм, и отпечатался в памяти момент, когда один с главных героев фильма тактично поднимает ногу и руку в ритм мелодии. Так вот: примечателен не столько герой, сколько набор мониторов, стоящих за ним.

Недавно у меня, как и у многих, сильно увеличилось количество времени, проведенного в четырех стенах, и пришла идея: "А какой максимально странный способ реализовать такую сцену?".

Забегая вперёд, скажу, что выбор пал на использование веб-браузера, а именно 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *