Когда-то смотрел я один фильм, и отпечатался в памяти момент, когда один с главных героев фильма тактично поднимает ногу и руку в ритм мелодии. Так вот: примечателен не столько герой, сколько набор мониторов, стоящих за ним.
Недавно у меня, как и у многих, сильно увеличилось количество времени, проведенного в четырех стенах, и пришла идея: "А какой максимально странный способ реализовать такую сцену?".
Забегая вперёд, скажу, что выбор пал на использование веб-браузера, а именно 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));
}
Со всего спектра выделяется полоса, которую будет представлять отдельно взятый браузер, и усредняя, получается амплитуда. Дальше просто делаем цвет между красным и синим, в зависимости от той самой амплитуды. Зарисовываем канву.
Как-то так выглядит результат использования плода воображения:
evgenij_byvshev
Цветомузыку в ролике не увидел. Виден только размалеванный пьяный мужик в труселях и маске.