diff --git a/.gitignore b/.gitignore index bf7300c..565a9a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ venv __pycache__ *.mkv -*.json \ No newline at end of file +*.json +push.bat +pull.bat \ No newline at end of file diff --git a/dmx.py b/dmx.py index 4786f44..8d09692 100644 --- a/dmx.py +++ b/dmx.py @@ -8,50 +8,55 @@ import os class DMXRecorder(QObject): frame_ready = pyqtSignal(np.ndarray) - - def __init__(self): + + def __init__(self, listen_address='0.0.0.0', port=6454): super().__init__() self.thread = None self.recording_thread = None + self.listen_address = listen_address + self.port = port def start_recording(self): self.thread = QThread() - self.recording_thread = RecordingThread() + self.recording_thread = RecordingThread(listen_address=self.listen_address, port=self.port) self.recording_thread.moveToThread(self.thread) self.thread.started.connect(self.recording_thread.run) self.recording_thread.finished.connect(self.thread.quit) - self.recording_thread.finished.connect(self.recording_thread.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) # Connect frame_ready signal self.recording_thread.frame_ready.connect(self.frame_ready) + self.thread.finished.connect(self.thread.deleteLater) + self.recording_thread.finished.connect(self.recording_thread.deleteLater) self.thread.start() def stop_recording(self): if self.recording_thread: self.recording_thread.stop() - if self.thread: self.thread.quit() self.thread.wait() + self.recording_thread = None + self.thread = None class RecordingThread(QObject): finished = pyqtSignal() frame_ready = pyqtSignal(np.ndarray) - def __init__(self): + def __init__(self, listen_address='0.0.0.0', port=6454): super().__init__() self.running = False self.socket = None self.ffmpeg_process = None + self.listen_address = listen_address + self.port = port def run(self): self.running = True # Set up UDP socket to listen for Art-Net DMX data - UDP_IP = "0.0.0.0" - UDP_PORT = 6454 + UDP_IP = self.listen_address + UDP_PORT = self.port self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.socket.bind((UDP_IP, UDP_PORT)) @@ -82,7 +87,6 @@ class RecordingThread(QObject): ffmpeg_executable += '.exe' # Ensure the ffmpeg executable is executable - # For Unix-like systems, set the execute permission if needed if not os.access(ffmpeg_executable, os.X_OK): if os.name != 'nt': os.chmod(ffmpeg_executable, 0o755) @@ -114,7 +118,6 @@ class RecordingThread(QObject): return universes_data = {} - last_received = {} try: while self.running: @@ -134,7 +137,6 @@ class RecordingThread(QObject): dmx_data = data[18:18+length] # Store the data for this universe universes_data[universe] = dmx_data - last_received[universe] = current_time except BlockingIOError: # No data received pass @@ -178,4 +180,188 @@ class RecordingThread(QObject): self.finished.emit() def stop(self): - self.running = False \ No newline at end of file + self.running = False + +class DMXPlayer(QObject): + frame_ready = pyqtSignal(np.ndarray) + playback_finished = pyqtSignal() + + def __init__(self, num_universes, transmit_address='255.255.255.255', port=6454): + super().__init__() + self.thread = None + self.playback_thread = None + self.num_universes = num_universes + self.transmit_address = transmit_address + self.port = port + + def start_playback(self): + self.thread = QThread() + self.playback_thread = PlaybackThread(self.num_universes, transmit_address=self.transmit_address, port=self.port) + self.playback_thread.moveToThread(self.thread) + + self.thread.started.connect(self.playback_thread.run) + self.playback_thread.finished.connect(self.thread.quit) + + # Connect signals + self.playback_thread.frame_ready.connect(self.frame_ready) + self.playback_thread.finished.connect(self.on_playback_thread_finished) + + self.thread.finished.connect(self.on_thread_finished) + + self.thread.start() + + def on_playback_thread_finished(self): + self.playback_finished.emit() + # Wait for the thread to exit before cleaning up + self.thread.quit() + self.thread.wait() + self.playback_thread = None + + def on_thread_finished(self): + self.thread = None + + def stop_playback(self): + if self.playback_thread: + self.playback_thread.stop() + self.thread.quit() + self.thread.wait() + self.playback_thread = None + self.thread = None + +class PlaybackThread(QObject): + finished = pyqtSignal() + frame_ready = pyqtSignal(np.ndarray) + + def __init__(self, num_universes, transmit_address='255.255.255.255', port=6454): + super().__init__() + self.num_universes = num_universes + self.running = False + self.ffmpeg_process = None + self.socket = None + self.transmit_address = transmit_address + self.port = port + + def run(self): + self.running = True + + # Set up UDP socket to send Art-Net DMX data + UDP_IP = self.transmit_address + UDP_PORT = self.port + + self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # Enable broadcasting mode if necessary + if UDP_IP == '255.255.255.255': + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + + # Adjust the path to the ffmpeg executable + ffmpeg_executable = os.path.join(os.path.dirname(__file__), 'bin', 'ffmpeg') + + # For Windows, add .exe extension + if os.name == 'nt': + ffmpeg_executable += '.exe' + + # Ensure the ffmpeg executable is executable + if not os.access(ffmpeg_executable, os.X_OK): + if os.name != 'nt': + os.chmod(ffmpeg_executable, 0o755) + else: + # On Windows, os.access may not check execute permissions accurately + if not os.path.isfile(ffmpeg_executable): + print(f"ffmpeg executable not found at: {ffmpeg_executable}") + self.finished.emit() + return + + # Define frame dimensions (must match recording) + frame_width = 64 # Must match recording settings + universe_size = 512 # DMX universe size + num_universes_in_file = 4 # Universes recorded (0 to 3) + total_pixels_in_file = num_universes_in_file * universe_size + frame_height_in_file = total_pixels_in_file // frame_width + frame_rate = 30 # frames per second + frame_interval = 1.0 / frame_rate + + # Compute playback dimensions based on number of universes to playback + total_pixels_playback = self.num_universes * universe_size + frame_height_playback = total_pixels_playback // frame_width + + # FFmpeg command to read frames + ffmpeg_cmd = [ + ffmpeg_executable, + '-i', 'dmx_video.mkv', # Input file + '-f', 'rawvideo', + '-pix_fmt', 'gray', + '-s', f'{frame_width}x{frame_height_in_file}', + '-r', str(frame_rate), + '-an', + '-sn', + '-' + ] + + try: + self.ffmpeg_process = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE) + except Exception as e: + print(f"Error starting ffmpeg: {e}") + self.finished.emit() + return + + # Read frames from ffmpeg stdout + frame_size = frame_width * frame_height_in_file + sequence = 0 + + try: + while self.running: + # Read frame data + frame_data = self.ffmpeg_process.stdout.read(frame_size) + if not frame_data or len(frame_data) < frame_size: + # End of file + break + + # Extract data for the specified number of universes + total_pixels_playback_bytes = self.num_universes * universe_size + frame_data_playback = frame_data[:total_pixels_playback_bytes] + + # Convert frame_data to numpy array + frame_array = np.frombuffer(frame_data_playback, dtype=np.uint8) + # Reshape to image dimensions + frame_array = frame_array.reshape((frame_height_playback, frame_width)) + + # Send Art-Net data + # Split frame_data into universes + expected_universes = sorted(range(self.num_universes)) + for idx, universe in enumerate(expected_universes): + start_idx = idx * universe_size + end_idx = start_idx + universe_size + universe_data = frame_data_playback[start_idx:end_idx] + # Construct ArtDMX packet + packet = self.build_artdmx_packet(sequence, universe, universe_data) + # Send packet over UDP + self.socket.sendto(packet, (UDP_IP, UDP_PORT)) + + sequence = (sequence + 1) % 256 + # Emit frame for visualization + self.frame_ready.emit(frame_array) + + # Sleep to synchronize to frame rate + time.sleep(frame_interval) + finally: + if self.ffmpeg_process: + self.ffmpeg_process.stdout.close() + self.ffmpeg_process.wait() + self.socket.close() + self.finished.emit() + + def stop(self): + self.running = False + + def build_artdmx_packet(self, sequence, universe, data): + packet = bytearray() + packet.extend(b'Art-Net\x00') # ID + packet.extend(struct.pack('H', 14)) # Protocol Version (High byte first) + packet.extend(struct.pack('B', sequence)) # Sequence + packet.extend(struct.pack('B', 0x00)) # Physical + packet.extend(struct.pack('H', length)) # Length (High byte first) + packet.extend(data) + return packet \ No newline at end of file diff --git a/main.py b/main.py index 03fad29..599fa22 100644 --- a/main.py +++ b/main.py @@ -1,19 +1,26 @@ +# main.py + import sys -from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton, QWidget, QVBoxLayout, QLabel +import json +import os +from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton, QWidget, QVBoxLayout, QLabel, QSpinBox, QLineEdit, QGridLayout from PyQt5.QtCore import Qt from PyQt5.QtGui import QImage, QPixmap, qRgb -from dmx import DMXRecorder +from dmx import DMXRecorder, DMXPlayer class MainWindow(QMainWindow): def __init__(self): super().__init__() + self.settings = self.load_settings() self.initUI() self.isRecording = False + self.isPlaying = False self.recorder = None + self.player = None def initUI(self): - self.setWindowTitle("DMX Recorder") - self.resize(300, 400) + self.setWindowTitle("DMX Recorder and Player") + self.resize(300, 500) # Use dark theme self.setStyleSheet(''' @@ -60,15 +67,57 @@ class MainWindow(QMainWindow): self.record_button = QPushButton("Record") self.record_button.clicked.connect(self.toggle_recording) + self.play_button = QPushButton("Play") + self.play_button.clicked.connect(self.toggle_playback) + + # Input fields for settings + self.universes_label = QLabel("Universes to Playback:") + self.universes_spinbox = QSpinBox() + self.universes_spinbox.setMinimum(1) + self.universes_spinbox.setMaximum(4) # Maximum universes recorded + self.universes_spinbox.setValue(self.settings.get('universes', 4)) + + # Port number + self.port_label = QLabel("Port Number:") + self.port_input = QLineEdit(str(self.settings.get('port', 6454))) + + # Transmit Address (Playback) + self.transmit_label = QLabel("Transmit Address:") + self.transmit_input = QLineEdit(self.settings.get('transmit_address', '255.255.255.255')) + + # Listen Address (Recording) + self.listen_label = QLabel("Listen Address:") + self.listen_input = QLineEdit(self.settings.get('listen_address', '0.0.0.0')) + self.video_label = QLabel() self.video_label.setAlignment(Qt.AlignCenter) self.layout.addWidget(self.video_label) + + # Add settings input fields to the layout + settings_layout = QGridLayout() + settings_layout.addWidget(self.universes_label, 0, 0) + settings_layout.addWidget(self.universes_spinbox, 0, 1) + settings_layout.addWidget(self.port_label, 1, 0) + settings_layout.addWidget(self.port_input, 1, 1) + settings_layout.addWidget(self.transmit_label, 2, 0) + settings_layout.addWidget(self.transmit_input, 2, 1) + settings_layout.addWidget(self.listen_label, 3, 0) + settings_layout.addWidget(self.listen_input, 3, 1) + self.layout.addLayout(settings_layout) + self.layout.addWidget(self.record_button) + self.layout.addWidget(self.play_button) def toggle_recording(self): if not self.isRecording: + if self.isPlaying: + # Can't record while playing + return + # Get settings + port = int(self.port_input.text()) + listen_address = self.listen_input.text() # Start recording - self.recorder = DMXRecorder() + self.recorder = DMXRecorder(listen_address=listen_address, port=port) self.recorder.frame_ready.connect(self.update_frame) self.recorder.start_recording() self.record_button.setText("Stop") @@ -82,6 +131,38 @@ class MainWindow(QMainWindow): self.record_button.setText("Record") self.isRecording = False + def toggle_playback(self): + if not self.isPlaying: + if self.isRecording: + # Can't play while recording + return + # Get settings + num_universes_to_playback = self.universes_spinbox.value() + port = int(self.port_input.text()) + transmit_address = self.transmit_input.text() + # Start playback + self.player = DMXPlayer(num_universes=num_universes_to_playback, transmit_address=transmit_address, port=port) + self.player.frame_ready.connect(self.update_frame) + self.player.playback_finished.connect(self.on_playback_finished) + self.player.start_playback() + self.play_button.setText("Stop") + self.isPlaying = True + else: + # Stop playback + if self.player: + self.player.stop_playback() + self.player = None + self.play_button.setText("Play") + self.isPlaying = False + + def on_playback_finished(self): + if self.isPlaying: + self.play_button.setText("Play") + self.isPlaying = False + if self.player: + self.player.frame_ready.disconnect(self.update_frame) + self.player = None + def update_frame(self, frame_array): # Convert the numpy array to QImage and display height, width = frame_array.shape @@ -96,6 +177,31 @@ class MainWindow(QMainWindow): # Set the pixmap to the label self.video_label.setPixmap(pixmap) + def closeEvent(self, event): + # Save settings to JSON file + self.save_settings() + super().closeEvent(event) + + def load_settings(self): + # Load settings from settings.json if it exists + settings_file = 'settings.json' + if os.path.exists(settings_file): + with open(settings_file, 'r') as f: + return json.load(f) + else: + # Return default settings + return {} + + def save_settings(self): + settings = { + 'universes': self.universes_spinbox.value(), + 'port': int(self.port_input.text()), + 'transmit_address': self.transmit_input.text(), + 'listen_address': self.listen_input.text(), + } + with open('settings.json', 'w') as f: + json.dump(settings, f) + if __name__ == '__main__': app = QApplication(sys.argv) window = MainWindow()