├── screenshot.png ├── requirements.txt ├── test_library.py ├── test_engine.py ├── test_player.py ├── test_send_play.py ├── monitor_events.py ├── main.py ├── social.py ├── engine.py ├── storage.py ├── social_controller.py ├── library.py ├── events.py ├── player.py ├── README.md ├── library_controller.py ├── ui.py └── controller.py /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonas-braun/remote-decks/HEAD/screenshot.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aio_pika 2 | PyQt5 3 | qasync 4 | soundfile 5 | sounddevice 6 | samplerate 7 | requests 8 | eyeD3 9 | 10 | google-auth 11 | -------------------------------------------------------------------------------- /test_library.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | 5 | from library import Library 6 | 7 | 8 | 9 | l = Library(os.getenv('RD_LIBRARY').split(':')[0]) 10 | 11 | print(l.tracks) 12 | -------------------------------------------------------------------------------- /test_engine.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import time 4 | 5 | from engine import Engine 6 | 7 | 8 | e = Engine() 9 | 10 | e.load_track(0, 'data/test.wav') 11 | e.load_track(1, 'data/test.wav') 12 | 13 | start_time = time.time() 14 | e.play(0, 0., start_time) 15 | 16 | time.sleep(5) 17 | 18 | #e.play(1, 5.) 19 | end_time = time.time() 20 | 21 | #e.play(1, end_time - start_time) 22 | e.play(1, 0., start_time) 23 | #e.play(0, 0., time.time()-5) 24 | -------------------------------------------------------------------------------- /test_player.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import time 4 | 5 | from player import Player 6 | 7 | 8 | 9 | player_1 = Player() 10 | 11 | player_1.load_audio_file('data/test.wav') 12 | 13 | player_2 = Player() 14 | 15 | player_2.load_audio_file('data/test.wav') 16 | 17 | start_time = time.time() 18 | 19 | player_1.play() 20 | 21 | 22 | time.sleep(5) 23 | 24 | 25 | player_2.play(5.) 26 | #player_1.pause() 27 | 28 | time.sleep(5) 29 | 30 | end_time = time.time() 31 | 32 | print(end_time - start_time) 33 | -------------------------------------------------------------------------------- /test_send_play.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import time 4 | import asyncio 5 | 6 | from events import EventBus 7 | 8 | 9 | class TestController(): 10 | 11 | def receive(timestamp, msg): 12 | print(timestamp, msg) 13 | 14 | async def main(): 15 | loop = asyncio.get_event_loop() 16 | 17 | controller = TestController() 18 | 19 | event_bus = EventBus(loop, controller) 20 | 21 | await asyncio.sleep(5) 22 | 23 | timestamp = time.time() 24 | event_bus.send_data(timestamp, 'PLAY 0 10.0') 25 | 26 | 27 | if __name__ == '__main__': 28 | 29 | loop = asyncio.get_event_loop() 30 | 31 | asyncio.run(main()) 32 | 33 | loop.run_forever() 34 | -------------------------------------------------------------------------------- /monitor_events.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import time 4 | import asyncio 5 | 6 | from events import EventBus 7 | 8 | 9 | class TestController(): 10 | 11 | def receive(self, timestamp, sender, msg): 12 | runtime = time.time() - timestamp 13 | print(timestamp, sender, msg) 14 | print('Runtime', runtime) 15 | 16 | async def main(): 17 | loop = asyncio.get_event_loop() 18 | 19 | controller = TestController() 20 | 21 | event_bus = EventBus(loop, controller, silent=True) 22 | 23 | await asyncio.sleep(100000) 24 | 25 | 26 | if __name__ == '__main__': 27 | 28 | loop = asyncio.get_event_loop() 29 | 30 | asyncio.run(main()) 31 | 32 | loop.run_forever() 33 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import json 5 | import time 6 | import asyncio 7 | import datetime 8 | import threading 9 | 10 | from PyQt5 import QtWidgets 11 | from qasync import QEventLoop 12 | 13 | from controller import Controller 14 | from engine import Engine 15 | 16 | 17 | 18 | 19 | def main(): 20 | app = QtWidgets.QApplication(sys.argv) 21 | loop = QEventLoop(app) 22 | 23 | asyncio.set_event_loop(loop) 24 | 25 | engine = Engine() 26 | 27 | controller = Controller(app, loop, engine) 28 | 29 | with loop: 30 | 31 | loop.run_forever() 32 | 33 | engine.terminate = True 34 | 35 | 36 | 37 | 38 | if __name__ == '__main__': 39 | main() 40 | -------------------------------------------------------------------------------- /social.py: -------------------------------------------------------------------------------- 1 | from PyQt5 import QtWidgets 2 | 3 | 4 | class Social(QtWidgets.QWidget): 5 | 6 | def __init__(self, parent=None): 7 | 8 | super().__init__(parent) 9 | 10 | layout = QtWidgets.QVBoxLayout(self) 11 | 12 | self.status_line = QtWidgets.QLabel() 13 | layout.addWidget(self.status_line) 14 | 15 | self.canvas = QtWidgets.QListWidget() 16 | layout.addWidget(self.canvas) 17 | 18 | bottom_layout = QtWidgets.QHBoxLayout() 19 | 20 | self.input_textbox = QtWidgets.QLineEdit() 21 | bottom_layout.addWidget(self.input_textbox) 22 | 23 | self.send_button = QtWidgets.QPushButton('Send') 24 | bottom_layout.addWidget(self.send_button) 25 | 26 | layout.addLayout(bottom_layout) 27 | 28 | self.setFixedHeight(120) 29 | 30 | def add_message(self, message): 31 | 32 | self.canvas.addItem(message) 33 | self.canvas.scrollToBottom() 34 | 35 | def show_status(self, status): 36 | 37 | line = ' '.join(status.keys()) 38 | self.status_line.setText(line) 39 | -------------------------------------------------------------------------------- /engine.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | 5 | class Engine(threading.Thread): 6 | 7 | def __init__(self): 8 | super().__init__() 9 | 10 | self.running = False 11 | self.terminate = False 12 | self.tempo_range = (33+8)/33 - 1 13 | 14 | self.start() 15 | 16 | while not self.running: 17 | time.sleep(.2) 18 | 19 | 20 | def run(self): 21 | 22 | self.players = {} 23 | 24 | self.running = True 25 | 26 | while not self.terminate: 27 | time.sleep(.5) 28 | 29 | def load_track(self, deck, filename): 30 | 31 | from player import Player 32 | 33 | if deck not in self.players: 34 | self.players[deck] = Player(deck) 35 | 36 | self.players[deck].load_audio_file(filename) 37 | 38 | 39 | def play(self, deck, offset=None, timestamp=None): 40 | """ 41 | Offset is the position in the track where the playback is supposed to start. 42 | timestamp is the time when that playback was supposed to start, so it can be used to 43 | adjust the offset so that it accounts for any time lag in the communication. 44 | """ 45 | 46 | if offset is not None and timestamp: 47 | offset += time.time() - timestamp 48 | print(offset) 49 | 50 | self.players[deck].play(offset) 51 | 52 | 53 | def pause(self, deck): 54 | 55 | self.players[deck].pause() 56 | 57 | def change_tempo(self, deck, value, timestamp=None): 58 | print(value) 59 | tempo = 1 - self.tempo_range*value 60 | if timestamp: 61 | offset = time.time() - timestamp 62 | self.players[deck].set_tempo(tempo, offset) 63 | else: 64 | self.players[deck].tempo = tempo 65 | -------------------------------------------------------------------------------- /storage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import json 5 | 6 | import urllib.parse 7 | import requests 8 | 9 | #from google.cloud import storage 10 | from google.oauth2 import service_account 11 | from google.auth import impersonated_credentials 12 | from google.auth.transport.requests import Request 13 | 14 | 15 | class GoogleStorage: 16 | 17 | def __init__(self, host_mode=False): 18 | 19 | self.initialized = False 20 | self.base_url = 'https://storage.googleapis.com/storage/v1/' 21 | 22 | if host_mode: 23 | # make read-only temporary credentials 24 | target_scopes = ['https://www.googleapis.com/auth/devstorage.read_only'] 25 | self.admin_credentials = service_account.Credentials.from_service_account_file(os.getenv('RD_STORAGE_GOOGLE_ACCOUNT'), scopes=target_scopes) 26 | 27 | self.reader_credentials = impersonated_credentials.Credentials( 28 | source_credentials=self.admin_credentials, 29 | target_principal=os.getenv('RD_STORAGE_GOOGLE_ACCOUNT_READONLY'), 30 | target_scopes=target_scopes, 31 | lifetime=3600) 32 | 33 | self.reader_credentials.refresh(Request()) 34 | 35 | self.initialize(os.getenv('RD_STORAGE_GOOGLE_BUCKET'), self.reader_credentials.token) 36 | 37 | else: 38 | # guest mode 39 | pass 40 | 41 | def initialize(self, bucket, token): 42 | 43 | self.bucket = bucket 44 | self.token = token 45 | self.initialized = True 46 | 47 | 48 | def list(self): 49 | headers = {'Authorization': 'Bearer {}'.format(self.token)} 50 | response = requests.get(self.base_url + 'b/' + self.bucket + '/o', headers=headers) 51 | 52 | return [item['name'] for item in response.json()['items']] 53 | 54 | def get(self, object_name, location): 55 | headers = {'Authorization': 'Bearer {}'.format(self.token)} 56 | 57 | url = self.base_url + 'b/' + self.bucket + '/o/' + urllib.parse.quote(object_name, safe='') + '?alt=media' 58 | print(url) 59 | response = requests.get(url, headers=headers) 60 | 61 | with location.open('wb') as f: 62 | f.write(response.content) 63 | 64 | 65 | if __name__ == '__main__': 66 | s = GoogleStorage(host_mode=True) 67 | print(s.token) 68 | print(s.list()) 69 | -------------------------------------------------------------------------------- /social_controller.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from PyQt5 import QtCore 4 | 5 | 6 | class SocialController(QtCore.QObject): 7 | 8 | def __init__(self, controller, ui): 9 | 10 | super().__init__() 11 | 12 | self.controller = controller 13 | self.ui = ui 14 | 15 | self.social = self.ui.social 16 | 17 | self.status = {} 18 | 19 | self.greetings = set() 20 | 21 | self.social.input_textbox.returnPressed.connect(self.send) 22 | self.social.send_button.clicked.connect(self.send) 23 | 24 | self.syn_timer = QtCore.QTimer() 25 | self.syn_timer.setInterval(10000) 26 | self.syn_timer.timeout.connect(self.check_status) 27 | self.syn_timer.start() 28 | 29 | def update_status(self, timestamp, sender): 30 | 31 | if sender not in self.status: 32 | self.controller.event_bus.send_data(time.time(), f'SOC SYN') 33 | for greeting in self.greetings: 34 | self.controller.event_bus.send_data(time.time(), greeting) 35 | 36 | self.status[sender] = timestamp 37 | self.social.show_status(self.status) 38 | 39 | @QtCore.pyqtSlot() 40 | def send(self): 41 | 42 | timestamp = time.time() 43 | 44 | text = self.social.input_textbox.text()[:256] 45 | 46 | self.controller.event_bus.send_data(timestamp, f'SOC CHAT {text}') 47 | 48 | self.social.add_message(text[:256]) 49 | 50 | self.social.input_textbox.setText('') 51 | 52 | 53 | def receive(self, timestamp, sender, msg): 54 | 55 | print('received') 56 | if msg.startswith('SYN'): 57 | self.receive_syn(timestamp, sender) 58 | elif msg.startswith('CHAT'): 59 | _, value = msg.split(' ', 1) 60 | self.receive_chat(timestamp, sender, value) 61 | 62 | def receive_syn(self, timestamp, sender): 63 | 64 | self.update_status(timestamp, sender) 65 | 66 | def receive_chat(self, timestamp, sender, message): 67 | self.social.add_message(sender + ' - ' + message[:256]) 68 | 69 | self.update_status(timestamp, sender) 70 | 71 | @QtCore.pyqtSlot() 72 | def check_status(self): 73 | 74 | now = time.time() 75 | 76 | for sender, timestamp in self.status.copy().items(): 77 | if now - timestamp > 30: 78 | del self.status[sender] 79 | self.social.show_status(self.status) 80 | 81 | -------------------------------------------------------------------------------- /library.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from pathlib import Path 4 | import subprocess 5 | 6 | import eyed3 7 | 8 | from storage import GoogleStorage 9 | 10 | class Library(): 11 | 12 | fields_of_interest = {'artist', 'title'} 13 | 14 | def __init__(self, folder): 15 | 16 | self.folder = Path(folder) 17 | self.name = self.folder.name 18 | self.tracks = {} 19 | 20 | self.temp_path = Path('data/temp') 21 | self.temp_path.mkdir(parents=True, exist_ok=True) 22 | 23 | #TODO check for json lib first, then check files, then check remote 24 | if (self.folder / 'library.json').exists(): 25 | self.load_library_file() 26 | else: 27 | self.import_folder() 28 | 29 | def load_library_file(self): 30 | with (self.folder / 'library.json').open() as f: 31 | self.tracks = json.load(f) 32 | 33 | def import_folder(self): 34 | 35 | assert not self.tracks 36 | 37 | for file_ in self.folder.iterdir(): 38 | filename = str(file_.relative_to(self.folder)) 39 | print(filename) 40 | 41 | metadata = eyed3.load(file_) 42 | if not metadata: 43 | continue 44 | 45 | data = {} 46 | for tag in self.fields_of_interest: 47 | data[tag] = getattr(metadata.tag, tag) 48 | 49 | self.tracks[filename] = data 50 | 51 | with (self.folder / 'library.json').open('w') as f: 52 | json.dump(self.tracks, f) 53 | 54 | def get_list(self): 55 | 56 | return(self.tracks) 57 | 58 | def get(self, name): 59 | 60 | if name not in self.tracks: 61 | raise Exception 62 | 63 | input_file = self.folder / name 64 | output_file = str(self.temp_path / (name + '.wav')) 65 | subprocess.run(['ffmpeg', '-y', '-i', input_file, '-vn', '-acodec', 'pcm_s16le', '-ac', '2', '-ar', '44100', '-f', 'wav', output_file]) 66 | 67 | return (output_file) 68 | 69 | 70 | class RemoteLibrary(Library): 71 | 72 | def __init__(self, bucket, name, token): 73 | 74 | self.bucket = bucket 75 | 76 | folder = Path('data') / name 77 | folder.mkdir(parents=True, exist_ok=True) 78 | 79 | self.storage = GoogleStorage() 80 | self.storage.initialize(bucket, token) 81 | 82 | self.storage.get(f'{name}/library.json', folder / 'library.json') 83 | 84 | super().__init__(folder) 85 | 86 | def get(self, name): 87 | 88 | input_file = self.folder.name + '/' + name 89 | 90 | self.storage.get(input_file, self.folder / name) 91 | 92 | return super().get(name) 93 | 94 | -------------------------------------------------------------------------------- /events.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import json 6 | import time 7 | import asyncio 8 | import datetime 9 | 10 | import aio_pika 11 | 12 | 13 | AMQP_HOST = os.getenv('AMQP_HOST') 14 | 15 | class EventBus: 16 | 17 | def __init__(self, loop, controller, silent=False): 18 | 19 | self.controller = controller 20 | 21 | self.exchange = None 22 | self.user_id = None 23 | 24 | task = asyncio.create_task(self.listen(loop, silent=silent)) 25 | 26 | async def listen(self, loop, silent=False): 27 | 28 | connection = await aio_pika.connect_robust( 29 | f'amqp://guest:guest@{AMQP_HOST}/', loop=loop 30 | ) 31 | 32 | async with connection: 33 | 34 | print('connected') 35 | channel = await connection.channel() 36 | self.exchange = await channel.declare_exchange('events', aio_pika.ExchangeType.FANOUT) 37 | 38 | 39 | queue = await channel.declare_queue(exclusive=True) 40 | print('QUEUE', queue) 41 | self.user_id = str(queue) 42 | 43 | await queue.bind(self.exchange) 44 | 45 | if not silent: 46 | loop.create_task(self.heartbeat()) 47 | 48 | async with queue.iterator() as queue_iter: 49 | 50 | async for message in queue_iter: 51 | 52 | async with message.process(): 53 | 54 | print(str(datetime.datetime.now())) 55 | print(message.correlation_id) 56 | print(message.body.decode()) 57 | 58 | if not message.correlation_id == self.user_id: 59 | 60 | body = message.body.decode() 61 | timestamp = body[:17] 62 | msg = body[18:] 63 | sender = message.correlation_id 64 | 65 | self.controller.receive(float(timestamp), sender, msg) 66 | 67 | 68 | def send_data(self, timestamp, msg): 69 | print('client send', msg) 70 | 71 | timestamped_message = f'{timestamp:17.6f} {msg}' 72 | 73 | if self.exchange: 74 | 75 | asyncio.create_task(self.client_send_data(timestamped_message)) 76 | 77 | 78 | async def client_send_data(self, msg): 79 | await self.exchange.publish( 80 | aio_pika.Message( 81 | body=msg.encode(), 82 | correlation_id=self.user_id 83 | ), 84 | routing_key='', 85 | ) 86 | 87 | async def heartbeat(self): 88 | while True: 89 | self.send_data(time.time(), 'SOC SYN') 90 | await asyncio.sleep(10) 91 | -------------------------------------------------------------------------------- /player.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | 5 | import soundfile as sf 6 | import sounddevice as sd 7 | 8 | import samplerate as sr 9 | 10 | 11 | class OutputFile: 12 | def __init__(self, samplerate, channels, blocksize, callback): 13 | self.output_file = sf.SoundFile('output.wav', 14 | mode='w', 15 | samplerate=samplerate, 16 | channels=channels, 17 | format='WAVEX') 18 | 19 | 20 | class Player: 21 | 22 | """ 23 | The Player represents one input audio file and one audio output. 24 | It should run in a separate thread so it can resample audio data without causing a buffer 25 | underrun. The output interface is configurable especially for testing purposes. 26 | """ 27 | 28 | blocksize = 1024 29 | 30 | def __init__(self, deck=None): 31 | 32 | self.deck = deck 33 | 34 | self.sample_rate = 44100 35 | 36 | self.position = -1 37 | self.volume = 1 38 | self.tempo = 1 39 | 40 | self.loudness = 0. 41 | 42 | self.is_playing = False 43 | 44 | self.buffer = np.zeros((self.blocksize, 2), dtype='float32') 45 | 46 | self.audio_file = None 47 | self.stream = sd.OutputStream( 48 | samplerate=self.sample_rate, 49 | channels=2, 50 | blocksize=self.blocksize, 51 | callback=self.callback_closure() 52 | ) 53 | 54 | 55 | def callback_closure(self): 56 | position = -1 57 | 58 | def callback(outdata, frames, time, status): 59 | nonlocal position 60 | 61 | frames_to_read = math.ceil(frames/self.tempo) 62 | 63 | data = self.audio_file.read(frames_to_read, fill_value=0) * self.volume 64 | 65 | data = sr.resample(data, self.tempo, 'sinc_best')[:self.blocksize] 66 | 67 | self.buffer[:data.shape[0], :] = data 68 | 69 | outdata[:] = self.buffer 70 | 71 | self.loudness = np.average(np.abs(self.buffer)) 72 | 73 | return callback 74 | 75 | 76 | def load_audio_file(self, filename): 77 | 78 | self.audio_file = sf.SoundFile(filename) 79 | 80 | def get_position(self): 81 | return self.audio_file.tell() / self.sample_rate 82 | 83 | def set_tempo(self, tempo, offset): 84 | position = self.audio_file.tell() / self.sample_rate 85 | corrected_position = position + (offset * (tempo - self.tempo)) 86 | self.audio_file.seek(int(corrected_position * self.sample_rate)) 87 | self.tempo = tempo 88 | 89 | def play(self, offset=None): 90 | self.is_playing = True 91 | if offset: 92 | pos = int(offset * self.sample_rate) 93 | self.audio_file.seek(pos) 94 | self.stream.start() 95 | 96 | def pause(self): 97 | self.is_playing = False 98 | self.stream.stop() # TODO resync 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | !["screenshot"](screenshot.png?raw=true) 2 | 3 | # Remote Decks 4 | 5 | A latency-aware DJ tool for remote collaborative music playing. 6 | 7 | ## Idea 8 | When two or more people want to record or stream a DJ session together and are not in the same location, network latency makes them out of sync and thus a joint recording would be messy. 9 | 10 | But the audio stream from a DJ set - in contrast to e.g. a remote orchestra rehearsal - is somewhat predictable and can be retroactively synched between devices without much loss of listening quality. 11 | 12 | * Sync clocks between remote clients (already done using NTP on th OS) 13 | * Send all user input from a client to all other connected clients. Include a timestamp. 14 | * On the remote clients, act on the user input but account to the time lag from the transmission. 15 | 16 | Example: 17 | Alice presses the play button on her DJ deck. On her machine, the song starts playing immediately. 18 | Bob receives the message 100ms later that Alice started playing the song. He starts playback of the same song but skips ahead 100ms. He hears somethinig different than Alice for 100ms but after that, the two audio playbacks are perfectly in sync. 19 | A server is attached to the same events in "Streamer" mode. It sets it's internal clock comfortably behind Alice's and Bob's synchronous clocks, so it will have received every event before it has to act on it. The streamer creates a perfect version of the DJ set but with some latency. 20 | 21 | 22 | ## Installation 23 | 24 | Depends on python3 and PyQt5 25 | 26 | `pip install -r requirements.txt` 27 | 28 | The python package `samplerate` depends on `libsamplerate0-dev` and `libffi-dev` version 7. 29 | 30 | Uses `ffmpeg` to convert audio files. 31 | 32 | Set up a RabbitMQ cluster for communication between clients. 33 | 34 | 35 | ## Run 36 | 37 | Set some varibles in the shell environment: 38 | 39 | ``` 40 | export AMQP_HOST="X.X.X.X" # set your RabbitMQ IP 41 | export RD_LIBRARY="/home/user/Music" # set local path to music libraries, separated by : 42 | ``` 43 | 44 | Run the program with `./main` 45 | 46 | 47 | ## Music Library 48 | 49 | Currently, Google Cloud Storage is supported as a backend for sharing music libraries between participants. A user that wants to host their music collection needs to upload it to a bucket, including the library.json file, and set the environment variables 50 | 51 | ``` 52 | export RD_STORAGE_GOOGLE_ACCOUNT=/path/to/service-account.json 53 | export RD_GOOGLE_ACCOUNT_READONLY=service-account-readonly@project.iam.gserviceaccount.com 54 | export RD_GOOGLE_BUCKET=bucket-name 55 | ``` 56 | 57 | When the remote-decks program is started, a temporary token is made for the read-only service account and that token is sent to all other connected clients. These clients download music files from the bucket using that token. 58 | 59 | ## TODO 60 | 61 | ### Headphone Logic 62 | Any client can play songs only locally to pre-listen. Those events are not sent to the other clients. 63 | 64 | ## Extension idea: Time warp feature 65 | For connected live gigs, you would need an additional feature. 66 | 67 | * Measure the maxiumum latency between to (or more) clients and make that the "main latency" 68 | * receive all control events from the other machine(s) 69 | * Make a new control ("time warp") switch that changes whether your client is live/real-time or whether it is behind with the value of "main latency". Toggeling the switch will slowly (over a couple of seconds) strech the current track without listeners being able to notice ("transition time"). 70 | * When one DJ hands over the control to another DJ, they click the "time warp" switch and their interface is partly frozen. After the "transition time", the other DJs interface is fully unlocked and their stream will be played real-time in their room and with "main latency" in the other room. 71 | 72 | -------------------------------------------------------------------------------- /library_controller.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import partial 3 | 4 | from PyQt5 import QtCore 5 | 6 | from library import Library, RemoteLibrary 7 | from storage import GoogleStorage 8 | 9 | 10 | class StorageThread(QtCore.QThread): 11 | 12 | initialized = QtCore.pyqtSignal() 13 | 14 | def run(self): 15 | 16 | self.storage = GoogleStorage(host_mode=True) 17 | 18 | if self.storage.initialized: 19 | self.initialized.emit() 20 | 21 | 22 | class LibraryController(QtCore.QObject): 23 | 24 | def __init__(self, controller, ui): 25 | 26 | super().__init__() 27 | 28 | self.controller = controller 29 | self.ui = ui 30 | 31 | # TODO: load asynchronously, especially the storage code that calls an API. 32 | self.libraries = {} 33 | 34 | folders = os.getenv('RD_LIBRARY') 35 | if not folders: 36 | return 37 | for folder in folders.split(':'): 38 | 39 | new_library = Library(folder) 40 | 41 | self.libraries[new_library.name] = new_library 42 | 43 | self.load_track_list(new_library) 44 | 45 | self.storage_thread = StorageThread() 46 | self.storage_thread.initialized.connect(self.storage_initialized) 47 | self.storage_thread.start() 48 | 49 | def storage_initialized(self): 50 | # host mode 51 | 52 | # assume that the local folder and the remote folder are in sync 53 | 54 | bucket = os.getenv('RD_STORAGE_GOOGLE_BUCKET') 55 | for library in self.libraries.values(): 56 | if isinstance(library, RemoteLibrary): 57 | continue 58 | name = library.name 59 | token = self.storage_thread.storage.token 60 | self.controller.social_controller.greetings.add(f'LIBRARY {bucket} {name} {token}') 61 | 62 | def receive(self, timetamp, sender, value): 63 | bucket, name, token = value.split(' ') 64 | 65 | for library in self.libraries.values(): 66 | print('find', library.name) 67 | if (isinstance(library, RemoteLibrary) 68 | and library.bucket == bucket 69 | and library.name == name): 70 | library.token = token 71 | return 72 | else: 73 | try: 74 | new_library = RemoteLibrary(bucket, name, token) 75 | except Exception as e: 76 | raise e 77 | self.libraries[new_library.name] = new_library 78 | self.load_track_list(new_library) 79 | 80 | 81 | def load_track_list(self, library): 82 | index, track_list = self.ui.add_track_list(library.name, remote=isinstance(library, RemoteLibrary)) 83 | callback = partial(self.load_track, library_name=library.name) 84 | track_list.track_selected.connect(callback) 85 | 86 | self.ui.set_track_list(index, library.get_list()) 87 | 88 | @QtCore.pyqtSlot(int, str) 89 | def load_track(self, deck=-1, name=None, library_name=None, send=True): 90 | 91 | if not name: 92 | raise Exception 93 | 94 | if deck < 0: 95 | deck = 0 96 | 97 | self.ui.decks[deck].track_info.setText('loading...') 98 | self.ui.decks[deck].repaint() 99 | 100 | if not library_name: 101 | # search everywhere 102 | library_name = self.find_name_in_library(name) 103 | 104 | track = self.libraries[library_name].get(name) 105 | 106 | if send is True: 107 | self.controller.send_load(deck, name) # TODO load earlier 108 | 109 | self.controller.engine.load_track(deck, track) 110 | self.ui.decks[deck].track_info.setText(name) 111 | 112 | 113 | def find_name_in_library(self, name): 114 | for library in self.libraries.values(): 115 | if name in library.tracks: 116 | return library.name 117 | else: 118 | raise Exception('File name not found in libraries') 119 | -------------------------------------------------------------------------------- /ui.py: -------------------------------------------------------------------------------- 1 | from PyQt5 import QtCore, QtWidgets, QtGui 2 | 3 | from social import Social 4 | 5 | 6 | class TrackList(QtWidgets.QTableWidget): 7 | track_selected = QtCore.pyqtSignal(int, str) 8 | 9 | def __init__(self, parent=None): 10 | super().__init__(parent) 11 | 12 | self.setColumnCount(3) 13 | self.setColumnWidth(0, 300) 14 | self.setColumnWidth(1, 400) 15 | self.setColumnWidth(2, 200) 16 | self.setHorizontalHeaderLabels(['Artist', 'Track', 'File']) 17 | self.verticalHeader().hide() 18 | 19 | self.setSelectionBehavior(1) 20 | self.itemDoubleClicked.connect(self.track_clicked) 21 | 22 | def track_clicked(self, item): 23 | row = self.row(item) 24 | id_item = self.item(row, 2) 25 | self.track_selected.emit(-1, id_item.data(0)) 26 | 27 | def set_track_info(self, data): 28 | for i, (filename, row) in enumerate(data.items()): 29 | self.insertRow(i) 30 | for j, info in enumerate([row['artist'], row['title'], filename]): 31 | item = QtWidgets.QTableWidgetItem(info) 32 | item.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable) 33 | self.setItem(i, j, item) 34 | 35 | def contextMenuEvent(self, event): 36 | 37 | row = self.row(self.itemAt(event.pos())) 38 | track_id = self.item(row, 2).data(0) 39 | 40 | menu = QtWidgets.QMenu(self) 41 | load_action_1 = menu.addAction('Load to Deck 1') 42 | load_action_2 = menu.addAction('Load to Deck 2') 43 | action = menu.exec_(event.globalPos()) 44 | 45 | if action == load_action_1: 46 | self.track_selected.emit(0, track_id) 47 | elif action == load_action_2: 48 | self.track_selected.emit(1, track_id) 49 | 50 | 51 | class Deck(QtWidgets.QWidget): 52 | play_pause = QtCore.pyqtSignal(bool, int) 53 | tempo_changed = QtCore.pyqtSignal(int, int) 54 | 55 | def __init__(self, deck, parent=None): 56 | 57 | super().__init__(parent) 58 | self.deck = deck 59 | 60 | main_layout = QtWidgets.QHBoxLayout(self) 61 | layout = QtWidgets.QVBoxLayout() 62 | main_layout.addLayout(layout) 63 | 64 | self.track_info = QtWidgets.QLabel() 65 | layout.addWidget(self.track_info) 66 | 67 | self.play_button = QtWidgets.QPushButton('Play') 68 | self.play_button.setCheckable(True) 69 | self.play_button.clicked.connect(self.play_button_clicked) 70 | layout.addWidget(self.play_button) 71 | 72 | self.tempo_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) 73 | self.tempo_slider.setMinimum(-255) 74 | self.tempo_slider.setMaximum(+255) 75 | self.tempo_slider.valueChanged.connect(self.tempo_change) 76 | layout.addWidget(self.tempo_slider) 77 | 78 | self.vu_meter = QtWidgets.QProgressBar() 79 | self.vu_meter.setOrientation(2) 80 | 81 | main_layout.addWidget(self.vu_meter) 82 | 83 | @QtCore.pyqtSlot() 84 | def play_button_clicked(self): 85 | if self.play_button.isChecked(): 86 | self.play_pause.emit(True, self.deck) 87 | else: 88 | self.play_pause.emit(False, self.deck) 89 | 90 | @QtCore.pyqtSlot(int) 91 | def tempo_change(self, value): 92 | self.tempo_changed.emit(value, self.deck) 93 | 94 | 95 | class Ui(QtWidgets.QWidget): 96 | 97 | def __init__(self, parent=None): 98 | super().__init__(parent) 99 | 100 | self.setGeometry(300, 300, 800, 600) 101 | self.setWindowTitle('Remote Decks') 102 | 103 | self.shortcut_close = QtWidgets.QShortcut(QtGui.QKeySequence('Ctrl+Q'), self) 104 | 105 | layout = QtWidgets.QVBoxLayout(self) 106 | 107 | self.top_layout = QtWidgets.QTabWidget() 108 | layout.addWidget(self.top_layout) 109 | 110 | bottom_layout = QtWidgets.QHBoxLayout() 111 | self.decks = [ 112 | Deck(0), 113 | Deck(1) 114 | ] 115 | for deck in self.decks: 116 | bottom_layout.addWidget(deck) 117 | 118 | layout.addLayout(bottom_layout) 119 | 120 | self.cross_fader = QtWidgets.QSlider(QtCore.Qt.Horizontal) 121 | self.cross_fader.setMinimum(-256) 122 | self.cross_fader.setMaximum(+256) 123 | layout.addWidget(self.cross_fader) 124 | 125 | self.social = Social() 126 | layout.addWidget(self.social) 127 | 128 | def add_track_list(self, name, remote=False): 129 | 130 | if remote: 131 | name = '*' + name 132 | 133 | track_list = TrackList() 134 | self.top_layout.addTab(track_list, name) 135 | index = self.top_layout.indexOf(track_list) 136 | 137 | return index, track_list 138 | 139 | def set_track_list(self, index, data): 140 | track_list = self.top_layout.widget(index) 141 | track_list.set_track_info(data) 142 | -------------------------------------------------------------------------------- /controller.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | 4 | from functools import partial 5 | 6 | from PyQt5 import QtCore 7 | 8 | from ui import Ui 9 | from events import EventBus 10 | from library_controller import LibraryController 11 | from social_controller import SocialController 12 | 13 | 14 | class Controller(QtCore.QObject): 15 | 16 | def __init__(self, app, loop, engine): 17 | 18 | super().__init__() 19 | 20 | self.app = app 21 | self.loop = loop 22 | self.engine = engine 23 | 24 | self.timers = {} 25 | 26 | self.event_bus = EventBus(loop, self) 27 | 28 | self.ui = Ui() 29 | self.ui.show() 30 | 31 | self.ui.shortcut_close.activated.connect(self.close_app) 32 | 33 | self.ui.decks[0].play_pause.connect(self.play_pause_clicked) 34 | self.ui.decks[1].play_pause.connect(self.play_pause_clicked) 35 | self.ui.decks[0].tempo_changed.connect(self.tempo_changed) 36 | self.ui.decks[1].tempo_changed.connect(self.tempo_changed) 37 | 38 | self.ui.cross_fader.valueChanged.connect(self.cross_fade) 39 | 40 | self.social_controller = SocialController(self, self.ui) 41 | 42 | self.library_controller = LibraryController(self, self.ui) 43 | 44 | def close_app(self): 45 | self.app.quit() 46 | 47 | 48 | @QtCore.pyqtSlot(bool, int) 49 | def play_pause_clicked(self, value, deck): 50 | 51 | if value is True: 52 | position = self.engine.players[deck].get_position() 53 | self.engine.play(deck) 54 | self.connect_vu_meter(deck) 55 | self.send_play(deck, position) 56 | else: 57 | self.engine.pause(deck) 58 | del self.timers[deck] 59 | self.ui.decks[deck].vu_meter.setValue(0) 60 | self.send_pause(deck) 61 | 62 | print(value) 63 | 64 | 65 | def send_play(self, deck, position): 66 | timestamp = time.time() 67 | self.event_bus.send_data(timestamp, f'PLAY {deck} {position}') 68 | 69 | def receive_play(self, timestamp, deck, offset): 70 | self.ui.decks[int(deck)].play_button.setChecked(True) 71 | 72 | self.engine.play(int(deck), offset, timestamp) 73 | self.connect_vu_meter(deck) 74 | 75 | def send_load(self, deck, track): 76 | timestamp = time.time() 77 | self.event_bus.send_data(timestamp, f'LOAD {deck} {track}') 78 | 79 | def receive_load(self, timestamp, deck, track): 80 | self.library_controller.load_track(deck, name=track, send=False) 81 | 82 | def send_pause(self, deck): 83 | timestamp = time.time() 84 | self.event_bus.send_data(timestamp, f'PAUSE {deck}') 85 | 86 | def receive_pause(self, timestamp, deck): 87 | self.engine.pause(deck) 88 | self.ui.decks[deck].play_button.setChecked(False) 89 | del self.timers[deck] 90 | self.ui.decks[deck].vu_meter.setValue(0) 91 | 92 | def send_tempo_changed(self, deck, tempo): 93 | timestamp = time.time() 94 | self.event_bus.send_data(timestamp, f'TEMPO {deck} {tempo}') 95 | 96 | def receive_tempo(self, timestamp, deck, tempo): 97 | self.engine.change_tempo(deck, tempo, timestamp) 98 | self.ui.decks[deck].tempo_slider.blockSignals(True) 99 | self.ui.decks[deck].tempo_slider.setValue(int(tempo*256)) 100 | self.ui.decks[deck].tempo_slider.blockSignals(False) 101 | 102 | def send_cross_fade(self, value): 103 | timestamp = time.time() 104 | self.event_bus.send_data(timestamp, f'CROSSFADE {value}') 105 | 106 | def receive_cross_fade(self, timestamp, value): 107 | self.cross_fade(value, send=False) 108 | self.ui.cross_fader.blockSignals(True) 109 | self.ui.cross_fader.setValue(value) 110 | self.ui.cross_fader.blockSignals(False) 111 | 112 | @QtCore.pyqtSlot(int, int) 113 | def tempo_changed(self, value, deck): 114 | self.engine.change_tempo(deck, value/256) 115 | self.send_tempo_changed(deck, value/256) 116 | 117 | 118 | def receive(self, timestamp, sender, msg): 119 | 120 | print('RECEIVED', timestamp, msg) 121 | print(msg) 122 | if msg.startswith('PLAY'): 123 | _, deck, offset = msg.split(' ') 124 | self.receive_play(timestamp, int(deck), float(offset)) 125 | elif msg.startswith('LOAD'): 126 | _, deck, track = msg.split(' ', 2) 127 | self.receive_load(timestamp, int(deck), track) 128 | elif msg.startswith('PAUSE'): 129 | _, deck = msg.split(' ') 130 | self.receive_pause(timestamp, int(deck)) 131 | elif msg.startswith('TEMPO'): 132 | _, deck, tempo = msg.split(' ') 133 | self.receive_tempo(timestamp, int(deck), float(tempo)) 134 | elif msg.startswith('CROSSFADE'): 135 | _, value = msg.split(' ') 136 | self.receive_cross_fade(timestamp, int(value)) 137 | elif msg.startswith('LIBRARY'): 138 | _, value = msg.split(' ', 1) 139 | self.library_controller.receive(timestamp, sender, value) 140 | elif msg.startswith('SOC'): 141 | _, value = msg.split(' ', 1) 142 | self.social_controller.receive(timestamp, sender, value) 143 | 144 | 145 | @QtCore.pyqtSlot(int) 146 | def cross_fade(self, value, send=True): 147 | # "Transition" linear mode 148 | left_volume = min(1, 1 - value/256) 149 | right_volume = min(1, 1 + value/256) 150 | 151 | self.engine.players[0].volume = left_volume 152 | self.engine.players[1].volume = right_volume 153 | 154 | if send is True: 155 | self.send_cross_fade(value) 156 | 157 | def connect_vu_meter(self, deck): 158 | 159 | timer = QtCore.QTimer() 160 | timer.deck = deck 161 | timer.setInterval(20) 162 | 163 | callback = partial(self.update_vu_meter, deck=deck) 164 | timer.timeout.connect(callback) 165 | timer.start() 166 | self.timers[deck] = timer 167 | 168 | @QtCore.pyqtSlot() 169 | def update_vu_meter(self, deck): 170 | 171 | value = int(self.engine.players[deck].loudness * 100) 172 | self.ui.decks[deck].vu_meter.setValue(value) 173 | 174 | 175 | --------------------------------------------------------------------------------