diff --git a/encoding.py b/encoding.py index 029e3c0..9886f31 100644 --- a/encoding.py +++ b/encoding.py @@ -63,12 +63,21 @@ def set_bitfield(word, val, position, length): return (word & mask << position) | val +""" +Special level calculation for main/ph outputs +""" def level_to_db(val): return 6 + (val - 0xFF) / 2 +""" +Special level calculation for main/ph outputs +""" +def db_to_level(val): + return int((val - 6) * 2) + 0xFF -def fader_to_db(val): - return 6 + (abs(val) - 0xFF) / 2 + +def db_to_fader(db): + return int(10 ** (db / 20) * 0x40000) def format_db(val, min_db=-120): @@ -76,3 +85,9 @@ def format_db(val, min_db=-120): return '∞' else: return format(val, '.1f') + +def map_range(val, min_, max_, low, high): + return ((clamp(val, min_, max_) - min_) / (max_ - min_)) * (high - low) + low + +def clamp(val, low, high): + return max(min(val, high), low) \ No newline at end of file diff --git a/main.py b/main.py index 58f3377..d331268 100644 --- a/main.py +++ b/main.py @@ -1,51 +1,113 @@ import math +from collections import namedtuple from rich.console import ConsoleOptions, Console from rich.segment import Segment from rich.style import Style +from textual import events from textual.app import App, ComposeResult, Widget, RenderResult from textual.color import Color from textual.containers import HorizontalScroll, Horizontal, Vertical, Container +from textual.events import Event, MouseEvent +from textual.geometry import Size from textual.reactive import reactive, var +from textual.strip import Strip from textual.widgets import Static, Button, Pretty, Log, Footer, Header +from textual.messages import Message import mido -from encoding import decode_message, encode_message, hex_format, get_bitfield, level_to_db, format_db +from encoding import decode_message, encode_message, hex_format, get_bitfield, level_to_db, format_db, clamp import encoding +ChannelState = namedtuple('ChannelState', ['level', 'muted', 'soloed']) + class MixerMatrix: - def __init__(self, out_port: mido.ports.BaseOutput, in_channels = 10, play_channels = 12, out_channels = 12) -> None: + def __init__(self, out_port: mido.ports.BaseOutput, in_channels=10, play_channels=12, out_channels=12) -> None: self.out_port = out_port - self.in_channels = in_channels - self.in_levels = [[-120.0 for _ in range(in_channels)] for _ in range(out_channels)] - self.play_levels = [[0.0 if p == o else -120.0 for p in range(play_channels)] for o in range(out_channels)] - self.out_levels = [0.0 for _ in range(out_channels)] + self.matrix = [[ -120.0 for _ in range(out_channels) ] for _ in range(in_channels)] + [ + [ 0.0 if p == o else -120.0 for p in range(play_channels) + ] for o in range(out_channels)] - def update_out(self, channel, val): - self.out_levels[channel] = val + self.outputs = [0.0 for _ in range(out_channels)] + self.output_mutes = [False for _ in range(out_channels)] + self.output_solos = [False for _ in range(out_channels)] + self.input_mutes = [False for _ in range(in_channels + play_channels)] + self.input_solos = [False for _ in range(in_channels + play_channels)] - def update_in(self, in_channel, out_channel, val): - self.in_levels[out_channel][in_channel] = val - self._send_update(in_channel, out_channel, val) + def toggle_out_solo_status(self, channel): + solo = self.output_solos[channel] = not self.output_solos[channel] + if solo or any(self.output_solos): + for channel, channel_solo in enumerate(self.output_solos): + if channel_solo: + self._send_out_update(channel, self.outputs[channel]) + else: + self._send_out_update(channel, -120) + else: + for channel, level in enumerate(self.outputs): + if not self.output_mutes[channel]: + self._send_out_update(channel, level) - def update_play(self, play_channel, out_channel, val): - self.play_levels[out_channel][play_channel] = val - self._send_update(play_channel + self.in_channels, out_channel, val) + def toggle_out_mute_status(self, channel): + mute = self.output_mutes[channel] = not self.output_mutes[channel] + disabled = self.outputs[channel] == -120.0 + soloed = self.output_solos[channel] + if mute and not disabled and not soloed: + self._send_out_update(channel, -120) + elif not mute and not disabled: + self._send_out_update(channel, self.outputs[channel]) - def _send_out_update(self, out_channel, val): + def set_out_level(self, channel, val): + self.outputs[channel] = clamp(val, -120, 6) + if not self.output_mutes[channel]: + self._send_out_update(channel, self.outputs[channel]) + + def toggle_mix_mute_status(self, in_channel): + mute = self.input_mutes[in_channel] = not self.input_mutes[in_channel] + for out_channel, level in enumerate(self.matrix[in_channel]): + if mute and level != -120: + self._send_mix_update(in_channel, out_channel, -120) + else: + self._send_mix_update(in_channel, out_channel, level) + + def set_mix_level(self, in_channel, out_channel, val): + self.matrix[in_channel][out_channel] = clamp(val, -120, 6) + if not self.input_mutes[in_channel]: + self._send_mix_update(in_channel, out_channel, val) + + def _send_out_update(self, out_channel, db_val): reg = 0x3e0 | out_channel - msg = val << 15 | reg - self.out_port.send(encode_message(encoding.SUBID_MIXER, msg)) - if out_channel < 4: - gain_val = int(0x3b + (val / 2**16) * 0x3b) - gain_msg = gain_val << 16 | 0x10 + out_channel - self.out_port.send(encode_message(encoding.SUBID_GAIN, gain_msg)) + msg = encoding.db_to_fader(db_val) << 12 | reg - def _send_update(self, in_channel, out_channel, val): + self.out_port.send(encode_message(encoding.SUBID_MIXER, [msg])) + if out_channel < 4: + gain_val = encoding.db_to_level(clamp(db_val, -92, 6)) + gain_msg = gain_val << 16 | 0x2 + out_channel + self.out_port.send(encode_message(encoding.SUBID_GAIN, [gain_msg])) + + def _send_mix_update(self, in_channel, out_channel, db_val): reg = out_channel * 24 + in_channel - msg = val << 15 | reg - self.out_port.send(encode_message(encoding.SUBID_MIXER, msg)) + msg = encoding.db_to_fader(db_val) << 12 | reg + self.out_port.send(encode_message(encoding.SUBID_MIXER, [msg])) + + +class FaderIndicator(Widget): + DEFAULT_CSS = """ + FaderIndicator { + width: 1; + } + """ + value = reactive(0.925) + def render_line(self, y: int): + if y == math.floor((self.region.height - 1) * (1 - self.value)): + c = '<' + else: + c = ' ' + return Strip((Segment(c),), 1) + + def get_content_width(self, container: Size, viewport: Size) -> int: + return 1 + class MeterGradient: FRACTIONS = [ @@ -65,7 +127,6 @@ class MeterGradient: self._color2 = Color.parse(color2) self._value = 1 - value - def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: @@ -102,6 +163,7 @@ class Meter(Widget): } """ value = reactive(1.0) + def render(self) -> RenderResult: return MeterGradient(self.value, '#04ff00', '#04aa00') @@ -109,7 +171,7 @@ class Meter(Widget): class Channel(Widget): DEFAULT_CSS = """ Channel { - width: 11; + width: 12; border: solid #333333; Horizontal { height: 100%; @@ -129,17 +191,17 @@ class Channel(Widget): min-width: 6; height: 1; } - .mute { + .mute.enabled { background: #00f !important; } - .solo { - background: #0ff; + .solo.enabled { + background: #0ff !important; } - .phantom { - background: #f00; + .phantom.enabled { + background: #f00 !important; } - .inst { - background: #ff0; + .inst.enabled { + background: #ff0 !important; } .indicators-wrap { margin-left: 1; @@ -155,19 +217,85 @@ class Channel(Widget): text-style: bold; } } + Channel.selected { + border: #fff; + } + Channel:focus { + background: #222; + border: #fff; + } """ + BINDINGS = [ + ('equals', 'move_fader(.5)'), + ('plus', 'move_fader(.5)'), + ('minus', 'move_fader(-.5)'), + ('m', 'mute'), + ('s', 'solo'), + ('p', 'phantom'), + ('i', 'inst'), + ('q', 'gain(-3)'), + ('e', 'gain(3)'), + ] + + can_focus = True + selected = reactive(False) + mute = reactive(False) + solo = reactive(False) + phantom = reactive(False) + inst = reactive(False) + gain = reactive(0.0) + level = reactive(0.0) rms_value = var(0) peak_value = var(0) - level_value = var(0x20000000) db_rms_value = reactive(-100.0) db_peak_value = reactive(-100.0) - db_level_value = reactive(0.0) - RMS_FACTOR = float(2**54) - PEAK_FACTOR = float(2**23) + RMS_FACTOR = float(2 ** 54) + PEAK_FACTOR = float(2 ** 23) - def compute_db_level_value(self) -> float: - return level_to_db(self.level_value) + class MoveFader(Message): + def __init__(self, channel: int, output=False, change=0): + self.channel = channel + self.output = output + self.change = change + super().__init__() + + class ChangeGain(MoveFader): + pass + + class ChannelMessage(Message): + def __init__(self, channel: int, output=False): + self.channel = channel + self.output = output + super().__init__() + + class MuteToggle(ChannelMessage): + pass + + class SoloToggle(ChannelMessage): + pass + + class PhantomToggle(ChannelMessage): + pass + + class InstToggle(ChannelMessage): + pass + + class LevelSet(Message): + def __init__(self, channel: int, val: int, output=False): + self.channel = channel + self.val = val + self.output = output + super().__init__() + + + def __init__(self, channel: int, label: str, phantom=False, inst=False, output=False, *children: Widget): + super().__init__(*children) + self.channel = channel + self.label = label + self.phantom_available = phantom + self.inst_available = inst + self.output = output def compute_db_peak_value(self) -> float: try: @@ -181,41 +309,128 @@ class Channel(Widget): except ValueError: return -120 - def watch_db_level_value(self, val) -> None: + def watch_level(self, val) -> None: self.query_one('.level').update(format_db(val)) + self.query_one('#level-indicator').value = encoding.map_range(val, -75, 6, 0, 1) + + def watch_gain(self, val) -> None: + if not self.phantom_available: + return + + self.query_one('#gain').update(format_db(val)) + + def watch_mute(self, val) -> None: + if val: + self.query_one('#mute').add_class('enabled') + else: + self.query_one('#mute').remove_class('enabled') + + def watch_solo(self, val) -> None: + if val: + self.query_one('#solo').add_class('enabled') + else: + self.query_one('#solo').remove_class('enabled') + + def watch_phantom(self, val) -> None: + if not self.phantom_available: + return + + if val: + self.query_one('#phantom').add_class('enabled') + else: + self.query_one('#phantom').remove_class('enabled') + + def watch_inst(self, val) -> None: + if not self.inst_available: + return + + if val: + self.query_one('#inst').add_class('enabled') + else: + self.query_one('#inst').remove_class('enabled') + + def watch_selected(self, val) -> None: + if val: + self.add_class('selected') + else: + self.remove_class('selected') + def watch_db_peak_value(self, val) -> None: self.query_one('.peak').update(format_db(val)) def watch_db_rms_value(self, val) -> None: self.query_one('.rms').update(format_db(val)) - self.query_one(Meter).value = (val + 90) / 110 - - - def __init__(self, label, phantom=False, inst=False, *children: Widget): - super().__init__(*children) - self.label = label - self.phantom = phantom - self.inst = inst + self.query_one(Meter).value = encoding.map_range(val, -75, 6, 0, 1) def compose(self) -> ComposeResult: yield Static(self.label, classes='label') with Horizontal(): - yield Meter() + yield Meter(id='meter') + yield FaderIndicator(id='level-indicator') with Vertical(): with Vertical(classes='controls'): - yield Button("M", classes='mute', compact=True) - yield Button("S", classes='solo', compact=True) - if self.phantom: - yield Button("48v", classes='phantom', compact=True) - if self.inst: - yield Button("Inst", classes='inst', compact=True) + yield Button("M", classes='mute', compact=True, id='mute') + yield Button("S", classes='solo', compact=True, id='solo') + if self.phantom_available: + yield Button("48v", classes='phantom', compact=True, id='phantom') + if self.inst_available: + yield Button("Inst", classes='inst', compact=True, id='inst') + if self.phantom_available: + yield Static('0.0', id='gain') with Vertical(classes='indicators-wrap'): with Container(classes='indicators'): yield Static('0.0', classes='level') yield Static('0.0', classes='peak') yield Static('0.1', classes='rms') + def action_mute(self) -> None: + self.post_message(self.MuteToggle(self.channel, output=self.output)) + + def action_solo(self) -> None: + self.post_message(self.SoloToggle(self.channel, output=self.output)) + + def action_phantom(self) -> None: + if not self.phantom_available: + return + + self.post_message(self.PhantomToggle(self.channel, output=self.output)) + + def action_inst(self) -> None: + if not self.inst_available: + return + + self.post_message(self.InstToggle(self.channel, output=self.output)) + + def action_move_fader(self, change) -> None: + self.post_message(self.MoveFader(self.channel, output=self.output, change=change)) + + def action_gain(self, change) -> None: + self.post_message(self.ChangeGain(self.channel, output=self.output, change=change)) + + def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: + if event.shift and self.phantom_available: + self.action_gain(3) + else: + self.action_move_fader(1) + + def _on_mouse_scroll_down(self, event: events.MouseScrollUp) -> None: + if event.shift and self.phantom_available: + self.action_gain(-3) + else: + self.action_move_fader(-1) + + def on_button_pressed(self, event): + if 'mute' == event.button.id: + self.action_mute() + elif 'solo' == event.button.id: + self.action_solo() + elif 'phantom' == event.button.id: + self.action_phantom() + elif 'inst' == event.button.id: + self.action_inst() + + class ChannelRow(Widget): DEFAULT_CSS = """ @@ -228,6 +443,10 @@ class ChannelRow(Widget): peak_values = var([]) level_values = var([]) + def __init__(self, channels: range, *children: Widget): + super().__init__(*children) + self.channels = channels + def watch_rms_values(self, new_values): for channel, value in zip(self.query(Channel), new_values): channel.rms_value = value @@ -240,52 +459,93 @@ class ChannelRow(Widget): for channel, value in zip(self.query(Channel), new_values): channel.level_value = value + def update_mixer_display(self, mixer: MixerMatrix, current_out: int): + pass + + class InputChannelRow(ChannelRow): def compose(self) -> ComposeResult: with HorizontalScroll(): - yield Channel('AN1', phantom=True) - yield Channel('AN2', phantom=True, inst=True) - yield Channel('ADAT1') - yield Channel('ADAT2') - yield Channel('ADAT3') - yield Channel('ADAT4') - yield Channel('ADAT5') - yield Channel('ADAT6') - yield Channel('ADAT7') - yield Channel('ADAT8') + yield Channel(self.channels[0], 'AN1', phantom=True) + yield Channel(self.channels[1], 'AN2', phantom=True, inst=True) + for n in range(0, 8): + yield Channel(channel=self.channels[n+2], label='ADAT{n}'.format(n=n+1)) + + def update_mixer_display(self, mixer: MixerMatrix, current_out: int): + for channel_widget, levels, mute, solo in zip( + self.query(Channel), + mixer.matrix[:self.channels[-1]], + mixer.input_mutes[:self.channels[-1]], + mixer.input_solos[:self.channels[-1]]): + channel_widget.level = levels[current_out] + channel_widget.mute = mute + channel_widget.solo = solo + class PlaybackChannelRow(ChannelRow): + LABELS = [ + 'AN1', + 'AN2', + 'PH3', + 'PH4', + 'ADAT1', + 'ADAT2', + 'ADAT3', + 'ADAT4', + 'ADAT5', + 'ADAT6', + 'ADAT7', + 'ADAT8', + ] + def compose(self) -> ComposeResult: with HorizontalScroll(): - yield Channel('AN1') - yield Channel('AN2') - yield Channel('PH3') - yield Channel('PH4') - yield Channel('ADAT1') - yield Channel('ADAT2') - yield Channel('ADAT3') - yield Channel('ADAT4') - yield Channel('ADAT5') - yield Channel('ADAT6') - yield Channel('ADAT7') - yield Channel('ADAT8') + for n, label in zip(self.channels, self.LABELS): + yield Channel(channel=n, label=label) + + def update_mixer_display(self, mixer: MixerMatrix, current_out: int): + for channel_widget, levels, mute, solo in zip( + self.query(Channel), + mixer.matrix[self.channels[0]:self.channels[-1]], + mixer.input_mutes[self.channels[0]:self.channels[-1]], + mixer.input_solos[self.channels[0]:self.channels[-1]]): + channel_widget.level = levels[current_out] + channel_widget.mute = mute + channel_widget.solo = solo class OutputChannelRow(ChannelRow): + LABELS = [ + 'AN1', + 'AN2', + 'PH3', + 'PH4', + 'ADAT1', + 'ADAT2', + 'ADAT3', + 'ADAT4', + 'ADAT5', + 'ADAT6', + 'ADAT7', + 'ADAT8', + ] + def compose(self) -> ComposeResult: with HorizontalScroll(): - yield Channel('AN1') - yield Channel('AN2') - yield Channel('PH3') - yield Channel('PH4') - yield Channel('ADAT1') - yield Channel('ADAT2') - yield Channel('ADAT3') - yield Channel('ADAT4') - yield Channel('ADAT5') - yield Channel('ADAT6') - yield Channel('ADAT7') - yield Channel('ADAT8') + with HorizontalScroll(): + for n, label in zip(self.channels, self.LABELS): + yield Channel(channel=n, label=label, output=True) + def update_mixer_display(self, mixer: MixerMatrix, current_out: int): + for idx, channel_widget, level, mute, solo in zip( + self.channels, + self.query(Channel), + mixer.outputs, + mixer.output_mutes, + mixer.output_solos): + channel_widget.level = level + channel_widget.mute = mute + channel_widget.solo = solo + channel_widget.selected = idx == current_out class BFStatus: word0 = 0 @@ -315,20 +575,28 @@ class BFStatus: if len(msg) > 2: self.word1 = format(msg[1], '032b') self.word2 = format(msg[2], '032b') - self.ch1_48v = get_bitfield(msg[0], 31, 1) - self.ch2_48v = get_bitfield(msg[0], 30, 1) + self.ch1_48v = get_bitfield(msg[0], 30, 1) + self.ch2_48v = get_bitfield(msg[0], 31, 1) self.ch2_inst = get_bitfield(msg[0], 29, 1) self.digital_out = get_bitfield(msg[0], 28, 1) self.out_1_vol = level_to_db(get_bitfield(msg[1], 0, 8)) self.out_2_vol = level_to_db(get_bitfield(msg[1], 9, 8)) - self.ch2_gain = get_bitfield(msg[1], 24, 5) - self.ch1_gain = get_bitfield(msg[1], 19, 5) - self.out_3_vol = level_to_db(get_bitfield(msg[2], 15, 8)) + self.ch2_gain = get_bitfield(msg[1], 24, 5) * 3 + 6 + self.ch1_gain = get_bitfield(msg[1], 19, 5) * 3 + 6 + self.out_3_vol = level_to_db(get_bitfield(msg[2], 14, 8)) self.out_4_vol = level_to_db(get_bitfield(msg[2], 23, 8)) self.dim = get_bitfield(msg[2], 5, 1) + def out_levels(self): + return ( + self.out_1_vol, + self.out_2_vol, + self.out_3_vol, + self.out_4_vol, + ) + class BFRMS: def __init__(self): @@ -354,6 +622,7 @@ class BFRMS: self._parse_rms(id1_msg[10:34], self.output_rms) self._parse_rms(id1_msg[34:38], self.fx_in_rms) + class BFPeak: def __init__(self): self.input_peaks = [0 for _ in range(10)] @@ -382,13 +651,27 @@ class Status(Widget): class Mixer(App): in_port: mido.ports.BaseInput - out_port: mido.ports.BaseInput + out_port: mido.ports.BaseOutput messages = [] messageset = set() STATUS_MSG = mido.Message('sysex', data=[0x00, 0x20, 0x0D, 0x10, 0x10]) bf_status = BFStatus() bf_rms = BFRMS() bf_peak = BFPeak() + current_mix_out = 0 + focused_channel = (0, 2) + + INPUTS = 10 + PLAYBACKS = 12 + OUTPUTS = 12 + BINDINGS = [ + ('o', 'mix_select(1)'), + ('u', 'mix_select(-1)'), + ('h', 'change_focus(-1, 0)'), + ('j', 'change_focus(0, 1)'), + ('k', 'change_focus(0, -1)'), + ('l', 'change_focus(1, 0)'), + ] def __init__(self): super().__init__() @@ -404,6 +687,8 @@ class Mixer(App): else: self.out_port = mido.open_output('Mixer', virtual=True) + self.mixer = MixerMatrix(self.out_port) + def on_mount(self) -> None: self.set_interval(.1, self.update_messages) @@ -414,11 +699,83 @@ class Mixer(App): def compose(self) -> ComposeResult: yield Header() - yield InputChannelRow() - yield PlaybackChannelRow() - yield OutputChannelRow() + yield InputChannelRow(channels=range(0, self.INPUTS)) + yield PlaybackChannelRow(channels=range(self.INPUTS, self.PLAYBACKS + self.INPUTS)) + yield OutputChannelRow(channels=range(0, self.OUTPUTS)) yield Footer() + """ + Change the mix output + """ + def action_mix_select(self, n): + self.current_mix_out = (self.current_mix_out + n) % self.OUTPUTS + self.update_mixer_display() + + """ + Update focused channel. Mix output follows focus. + """ + def action_change_focus(self, dx, dy): + (x, y) = self.focused_channel + y = (y + dy) % 3 + x = x + dx + if y == 0: + x = x % self.INPUTS + elif y == 1: + x = x % self.PLAYBACKS + elif y == 2: + x = x % self.OUTPUTS + self.current_mix_out = x + + self.focused_channel = (x, y) + ch = self.query(ChannelRow)[y].query(Channel)[x] + self.query(ChannelRow)[y].query(Channel)[x].focus() + self.update_mixer_display() + + def _move_fader(self, dval): + + self.update_mixer_display() + + def on_channel_phantom_toggle(self, message: Channel.PhantomToggle) -> None: + if message.channel == 0: + self.out_port.send(encode_message(3, [0x00010001 ^ self.bf_status.ch1_48v << 16])) + elif message.channel == 1: + self.out_port.send(encode_message(3, [0x00020002 ^ self.bf_status.ch2_48v << 17])) + + def on_channel_inst_toggle(self, message: Channel.PhantomToggle) -> None: + if message.channel == 1: + self.out_port.send(encode_message(3, [0x00100010 ^ self.bf_status.ch2_inst << 20])) + + def on_channel_move_fader(self, message: Channel.MoveFader) -> None: + if message.output: + self.mixer.set_out_level(message.channel, self.mixer.outputs[message.channel] + message.change) + else: + self.mixer.set_mix_level(message.channel, self.current_mix_out, + self.mixer.matrix[message.channel][self.current_mix_out] + message.change) + self.update_mixer_display() + + def on_channel_change_gain(self, message: Channel.ChangeGain) -> None: + if message.channel > 1: + return + + if message.channel == 0: + gain = clamp(message.change + self.bf_status.ch1_gain, 6, 60) + elif message.channel == 1: + gain = clamp(message.change + self.bf_status.ch2_gain, 6, 60) + + gain_scaled = int((gain - 6) / 3) + self.out_port.send(encode_message(4, [gain_scaled << 16 | message.channel])) + + def on_channel_mute_toggle(self, message: Channel.MuteToggle) -> None: + if message.output: + self.mixer.toggle_out_mute_status(message.channel) + else: + self.mixer.toggle_mix_mute_status(message.channel) + self.update_mixer_display() + + def update_mixer_display(self): + for row in self.query(ChannelRow): + row.update_mixer_display(self.mixer, self.current_mix_out) + def process_message(self, msg: mido.Message) -> None: subid, decoded_msg = decode_message(msg) if subid is None: @@ -434,25 +791,36 @@ class Mixer(App): self.messages.append(hex_format(decoded_msg)) self.query_one('#log').update(self.messages) - return input_row = self.query_one(InputChannelRow) input_row.rms_values = self.bf_rms.input_rms input_row.peak_values = self.bf_peak.input_peaks - input_row.mutate_reactive(ChannelRow.rms_values) - input_row.mutate_reactive(ChannelRow.peak_values) + channels = input_row.query(Channel) + channels[0].phantom = self.bf_status.ch1_48v + channels[1].phantom = self.bf_status.ch2_48v + channels[1].inst = self.bf_status.ch2_inst + channels[0].gain = self.bf_status.ch1_gain + channels[1].gain = self.bf_status.ch2_gain playback_row = self.query_one(PlaybackChannelRow) playback_row.rms_values = self.bf_rms.playback_rms playback_row.peak_values = self.bf_peak.playback_peaks - playback_row.mutate_reactive(ChannelRow.rms_values) - playback_row.mutate_reactive(ChannelRow.peak_values) output_row = self.query_one(OutputChannelRow) output_row.rms_values = self.bf_rms.output_rms output_row.peak_values = self.bf_peak.output_peaks - output_row.mutate_reactive(ChannelRow.rms_values) - output_row.mutate_reactive(ChannelRow.peak_values) - #self.query_one('#status').update(self.bf_status.__dict__) + + dirty_faders = False + for n, mixvol in enumerate(self.bf_status.out_levels()): + if self.mixer.outputs[n] != mixvol: + self.mixer.outputs[n] = mixvol + dirty_faders = True + + if dirty_faders: + self.update_mixer_display() + + for row in [input_row, playback_row, output_row]: + row.mutate_reactive(ChannelRow.rms_values) + row.mutate_reactive(ChannelRow.peak_values) if __name__ == '__main__':