#!/usr/bin/env python3
"""A winch control system with GUI."""
################################################################
# Written in 2018-2019 by Garth Zeglin <garthz@cmu.edu>
# To the extent possible under law, the author has dedicated all copyright
# and related and neighboring rights to this software to the public domain
# worldwide. This software is distributed without any warranty.
# You should have received a copy of the CC0 Public Domain Dedication along with this software.
# If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
################################################################
# standard Python libraries
from __future__ import print_function
import sys, logging, signal
# for documentation on the PyQt5 API, see http://pyqt.sourceforge.net/Docs/PyQt5/index.html
from PyQt5 import QtCore, QtWidgets
# This uses several modules from the course library.
import kf.QtWinch
import kf.QtMPD218
import kf.QtConfig
import kf.QtLog
import kf.QtPlotter
import kf.path
import kf.midi
import kf.osc
import kf.sim
import kf.winch
# set up logger for module
log = logging.getLogger('exercise5')
################################################################
[docs]class ControlLogic(kf.midi.MIDIProcessor):
"""Core performance logic for processing MIDI input into winch commands."""
def __init__(self):
super(ControlLogic,self).__init__()
self.winches = None
self.display = None
self.all_axes = range(4) # index list for updating all motors
self.frequency = 1.0
self.damping_ratio = 1.0
self.winch_dir = [0,0,0,0] # array to keep track of currently moving winches for aftertouch control
# set up a metronome timer
self.tempo = 60 # metronome rate in beats per minute
self.metronome_timer = QtCore.QTimer()
self.metronome_timer.start(int(60000/self.tempo)) # units are milliseconds
self.metronome_timer.timeout.connect(self.metronome_tick)
# state variables for the metronome process
self.pulsing = [0,0,0,0] # array of offsets for metronomic output pulsing
return
[docs] def connect_winches(self, winches):
"""Attach a winch output device to the performance logic, either physical or simulated."""
self.winches = winches
[docs] def connect_display(self, display):
"""Attach a console status output device to the performance logic."""
self.display = display
#---- methods related to the metronome -------------------------------------
[docs] def set_metronome_tempo(self, tempo):
"""Adjust the metronome timer rate. N.B. the underlying Qt QTimer accepts
intervals in milliseconds, so this is fairly precise for beats but will
be approximate for small subdivisions. E.g. a 120 BPM timer at 500 ms
is precise, but the 32nd note subdivision at 62.5 ms would be rounded to
62 ms and run about 1% slow.
:param tempo: tempo in BPM
"""
self.tempo = tempo
self.metronome_timer.setInterval(int(60000/self.tempo)) # units are milliseconds
[docs] def metronome_tick(self):
"""Callback invoked at regular intervals governed by the metronome timer. In
this particular example, the metronome can trigger a regular series of
alternating forward and back movements to excite the path generator
oscillators.
"""
# check if any pulsing is active, i.e., any value is non-zero
if any(self.pulsing):
# send out the next pulse movement command
self.winches.increment_target(self.all_axes, self.pulsing)
# and reverse the directions for the next iteration
self.pulsing = [-d for d in self.pulsing]
#---- methods to process MIDI messages -------------------------------------
[docs] def note_on(self, channel, key, velocity):
"""Process a MIDI Note On event."""
log.debug("ControlLogic received note on: %d, %d", key, velocity)
row, col, bank = self.decode_mpd218_key(key)
# Each column maps to a specific winch.
# Rows 0 and 1 move forward, rows 2 and 3 move backward.
# The middle rows make small motions, the outer rows make larger motions.
delta = 5 * velocity if row <= 1 else -5 * velocity
if row == 0 or row == 3:
delta = delta * 8
if bank == 0 or bank == 1: # bank A or B directly control the winches
self.winches.increment_target(col, delta)
self.winch_dir[col] = 1 if delta > 0 else -1 if delta < 0 else 0
self.pulsing[col] = 0 # reset pulsing on this winch
else: # bank C pads instead invoke the metronome oscillation
self.pulsing[col] = delta//8
log.debug("Pulsing array now %s", self.pulsing)
[docs] def note_off(self, channel, key, velocity):
"""Process a MIDI Note Off event."""
log.debug("ControlLogic received note off: %d, %d", key, velocity)
row, col, bank = self.decode_mpd218_key(key)
self.winch_dir[col] = 0
self.winches.set_velocity(col, 0)
[docs] def control_change(self, channel, cc, value):
"""Process a MIDI Control Change event."""
knob, bank = self.decode_mpd218_cc(cc)
log.debug("Control change %d on knob %d bank %d", cc, knob, bank)
if knob == 1: # Knob #1 on MPD218, use to control resonant frequency
self.frequency = 0.05 + 0.1 * value
elif knob == 2: # Knob #2 on on MPD218, use to control damping ratio
self.damping_ratio = 0.05 + 0.01 * value
self.winches.set_freq_damping(self.all_axes, self.frequency, self.damping_ratio)
self.display.set_status("Frequency: %f, damping ratio: %f" % (self.frequency, self.damping_ratio))
return
[docs] def channel_pressure(self, channel, pressure):
"""Process a MIDI Channel Pressure event."""
velocities = [direction * 10 * pressure for direction in self.winch_dir]
self.winches.set_velocity(self.all_axes, velocities)
log.debug("aftertouch: %d, velocities: %s", pressure, velocities)
################################################################
[docs]class SimWindow(QtWidgets.QMainWindow):
"""A custom main window which provides all GUI controls. This generally follows
a model-view-controller convention in which this window provides the views,
passing events to the application controller via callbacks.
"""
def __init__(self):
super(SimWindow,self).__init__()
# the graphical state
self.cartoons = list() # WinchCartoon objects
# other possible callbacks
self.close_handler = None
# create the GUI elements
self._setupUi()
# finish initialization
self.show()
return
# ------------------------------------------------------------------------------------------------
def _setupUi(self):
# basic window setup
self.setWindowTitle("KF System Controller: Ex 5")
self.statusbar = QtWidgets.QStatusBar(self)
self.setStatusBar(self.statusbar)
# set up tabbed page structure
self.tabs = QtWidgets.QTabWidget()
self.setCentralWidget(self.tabs)
self.tabs.currentChanged.connect(self._tab_changed)
# set up the main tab
self.mainTab = QtWidgets.QWidget(self)
self.mainLayout = QtWidgets.QVBoxLayout(self.mainTab)
self.tabs.addTab(self.mainTab, 'Main')
# generate a horizontal array of winch cartoons
self.winchSet = kf.QtWinch.QtWinchSet()
self.mainLayout.addWidget(self.winchSet)
self.cartoons = self.winchSet.winches()
# generate a simulated MPD218 controller
self.MIDI_controller = kf.QtMPD218.QtMPD218()
self.mainLayout.addWidget(self.MIDI_controller)
# set up the configuration tab
self.configForm = kf.QtConfig.QtConfigForm()
self.tabs.addTab(self.configForm, 'Config')
self.oscListenerConfig = kf.QtConfig.QtConfigOSCPort()
self.configForm.addField("OSC message listener address:port", self.oscListenerConfig)
self.midiCombo = kf.QtConfig.QtConfigComboBox()
self.configForm.addField("MIDI input", self.midiCombo)
self.winchSelect = kf.QtConfig.QtConfigComboBox()
self.configForm.addField("Winch serial port", self.winchSelect)
# set up the logging tab
self.logDisplay = kf.QtLog.QtLog(level=logging.INFO)
self.tabs.addTab(self.logDisplay, 'Log')
# set up a more complex cartoon showing a suspended plotter system
self.plotter = kf.QtPlotter.QtPlotterCartoon()
self.tabs.addTab(self.plotter, 'Plotter')
return
# --- configure callbacks to connect GUI to application controller -----------------------------
[docs] def connect_close_handler(self, handler):
"""Connect a callback to be invoked when the window is about to close."""
self.close_handler = handler
[docs] def connect_midi_processor(self, processor):
"""Connect an object to receive synthetic MIDI events; the object is assumed to have MIDIProcessor methods."""
self.MIDI_controller.connect_midi_processor(processor)
[docs] def connect_osc_listener(self, listener):
"""Connect an OSC network listener to the port configuration control."""
self.oscListenerConfig.callback = listener.set_OSC_port
[docs] def connect_midi_listener(self, listener):
"""Connect a MIDI input listener to the port configuration control."""
self.midiCombo.callback = listener.open_MIDI_input
self.midiCombo.set_items(listener.get_midi_port_names())
[docs] def connect_winch(self, winch):
"""Connect a serial winch output to the port configuration control."""
self.winchSelect.callback = winch.set_and_open_port
self.winchSelect.set_items(winch.available_ports())
return
# --- window and Qt event processing -------------------------------------------------------------
[docs] def set_status(self, string):
"""Update the status bar at the bottom of the display to show the provided string."""
self.statusbar.showMessage(string)
return
def _tab_changed(self, index):
log.debug("Tab changed to %d", index)
return
[docs] def closeEvent(self, event):
"""Qt callback received before windows closes."""
log.info("Received window close event.")
if self.close_handler is not None:
self.close_handler()
super(SimWindow,self).closeEvent(event)
return
# --------------------------------------------------------------------------------------------------
################################################################
[docs]class OutputPatcher(object):
"""This object routes winch output commands to one or more streams. E.g. it can duplicate
outputs to both a serial port winch and a simulation.
"""
def __init__(self):
self.outputs = list()
[docs] def add_output(self, output):
self.outputs.append(output)
#------------------------------------------------------------------------------
# The command API follows which mimics the interface to the actual winches.
[docs] def set_target(self, axis, position):
for output in self.outputs:
output.set_target(axis, position)
[docs] def increment_target(self, axis, offset):
for output in self.outputs:
output.increment_target(axis, offset)
[docs] def set_velocity(self, axis, velocity):
for output in self.outputs:
output.set_velocity(axis, velocity)
[docs] def set_freq_damping(self, axis, freq, ratio):
for output in self.outputs:
output.set_freq_damping(axis, freq, ratio)
################################################################
[docs]class MainApp(object):
"""Main application controller object holding any non-GUI related state."""
def __init__(self):
# create the interface window
self.window = SimWindow()
self.window.connect_close_handler(self._close_handler)
# initialize the winch set simulator
self.sim = kf.sim.SimWinch()
# Initialize the performance logic.
self.control_logic = ControlLogic()
self.control_logic.connect_display(self.window)
self.window.connect_midi_processor(self.control_logic)
# Initialize the MIDI input system.
self.midi_listener = kf.midi.QtMIDIListener()
self.midi_listener.connect_midi_processor(self.control_logic)
self.window.connect_midi_listener(self.midi_listener)
# Initialize the OSC message dispatch system.
self.osc_listener = kf.osc.QtOSCListener()
self.osc_listener.map_handler("/midi", self._received_remote_midi)
self.osc_listener.open_receiver()
self.window.connect_osc_listener(self.osc_listener)
# Initialize the hardware winch system.
self.winch = kf.winch.QtSerialWinch()
self.window.connect_winch(self.winch)
# Create a virtual output to duplicate commands to simulation and winch.
self.output = OutputPatcher()
self.output.add_output(self.winch)
self.output.add_output(self.sim)
self.control_logic.connect_winches(self.output)
# start the animation timer
self.frame_timer = QtCore.QTimer()
self.frame_timer.start(40) # units are milliseconds
self.frame_timer.timeout.connect(self._timer_tick)
# Attach a handler to the keyboard interrupt (control-C).
signal.signal(signal.SIGINT, self._sigint_handler)
return
# ---- application event handlers -----------------------------------------------
def _timer_tick(self):
# Method called at intervals by the animation timer to update the model and graphics.
self.sim.update_for_interval(0.040)
positions = self.sim.positions()
for pos, cartoon in zip(positions, self.window.cartoons):
cartoon.update_position(pos)
self.window.plotter.update_positions(positions[0:2])
def _sigint_handler(self, signal, frame):
print("Keyboard interrupt caught, running _close_handler()...")
self._close_handler()
sys.exit(0)
def _close_handler(self):
log.info("_close_handler closing any winches.")
# This will help to avoid stale lock files.
self.winch.close()
# ---- process OSC network messages ---------------------------------------
# The midi_osc_bridge.py tool can tunnel MIDI messages over the network as
# OSC packets. This routes any bridged MIDI data into the same performance
# logic.
def _received_remote_midi(self, msgaddr, *args):
log.debug("remote midi: %s", " ".join([str(arg) for arg in args]))
self.control_logic.decode_message(args)
return
################################################################
def _main():
# initialize the Qt system itself
app = QtWidgets.QApplication(sys.argv)
# create the main application controller
main = MainApp()
# run the event loop until the user is done
log.info("Starting event loop.")
sys.exit(app.exec_())
################################################################
# Main script follows. This sequence is executed when the script is initiated from the command line.
if __name__ == "__main__":
_main()