#!/usr/bin/env python3
"""A simulated winch 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
# 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.QtPanelScene
import kf.path
import kf.midi
import kf.osc
import kf.sim
# set up logger for module
log = logging.getLogger('sim2')
################################################################
# Please keep these user instructions up to date with the ControlLogic class.
help_tab_text = """\
General Help
(N.B. may not be up to date with code.)
Pad bank A: direct control of eight winches
red-L-up red-R-up yel-L-up yel-R-up
red-L-dn red-R-dn yel-L-dn yel-R-dn
grn-L-up grn-R-up blu-L-up blu-R-up
grn-L-dn grn-R-dn blu-L-dn blu-R-dn
Pad bank B: fixed setpoints for eight winches
red-L-top red-R-top yel-L-top yel-R-top
red-L-bot red-R-bot yel-L-bot yel-R-bot
grn-L-top grn-R-top blu-L-top blu-R-top
grn-L-bot grn-R-bot blu-L-bot blu-R-bot
Pad bank C: set pulsing mode for eight winches
Knob 1: tempo (freq) of winch oscillation
Knob 2: damping of winch oscillation
Knob 3-6: stage lighting intensity level
"""
################################################################
[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.lights = None
self.channels = 8
self.all_axes = range(self.channels) # index list for updating all motors
self.frequency = 1.0
self.damping_ratio = 1.0
self.winch_dir = [0]*self.channels # 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]*self.channels # 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
[docs] def connect_lights(self, lights):
"""Attach a stage lighting output device to the performance logic."""
self.lights = lights
#---- 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)
# two pads per winch in front-back pairs
winch = col + 4*(row//2)
delta = 5 * velocity
if row == 0 or row == 2: delta = -delta
if col == 1 or col == 3: delta = -delta
if bank == 0: # bank A directly controls the winches
self.winches.increment_target(winch, delta)
self.winch_dir[winch] = 1 if delta > 0 else -1 if delta < 0 else 0
self.pulsing[winch] = 0 # reset pulsing on this winch
elif bank == 1: # bank B invokes fixed setpoints
self.pulsing[winch] = 0 # reset pulsing on this winch
if row == 0 or row == 2:
# lower setpoint
if col == 0 or col == 2:
self.winches.set_target(winch, -6000)
else:
self.winches.set_target(winch, 6000)
else:
# upper setpoint
if col == 0 or col == 2:
self.winches.set_target(winch, 0)
else:
self.winches.set_target(winch, 0)
else: # bank C pads invoke the metronome oscillation
self.pulsing[winch] = delta
log.debug("Pulsing array now %s", self.pulsing)
return
[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)
winch = col + 4*(row//2)
self.winch_dir[winch] = 0
self.winches.set_velocity(winch, 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
tempo = 40 + value # beats per minute
self.frequency = tempo / 60.0 # beats per second
self.winches.set_freq_damping(self.all_axes, self.frequency, self.damping_ratio)
self.display.set_status("Tempo: %f, frequency: %f, damping ratio: %f" % (tempo, self.frequency, self.damping_ratio))
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))
else:
# for now, simple direct control of lights
self.lights.set_intensity(knob-3, value/127.0)
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
# create the GUI elements
self.scene_location = None
self._setupUi()
# finish initialization
self.show()
return
# ------------------------------------------------------------------------------------------------
def _setupUi(self):
# basic window setup
self.setWindowTitle("KF System Simulator 2")
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')
# set up complex cartoon showing a suspended winch system
self.scene = kf.QtPanelScene.QtPanelScene()
self.mainLayout.addWidget(self.scene)
self.scene_location = 'Main'
# generate a simulated MPD218 controller
self.MIDI_controller = kf.QtMPD218.QtMPD218()
self.mainLayout.addWidget(self.MIDI_controller)
# set up the scene tab
self.sceneTab = QtWidgets.QWidget(self)
self.sceneLayout = QtWidgets.QVBoxLayout(self.sceneTab)
self.tabs.addTab(self.sceneTab, 'Scene')
# 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)
# set up the logging tab
self.logDisplay = kf.QtLog.QtLog(level=logging.INFO)
self.tabs.addTab(self.logDisplay, 'Log')
# set up a tab with the winch cartoons
self.winchSet = kf.QtWinch.QtWinchSet(count=8)
self.tabs.addTab(self.winchSet, 'Winches')
self.cartoons = self.winchSet.winches()
# set up the help tab
self.help = QtWidgets.QLabel()
self.help.setText(help_tab_text)
self.tabs.addTab(self.help, 'Help')
return
# --- configure callbacks to connect GUI to application controller -----------------------------
[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())
# --- 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)
if index==0: # changing to main tab
if self.scene_location == 'Scene':
log.debug("Removing scene widget from scene layout.")
self.sceneLayout.removeWidget(self.scene)
self.mainLayout.insertWidget(0, self.scene)
self.scene_location = 'Main'
elif index==1: # changing to scene tab
if self.scene_location == 'Main':
log.debug("Removing scene widget from main layout.")
self.mainLayout.removeWidget(self.scene)
self.sceneLayout.insertWidget(0, self.scene)
self.scene_location = 'Scene'
return
[docs] def closeEvent(self, event):
"""Qt callback received before windows closes."""
log.info("Received window close event.")
super(SimWindow,self).closeEvent(event)
return
# --------------------------------------------------------------------------------------------------
################################################################
[docs]class MainApp(object):
"""Main application controller object holding any non-GUI related state."""
def __init__(self):
# create the interface window
self.window = SimWindow()
# initialize the winch set simulator
self.sim = kf.sim.SimWinch(count=8)
# Initialize the performance logic.
self.control_logic = ControlLogic()
self.control_logic.connect_winches(self.sim)
self.control_logic.connect_display(self.window)
self.control_logic.connect_lights(self.window.scene)
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)
# 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)
return
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.scene.update_positions(positions)
# ---- 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()