You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

720 lines
27 KiB

"""
Main Window GUI
Primary application window with all controls and displays.
Integrates all GUI components into a cohesive interface.
"""
from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QGridLayout, QPushButton, QLabel, QSlider, QComboBox,
QSpinBox, QGroupBox, QTabWidget, QSplitter, QFrame,
QSizePolicy)
from PyQt5.QtCore import Qt, QTimer, pyqtSlot, QSize
from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence
from .arpeggiator_controls import ArpeggiatorControls
from .channel_controls import ChannelControls
from .volume_controls import VolumeControls
from .simulator_display import SimulatorDisplay
from .output_controls import OutputControls
from .preset_controls import PresetControls
class ScalingUtils:
"""Utility class for handling dynamic GUI scaling"""
@staticmethod
def get_scale_factor(widget):
"""Calculate scale factor based on widget size"""
# Base size is 1200x800
base_width = 1200
base_height = 800
# Get actual widget size
actual_size = widget.size()
width_factor = actual_size.width() / base_width
height_factor = actual_size.height() / base_height
# Use the smaller factor to maintain proportions
scale_factor = min(width_factor, height_factor)
# Clamp between 0.8 and 3.0 for reasonable bounds
return max(0.8, min(3.0, scale_factor))
@staticmethod
def scale_font_size(base_size, scale_factor):
"""Scale font size with factor"""
return max(8, int(base_size * scale_factor))
@staticmethod
def scale_button_size(base_width, base_height, scale_factor):
"""Scale button dimensions with factor"""
return (
max(30, int(base_width * scale_factor)),
max(20, int(base_height * scale_factor))
)
@staticmethod
def apply_scalable_button_style(button, base_font_size=12, scale_factor=1.0):
"""Apply scalable styling to a button"""
font_size = ScalingUtils.scale_font_size(base_font_size, scale_factor)
padding = max(2, int(5 * scale_factor))
button.setStyleSheet(f"""
QPushButton {{
background: #3a3a3a;
color: #ffffff;
font-size: {font_size}px;
font-weight: bold;
border: 1px solid #555555;
padding: {padding}px;
min-height: {max(18, int(22 * scale_factor))}px;
}}
QPushButton:hover {{
background: #505050;
border: 1px solid #777777;
}}
""")
# Set size policy to allow expansion
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
class MainWindow(QMainWindow):
"""
Main application window containing all GUI components.
Provides organized layout and coordinates between different control panels.
"""
def __init__(self, arpeggiator, channel_manager, volume_engine, output_manager, simulator, maschine_controller=None):
super().__init__()
self.arpeggiator = arpeggiator
self.channel_manager = channel_manager
self.volume_engine = volume_engine
self.output_manager = output_manager
self.simulator = simulator
self.maschine_controller = maschine_controller
self.setWindowTitle("MIDI Arpeggiator - Lighting Controller")
self.setMinimumSize(1200, 800)
# Scaling support
self.scale_factor = 1.0
self.scaling_enabled = True
# Keyboard note mapping
self.keyboard_notes = {
Qt.Key_A: 60, # C
Qt.Key_W: 61, # C#
Qt.Key_S: 62, # D
Qt.Key_E: 63, # D#
Qt.Key_D: 64, # E
Qt.Key_F: 65, # F
Qt.Key_T: 66, # F#
Qt.Key_G: 67, # G
Qt.Key_Y: 68, # G#
Qt.Key_H: 69, # A
Qt.Key_U: 70, # A#
Qt.Key_J: 71, # B
Qt.Key_K: 72, # C (next octave)
}
self.held_keys = set()
self.setup_ui()
self.setup_connections()
self.apply_dark_theme()
def setup_ui(self):
"""Initialize the user interface"""
central_widget = QWidget()
self.setCentralWidget(central_widget)
# Create main layout with full-window tabs
main_layout = QVBoxLayout(central_widget)
main_layout.setContentsMargins(8, 8, 8, 8)
main_layout.setSpacing(8)
# Transport controls at top
transport_frame = self.create_transport_controls()
main_layout.addWidget(transport_frame)
# Create tabbed interface that fills the window
tab_widget = QTabWidget()
tab_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
main_layout.addWidget(tab_widget, 1) # Give tab widget all extra space
# Arpeggiator tab with quadrant layout
self.arp_controls = ArpeggiatorControls(self.arpeggiator, self.channel_manager, self.simulator)
tab_widget.addTab(self.arp_controls, "Arpeggiator")
# Channels tab
self.channel_controls = ChannelControls(self.channel_manager, self.output_manager)
tab_widget.addTab(self.channel_controls, "Channels")
# Volume/Lighting tab
self.volume_controls = VolumeControls(self.volume_engine)
tab_widget.addTab(self.volume_controls, "Volume/Lighting")
# Output tab
self.output_controls = OutputControls(self.output_manager)
tab_widget.addTab(self.output_controls, "Output")
# Simulator display now integrated into arpeggiator tab - removed standalone tab
# Presets tab
self.preset_controls = PresetControls(self.arpeggiator, self.channel_manager, self.volume_engine)
tab_widget.addTab(self.preset_controls, "Presets")
# Set up preset callback for armed preset system
self.arpeggiator.set_preset_apply_callback(self.preset_controls.apply_preset_settings)
# Status display at bottom
status_frame = self.create_status_display()
main_layout.addWidget(status_frame)
# Create status bar
self.statusBar().showMessage("Ready - Use keyboard (AWSDFGTGHYUJ) to play notes, SPACE for emergency stop")
# Create menu bar
self.create_menu_bar()
# Removed create_control_panel and create_display_panel methods - now using direct tab layout
def create_transport_controls(self) -> QFrame:
"""Create transport control buttons"""
frame = QFrame()
frame.setFrameStyle(QFrame.Box)
frame.setMaximumHeight(60) # Limit height to just accommodate buttons
layout = QHBoxLayout(frame)
# Play/Stop buttons
self.play_button = QPushButton("▶ PLAY")
self.play_button.setObjectName("playButton")
self.stop_button = QPushButton("⏹ STOP")
self.stop_button.setObjectName("stopButton")
self.panic_button = QPushButton("⚠ PANIC")
self.panic_button.setObjectName("panicButton")
# Style buttons
button_style = """
QPushButton {
font-size: 14px;
font-weight: bold;
padding: 10px 20px;
border-radius: 5px;
}
QPushButton:hover {
background-color: #404040;
}
"""
play_style = button_style + """
QPushButton {
background-color: #2d5a2d;
color: white;
}
"""
stop_style = button_style + """
QPushButton {
background-color: #5a2d2d;
color: white;
}
"""
panic_style = button_style + """
QPushButton {
background-color: #5a2d5a;
color: white;
}
"""
self.play_button.setStyleSheet(play_style)
self.stop_button.setStyleSheet(stop_style)
self.panic_button.setStyleSheet(panic_style)
# Connect buttons
self.play_button.clicked.connect(self.on_play_clicked)
self.stop_button.clicked.connect(self.on_stop_clicked)
self.panic_button.clicked.connect(self.on_panic_clicked)
# Add to layout
layout.addWidget(self.play_button)
layout.addWidget(self.stop_button)
layout.addWidget(self.panic_button)
layout.addStretch()
# Tempo display
tempo_label = QLabel("Tempo:")
self.tempo_display = QLabel("120 BPM")
self.tempo_display.setStyleSheet("font-weight: bold; font-size: 14px;")
layout.addWidget(tempo_label)
layout.addWidget(self.tempo_display)
return frame
def create_status_display(self) -> QFrame:
"""Create status information display"""
frame = QFrame()
frame.setFrameStyle(QFrame.Box)
frame.setMaximumHeight(60)
layout = QHBoxLayout(frame)
# Output mode indicator
self.mode_indicator = QLabel("Mode: Simulator")
self.mode_indicator.setStyleSheet("font-weight: bold; color: #00aa00;")
# Connection status
self.connection_status = QLabel("Connected")
self.connection_status.setStyleSheet("color: #00aa00;")
# Active voices count
self.voices_display = QLabel("Voices: 0")
layout.addWidget(QLabel("Status:"))
layout.addWidget(self.mode_indicator)
layout.addWidget(QFrame()) # Separator
layout.addWidget(self.connection_status)
layout.addWidget(QFrame()) # Separator
layout.addWidget(self.voices_display)
layout.addStretch()
return frame
def create_menu_bar(self):
"""Create application menu bar"""
menubar = self.menuBar()
# File menu
file_menu = menubar.addMenu('File')
file_menu.addAction('New Preset', self.preset_controls.new_preset)
file_menu.addAction('Load Preset', self.preset_controls.load_preset)
file_menu.addAction('Save Preset', self.preset_controls.save_preset)
file_menu.addSeparator()
file_menu.addAction('Exit', self.close)
# View menu
view_menu = menubar.addMenu('View')
view_menu.addAction('Reset Layout', self.reset_layout)
# MIDI menu
midi_menu = menubar.addMenu('MIDI')
midi_menu.addAction('Refresh Devices', self.output_controls.refresh_midi_devices)
midi_menu.addAction('Panic (All Notes Off)', self.on_panic_clicked)
# Help menu
help_menu = menubar.addMenu('Help')
help_menu.addAction('About', self.show_about)
def setup_connections(self):
"""Connect signals and slots"""
# Arpeggiator signals
self.arpeggiator.playing_state_changed.connect(self.on_playing_state_changed)
self.arpeggiator.tempo_changed.connect(self.on_tempo_changed)
# Output manager signals
self.output_manager.mode_changed.connect(self.on_output_mode_changed)
# Simulator signals now only connected to embedded display in arpeggiator controls
# Connect signals to embedded simulator display in arpeggiator controls
if hasattr(self.arp_controls, 'simulator_display'):
self.channel_manager.active_synth_count_changed.connect(
self.arp_controls.simulator_display.set_synth_count
)
# Disabled lighting_updated connection - using MIDI volume changes instead
# self.simulator.lighting_updated.connect(
# self.arp_controls.simulator_display.update_lighting
# )
self.arpeggiator.note_triggered.connect(
self.arp_controls.simulator_display.on_note_played
)
# Connect MIDI volume changes to display
self.output_manager.volume_sent.connect(
self.arp_controls.simulator_display.on_midi_volume_changed
)
# Initialize display with current volume values
for channel in range(1, 17):
current_volume = self.output_manager.get_channel_volume(channel)
self.arp_controls.simulator_display.on_midi_volume_changed(channel, current_volume)
# Update timer for status display
self.status_timer = QTimer()
self.status_timer.timeout.connect(self.update_status_display)
self.status_timer.start(100) # Update 10 times per second
def apply_dark_theme(self):
"""Apply modern dark theme optimized for live performance"""
dark_palette = QPalette()
# Modern dark colors
dark_palette.setColor(QPalette.Window, QColor(32, 32, 36))
dark_palette.setColor(QPalette.WindowText, QColor(255, 255, 255))
dark_palette.setColor(QPalette.Base, QColor(18, 18, 20))
dark_palette.setColor(QPalette.AlternateBase, QColor(42, 42, 46))
dark_palette.setColor(QPalette.ToolTipBase, QColor(0, 0, 0))
dark_palette.setColor(QPalette.ToolTipText, QColor(255, 255, 255))
dark_palette.setColor(QPalette.Text, QColor(255, 255, 255))
dark_palette.setColor(QPalette.Button, QColor(48, 48, 52))
dark_palette.setColor(QPalette.ButtonText, QColor(255, 255, 255))
dark_palette.setColor(QPalette.BrightText, QColor(255, 100, 100))
dark_palette.setColor(QPalette.Link, QColor(100, 200, 255))
dark_palette.setColor(QPalette.Highlight, QColor(0, 150, 255))
dark_palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255))
self.setPalette(dark_palette)
# Modern performance-oriented styling
self.setStyleSheet("""
/* Main Window */
QMainWindow {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #202024, stop:1 #181820);
}
/* Tabs - More prominent for live use */
QTabWidget::pane {
border: 2px solid #00aaff;
border-radius: 8px;
background-color: #2a2a2e;
}
QTabBar::tab {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #404044, stop:1 #353538);
color: white;
padding: 12px 20px;
margin: 2px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
min-width: 100px;
font-size: 12px;
font-weight: bold;
}
QTabBar::tab:selected {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #0099ff, stop:1 #0077cc);
color: white;
border: 2px solid #00aaff;
}
QTabBar::tab:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #505054, stop:1 #454548);
}
/* Group Boxes - Better organization */
QGroupBox {
font-weight: bold;
font-size: 13px;
color: #ffffff;
border: 2px solid #00aaff;
border-radius: 10px;
margin-top: 20px;
padding-top: 15px;
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #2a2a2e, stop:1 #242428);
}
QGroupBox::title {
subcontrol-origin: margin;
left: 15px;
padding: 0 10px 0 10px;
color: #00aaff;
background-color: #2a2a2e;
border-radius: 5px;
}
/* Buttons - Individual styling only */
QPushButton {
color: white;
font-weight: bold;
}
QPushButton:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #505054, stop:1 #454548);
border: 2px solid #00aaff;
}
QPushButton:pressed {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #353538, stop:1 #2a2a2e);
border: 2px solid #0088cc;
}
/* Combo Boxes - Cleaner look */
QComboBox {
background: #353538;
border: 2px solid #555555;
border-radius: 5px;
color: white;
padding: 5px 10px;
min-width: 120px;
}
QComboBox:hover {
border: 2px solid #00aaff;
}
QComboBox::drop-down {
border: none;
width: 20px;
}
QComboBox::down-arrow {
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid white;
margin-right: 5px;
}
/* Sliders - Better for real-time control */
QSlider::groove:horizontal {
border: 1px solid #555555;
height: 8px;
background: #2a2a2e;
border-radius: 4px;
}
QSlider::handle:horizontal {
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #00aaff, stop:1 #0088cc);
border: 2px solid #ffffff;
width: 20px;
margin: -8px 0;
border-radius: 10px;
}
QSlider::handle:horizontal:hover {
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #00ccff, stop:1 #00aacc);
}
/* Spin Boxes */
QSpinBox {
background: #353538;
border: 2px solid #555555;
border-radius: 5px;
color: white;
padding: 5px;
min-width: 60px;
}
QSpinBox:hover {
border: 2px solid #00aaff;
}
/* Labels - Better contrast */
QLabel {
color: #ffffff;
font-size: 11px;
}
/* Status Bar - More prominent */
QStatusBar {
background: #1a1a1e;
color: #00aaff;
border-top: 1px solid #555555;
font-weight: bold;
padding: 5px;
}
/* Transport Controls - Special styling */
QPushButton#playButton {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #2d5a2d, stop:1 #1a3d1a);
border: 2px solid #4a8a4a;
color: #aaffaa;
font-size: 16px;
font-weight: bold;
min-width: 100px;
min-height: 40px;
}
QPushButton#playButton:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #3d6a3d, stop:1 #2a4d2a);
border: 2px solid #5aaa5a;
}
QPushButton#stopButton {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #5a2d2d, stop:1 #3d1a1a);
border: 2px solid #8a4a4a;
color: #ffaaaa;
font-size: 16px;
font-weight: bold;
min-width: 100px;
min-height: 40px;
}
QPushButton#stopButton:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #6a3d3d, stop:1 #4d2a2a);
border: 2px solid #aa5a5a;
}
QPushButton#panicButton {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #5a2d5a, stop:1 #3d1a3d);
border: 2px solid #8a4a8a;
color: #ffaaff;
font-size: 16px;
font-weight: bold;
min-width: 100px;
min-height: 40px;
}
QPushButton#panicButton:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #6a3d6a, stop:1 #4d2a4d);
border: 2px solid #aa5aaa;
}
""")
@pyqtSlot()
def on_play_clicked(self):
"""Handle play button click"""
# If no notes are held, add some default notes for testing
if not self.arpeggiator.held_notes:
# Add a C major chord for testing
self.arpeggiator.note_on(60) # C
self.arpeggiator.note_on(64) # E
self.arpeggiator.note_on(67) # G
self.statusBar().showMessage("Added test notes (C major chord)", 2000)
success = self.arpeggiator.start()
if not success:
self.statusBar().showMessage("Could not start arpeggiator", 3000)
@pyqtSlot()
def on_stop_clicked(self):
"""Handle stop button click"""
self.arpeggiator.stop()
@pyqtSlot()
def on_panic_clicked(self):
"""Handle panic button click"""
self.output_manager.send_panic()
self.statusBar().showMessage("Panic sent - all notes off", 2000)
@pyqtSlot(bool)
def on_playing_state_changed(self, is_playing):
"""Handle arpeggiator play state change"""
if is_playing:
self.play_button.setText("⏸ Pause")
self.statusBar().showMessage("Arpeggiator playing")
else:
self.play_button.setText("▶ Play")
self.statusBar().showMessage("Arpeggiator stopped")
@pyqtSlot(float)
def on_tempo_changed(self, tempo):
"""Handle tempo change"""
self.tempo_display.setText(f"{tempo:.1f} BPM")
@pyqtSlot(str)
def on_output_mode_changed(self, mode):
"""Handle output mode change"""
if mode == "simulator":
self.mode_indicator.setText("Mode: Simulator")
self.mode_indicator.setStyleSheet("font-weight: bold; color: #00aa00;")
else:
self.mode_indicator.setText("Mode: Hardware")
self.mode_indicator.setStyleSheet("font-weight: bold; color: #aaaa00;")
def update_status_display(self):
"""Update status display information"""
# Update connection status
if self.output_manager.is_connected():
self.connection_status.setText("Connected")
self.connection_status.setStyleSheet("color: #00aa00;")
else:
self.connection_status.setText("Disconnected")
self.connection_status.setStyleSheet("color: #aa0000;")
# Update active voices count
if self.output_manager.current_mode == "simulator":
voice_count = self.simulator.get_active_voices_count()
else:
voice_count = sum(len(voices) for voices in self.channel_manager.active_voices.values())
self.voices_display.setText(f"Voices: {voice_count}")
def reset_layout(self):
"""Reset window layout to default"""
# This could restore default sizes, positions, etc.
self.statusBar().showMessage("Layout reset", 2000)
def show_about(self):
"""Show about dialog"""
from PyQt5.QtWidgets import QMessageBox
QMessageBox.about(self, "About MIDI Arpeggiator",
"MIDI Arpeggiator with Lighting Control\n\n"
"A modular arpeggiator for controlling synthesizers\n"
"and synchronized lighting effects.\n\n"
"Features:\n"
"• FL Studio-style arpeggiator patterns\n"
"• Multi-synth routing and voice management\n"
"• Volume/brightness pattern generation\n"
"• Built-in simulator mode\n"
"• Native Instruments Maschine integration")
def keyPressEvent(self, event):
"""Handle key press for note input"""
key = event.key()
# Avoid key repeat
if event.isAutoRepeat():
return
if key in self.keyboard_notes:
note = self.keyboard_notes[key]
if note not in self.held_keys:
self.held_keys.add(note)
self.arpeggiator.note_on(note)
self.statusBar().showMessage(f"Note ON: {note}", 500)
elif key == Qt.Key_Space:
# Spacebar emergency stop only (does not start playback)
if self.arpeggiator.is_playing:
self.on_stop_clicked()
self.statusBar().showMessage("Emergency stop activated", 1000)
super().keyPressEvent(event)
def keyReleaseEvent(self, event):
"""Handle key release for note input"""
key = event.key()
# Avoid key repeat
if event.isAutoRepeat():
return
if key in self.keyboard_notes:
note = self.keyboard_notes[key]
if note in self.held_keys:
self.held_keys.remove(note)
self.arpeggiator.note_off(note)
self.statusBar().showMessage(f"Note OFF: {note}", 500)
super().keyReleaseEvent(event)
def resizeEvent(self, event):
"""Handle window resize for dynamic scaling"""
super().resizeEvent(event)
if self.scaling_enabled and hasattr(self, 'arp_controls'):
# Calculate new scale factor
new_scale_factor = ScalingUtils.get_scale_factor(self)
# Only update if scale factor changed significantly
if abs(new_scale_factor - self.scale_factor) > 0.1:
self.scale_factor = new_scale_factor
self.update_scaling()
def update_scaling(self):
"""Update all GUI elements with new scaling"""
# Update controls with new scaling
if hasattr(self.arp_controls, 'apply_scaling'):
self.arp_controls.apply_scaling(self.scale_factor)
if hasattr(self.volume_controls, 'apply_scaling'):
self.volume_controls.apply_scaling(self.scale_factor)
if hasattr(self.preset_controls, 'apply_scaling'):
self.preset_controls.apply_scaling(self.scale_factor)
def closeEvent(self, event):
"""Handle window close event"""
# Clean up resources
self.arpeggiator.stop()
self.output_manager.close()
self.simulator.cleanup()
event.accept()