Source code for jdxi_editor.ui.widgets.midi.track
"""
Midi Track Widget
"""
import mido
from decologr import Decologr as log
from PySide6.QtCore import QRectF
from PySide6.QtGui import QColor, QPainter, QPaintEvent, QPixmap
from jdxi_editor.ui.common import JDXi, QWidget
from jdxi_editor.ui.widgets.midi.colors import MIDI_CHANNEL_COLORS
from jdxi_editor.ui.widgets.midi.utils import generate_track_colors, get_first_channel
from picomidi.message.type import MidoMessageType
[docs]
class MidiTrackWidget(QWidget):
"""
MidiTrackWidget
"""
def __init__(
self,
track: mido.MidiTrack,
track_number: int,
total_length: float,
parent: QWidget = None,
):
"""
Initialize the MidiTrackWidget.
:param track: mido.MidiTrack the mido track data
:param track_number: int The track number
:param total_length: float The total length of the longest of the tracks in seconds
:param parent: QWidget Parent widget
"""
super().__init__(parent)
self.setMinimumHeight(JDXi.UI.Style.TRACK_HEIGHT_MINIMUM) # Adjust as needed
if track:
self.set_track(track, total_length)
[docs]
def set_track(self, track: mido.MidiTrack, total_length: float) -> None:
"""
set_track
:param track: mido.MidiTrack
:param total_length: float
:return: None
"""
self.track = track
self.track_data = None
if not track:
return
abs_time = 0
rects = []
channels = set()
note_count = 0
program_changes = []
# Find the first channel in the track
first_channel = None
for msg in track:
if hasattr(msg, "channel"):
first_channel = msg.channel
break
if first_channel is None:
first_channel = 0 # fallback if no channel found
for msg in track:
abs_time += msg.time
if hasattr(msg, "channel"):
channels.add(msg.channel)
if msg.type == MidoMessageType.NOTE_ON.value and msg.velocity > 0:
note_count += 1
norm_time = abs_time / total_length if total_length else 0
# Use first_channel for all notes
rects.append((norm_time, first_channel))
if msg.type == MidoMessageType.PROGRAM_CHANGE.value:
program_changes.append(msg.program)
label = (
f"Track | {track.name if track.name else 'Unnamed'} | Notes: {note_count}"
)
if channels:
label += f" | Channel: {first_channel + 1}" # Display 1-based channel
if program_changes:
label += f" | Prog: {', '.join(map(str, program_changes))}"
self.track_data = {"rects": rects, "label": label, "channels": {first_channel}}
self.cached_pixmap = None
self.cached_width = 0
self.update()
# log.message(f"rects: {rects}")
[docs]
def update_muted_tracks(self, muted_tracks: set[int]) -> None:
"""
Called when the global mute state is updated.
"""
self.muted_tracks = muted_tracks
self.update() # trigger repaint or UI change if needed
[docs]
def update_muted_channels(self, muted_channels: set[int]) -> None:
"""
Called when the global mute state is updated.
"""
self.muted_channels = muted_channels
self.update() # trigger repaint or UI change if needed
[docs]
def paintEvent(self, event: QPaintEvent) -> None:
"""
paintEvent with caching and optimization
"""
if not self.track_data:
return
if self.cached_pixmap is None or self.cached_width != self.width():
self.cached_pixmap = self.render_track_to_pixmap()
self.cached_width = self.width()
painter = QPainter(self)
painter.drawPixmap(0, 0, self.cached_pixmap)
painter.end()
[docs]
def paintEventOld(self, event: QPaintEvent) -> None:
"""
paintEvent
:param event: QPaintEvent
:return: None
"""
if not self.track_data:
return
painter = QPainter(self)
try:
painter.setRenderHint(QPainter.Antialiasing)
# Clear background
painter.fillRect(self.rect(), self.palette().window())
track_height = self.height()
widget_width = self.width()
font = painter.font()
font.setPointSize(8)
painter.setFont(font)
data = self.track_data
y = 0
rects = data["rects"]
label = data["label"]
channels = data["channels"]
muted = any(channel in self.muted_channels for channel in channels)
# Draw background for note range
if rects:
try:
times = [t[0] for t in rects]
max_time = max(times) # Use the last time in the list
scale = widget_width / max_time
start_x = min(times) * scale
end_x = max(times) * scale
# Adjust the end_x to ensure it doesn't go beyond the widget width
if end_x - start_x < self.note_width:
end_x = start_x + self.note_width
track_rect = QRectF(start_x, y, end_x - start_x, int(track_height))
channel = get_first_channel(self.track)
bg_color = generate_track_colors(16)[int(self.track_number) % 16]
if muted:
bg_color.setAlpha(50)
painter.setBrush(bg_color)
painter.drawRect(track_rect)
except Exception as e:
log.error(f"Error drawing track background: {e}")
# Draw notes
for norm_time, channel in rects:
x = norm_time * widget_width
height = track_height
color = MIDI_CHANNEL_COLORS.get(channel, QColor(100, 100, 255, 150))
if channel in self.muted_channels:
color.setAlpha(100)
painter.setBrush(color)
painter.drawRect(QRectF(x, y, self.note_width, height))
# Draw label
painter.setPen(QColor(200, 200, 200))
painter.drawText(5, int(y + 15), label)
finally:
painter.end()
[docs]
def render_track_to_pixmap(self) -> QPixmap:
"""
render_track_to_pixmap
:return: QPixmap
"""
width = self.width()
height = self.height()
pixmap = QPixmap(width, height)
pixmap.fill(self.palette().window().color())
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
font = painter.font()
font.setPointSize(8)
painter.setFont(font)
y = 0
rects = self.track_data["rects"]
label = self.track_data["label"]
channels = self.track_data["channels"]
muted = any(channel in self.muted_channels for channel in channels)
# Compute scale based on last timestamp
times = [t[0] for t in rects]
if not times:
return pixmap
max_time = max(times)
scale = width / max_time if max_time else 1.0
# Background track bar
start_x = min(times) * scale
end_x = max(times) * scale
if end_x - start_x < self.note_width:
end_x = start_x + self.note_width
track_rect = QRectF(start_x, y, end_x - start_x, height)
channel = get_first_channel(self.track)
bg_color = generate_track_colors(16)[int(self.track_number) % 16]
if muted:
bg_color.setAlpha(50)
painter.setBrush(bg_color)
painter.drawRect(track_rect)
# Notes
for norm_time, channel in rects:
x = norm_time * scale
if x + self.note_width < 0 or x > width:
continue # Skip offscreen
color = MIDI_CHANNEL_COLORS.get(channel, QColor(100, 100, 255, 150))
if channel in self.muted_channels:
color.setAlpha(100)
painter.setBrush(color)
painter.drawRect(QRectF(x, y, self.note_width, height))
# Label
painter.setPen(QColor(200, 200, 200))
painter.drawText(5, int(y + 15), label)
painter.end()
return pixmap
[docs]
def change_track_channel(self, track_index: int, new_channel: int) -> None:
"""
change_track_channel
:param track_index: int
:param new_channel: int
:return: None
"""
if not (0 <= new_channel <= 15):
raise ValueError("MIDI channel must be between 0 and 15")
if not (0 <= track_index < len(self.midi_file.tracks)):
raise IndexError("Invalid track index")
track = self.midi_file.tracks[track_index]
for msg in track:
if msg.type in [
MidoMessageType.NOTE_ON.value,
MidoMessageType.NOTE_OFF.value,
MidoMessageType.CONTROL_CHANGE.value,
MidoMessageType.PROGRAM_CHANGE.value,
MidoMessageType.PITCH_WHEEL,
MidoMessageType.AFTERTOUCH,
MidoMessageType.POLYTOUCH,
]:
msg.channel = new_channel
# Force reset by creating a new MidiFile with modified tracks
new_midi = mido.MidiFile()
new_midi.ticks_per_beat = self.midi_file.ticks_per_beat
for t in self.midi_file.tracks:
new_midi.tracks.append(mido.MidiTrack(t)) # Shallow copy is okay here
self.set_midi_file(new_midi)
[docs]
def set_midi_file(self, new_midi: mido.MidiFile):
"""
set_midi_file
:param new_midi: mido.MidiFile
:return: None
"""
self.midi_file = new_midi