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


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


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

Со всего спектра выделяется полоса, которую будет представлять отдельно взятый браузер, и усредняя, получается амплитуда. Дальше просто делаем цвет между красным и синим, в зависимости от той самой амплитуды. Зарисовываем канву.


Как-то так выглядит результат использования плода воображения: