Wire up MidiHub to the onscreen piano of the instrument lib.

looper
Ben Niemann 7 years ago
parent 6813675a43
commit f9dd982f18

@ -0,0 +1,3 @@
from .midi_hub import MidiHub
from .midi_events import MidiEvent, NoteOnEvent, NoteOffEvent, ControlChangeEvent
from .libalsa import AlsaSequencer, PortInfo, ClientInfo

@ -1,15 +1,20 @@
class MidiEvent(object):
def __init__(self, timestamp, device_id):
NOTE_ON = 'note-on'
NOTE_OFF = 'note-off'
CONTROLLER_CHANGE = 'controller-change'
def __init__(self, type, timestamp, device_id):
self.type = type
self.timestamp = timestamp
self.device_id = device_id
def __eq__(self, other):
return (self.timestamp, self.device_id) == (other.timestamp, other.device_id)
return (self.type, self.timestamp, self.device_id) == (other.type, other.timestamp, other.device_id)
class NoteOnEvent(MidiEvent):
def __init__(self, timestamp, device_id, channel, note, velocity):
super().__init__(timestamp, device_id)
super().__init__(MidiEvent.NOTE_ON, timestamp, device_id)
self.channel = channel
self.note = note
self.velocity = velocity
@ -26,7 +31,7 @@ class NoteOnEvent(MidiEvent):
class NoteOffEvent(MidiEvent):
def __init__(self, timestamp, device_id, channel, note, velocity):
super().__init__(timestamp, device_id)
super().__init__(MidiEvent.NOTE_OFF, timestamp, device_id)
self.channel = channel
self.note = note
self.velocity = velocity
@ -43,7 +48,7 @@ class NoteOffEvent(MidiEvent):
class ControlChangeEvent(MidiEvent):
def __init__(self, timestamp, device_id, channel, controller, value):
super().__init__(timestamp, device_id)
super().__init__(MidiEvent.CONTROLLER_CHANGE, timestamp, device_id)
self.channel = channel
self.controller = controller
self.value = value
@ -60,7 +65,7 @@ class ControlChangeEvent(MidiEvent):
class DeviceChangeEvent(MidiEvent):
def __init__(self, timestamp, device_id, evt, client_id, port_id):
super().__init__(timestamp, device_id)
super().__init__(evt, timestamp, device_id)
self.evt = evt
self.client_id = client_id
self.port_id = port_id

@ -20,8 +20,8 @@ class Error(Exception):
class MidiHub(object):
def __init__(self):
self._seq = None
def __init__(self, seq):
self._seq = seq
self._thread = None
self._quit_event = None
self._started = False
@ -71,12 +71,6 @@ class MidiHub(object):
def start(self):
logger.info("Starting MidiHub...")
# Do other clients handle non-ASCII names?
# 'aconnect' seems to work (or just spits out whatever bytes it gets
# and the console interprets it as UTF-8), 'aconnectgui' shows the
# encoded bytes.
self._seq = libalsa.AlsaSequencer('noisicaä')
self._connected = {}
#port_info = next(
@ -101,9 +95,6 @@ class MidiHub(object):
self._thread = None
self._quit_event = None
if self._seq is not None:
self._seq.close()
self._seq = None
logger.info("MidiHub stopped.")
def list_devices(self):

@ -21,7 +21,7 @@ from . import midi_hub
class MockSequencer(object):
def __init__(self, name):
def __init__(self):
self._ci = libalsa.ClientInfo(10, 'test client')
self._pi = libalsa.PortInfo(
self._ci, 14,
@ -70,30 +70,25 @@ class MockSequencer(object):
class MidiHubTest(unittest.TestCase):
def setUp(self):
self._patcher = mock.patch(
'noisicaa.devices.libalsa.AlsaSequencer', MockSequencer)
self._mock_sequencer = self._patcher.start()
def tearDown(self):
self._patcher.stop()
self.seq = MockSequencer()
def test_start_stop(self):
hub = midi_hub.MidiHub()
hub = midi_hub.MidiHub(self.seq)
hub.start()
hub._seq.wait_until_done()
hub.stop()
def test_stop_before_start(self):
hub = midi_hub.MidiHub()
hub = midi_hub.MidiHub(self.seq)
hub.stop()
def test_list_devices(self):
with midi_hub.MidiHub() as hub:
with midi_hub.MidiHub(self.seq) as hub:
hub.list_devices()
def test_listener(self):
callback = mock.Mock()
with midi_hub.MidiHub() as hub:
with midi_hub.MidiHub(self.seq) as hub:
listener = hub.listeners.add('10/14', callback)
hub._seq.wait_until_done()
listener.remove()
@ -102,14 +97,14 @@ class MidiHubTest(unittest.TestCase):
midi_events.NoteOnEvent(1000, '10/14', 0, 65, 120))
def test_listen_before_start(self):
hub = midi_hub.MidiHub()
hub = midi_hub.MidiHub(self.seq)
with self.assertRaises(AssertionError):
hub.listeners.add('10/14', mock.Mock())
def test_listener_same_device(self):
callback1 = mock.Mock()
callback2 = mock.Mock()
with midi_hub.MidiHub() as hub:
with midi_hub.MidiHub(self.seq) as hub:
listener1 = hub.listeners.add('10/14', callback1)
listener2 = hub.listeners.add('10/14', callback2)
hub._seq.wait_until_done()
@ -122,7 +117,7 @@ class MidiHubTest(unittest.TestCase):
midi_events.NoteOnEvent(1000, '10/14', 0, 65, 120))
def test_listener_unknown_device(self):
with midi_hub.MidiHub() as hub:
with midi_hub.MidiHub(self.seq) as hub:
with self.assertRaises(midi_hub.Error):
hub.listeners.add('111/222', mock.Mock())

@ -4,7 +4,7 @@ from .exceptions import (
from .key_signature import KeySignature
from .time_signature import TimeSignature
from .clef import Clef
from .pitch import Pitch
from .pitch import Pitch, NOTE_TO_MIDI
from .time import Duration
from .project import (
Project,

@ -16,6 +16,7 @@ from PyQt5.QtWidgets import (
)
from noisicaa import music
from noisicaa import devices
from ..exceptions import RestartAppException, RestartAppCleanException
from ..constants import EXIT_EXCEPTION, EXIT_RESTART, EXIT_RESTART_CLEAN
from .editor_window import EditorWindow
@ -26,7 +27,6 @@ from ..audioproc.sink.pyaudio import PyAudioSink
from ..audioproc.sink.null import NullSink
from ..instr.library import InstrumentLibrary
logger = logging.getLogger('ui.editor_app')
@ -81,6 +81,8 @@ class BaseEditorApp(QApplication):
self.global_mixer = None
self.sink = None
self.playback_sources = None
self.sequencer = None
self.midi_hub = None
def setup(self):
self.default_style = self.style().objectName()
@ -102,6 +104,11 @@ class BaseEditorApp(QApplication):
self.playback_sources = {}
self.pipeline.start()
self.sequencer = self.createSequencer()
self.midi_hub = self.createMidiHub()
self.midi_hub.start()
self.new_project_action = QAction(
"New", self,
shortcut=QKeySequence.New,
@ -117,7 +124,17 @@ class BaseEditorApp(QApplication):
def cleanup(self):
logger.info("Cleaning up.")
self.pipeline.stop()
if self.midi_hub is not None:
self.midi_hub.stop()
self.midi_hub = None
if self.sequencer is not None:
self.sequencer.close()
self.sequencer = None
if self.pipeline is not None:
self.pipeline.stop()
self.pipeline = None
def createAudioSink(self):
sink = NullSink(sleep=0.1)
@ -125,6 +142,12 @@ class BaseEditorApp(QApplication):
sink.setup()
return sink
def createSequencer(self):
return None
def createMidiHub(self):
return devices.MidiHub(self.sequencer)
def exit(self, exit_code):
logger.info("exit(%d) received", exit_code)
self._exit_code = exit_code
@ -232,6 +255,13 @@ class EditorApp(BaseEditorApp):
self.aboutToQuit.connect(self.shutDown)
def cleanup(self):
super().cleanup()
if self._sequencer is not None:
self._sequencer.close()
self._sequencer = None
def shutDown(self):
logger.info("Shutting down.")
@ -253,3 +283,10 @@ class EditorApp(BaseEditorApp):
self.pipeline.set_sink(sink)
sink.setup()
return sink
def createSequencer(self):
# Do other clients handle non-ASCII names?
# 'aconnect' seems to work (or just spits out whatever bytes it gets
# and the console interprets it as UTF-8), 'aconnectgui' shows the
# encoded bytes.
return devices.AlsaSequencer('noisicaä')

@ -134,7 +134,7 @@ class InstrumentLibraryDialog(QDialog):
layout.addStretch(1)
self.piano = PianoWidget(self)
self.piano = PianoWidget(self, self._app)
self.piano.setVisible(False)
self.piano.noteOn.connect(self.onNoteOn)
self.piano.noteOff.connect(self.onNoteOff)

@ -6,10 +6,8 @@
import math
from .qled import QLed
from PyQt5.QtCore import Qt, QRect, QSize, pyqtSignal
from PyQt5.QtGui import QPalette, QPen, QBrush, QColor, QCursor, QIcon
from PyQt5.QtCore import Qt, QSize, pyqtSignal
from PyQt5.QtGui import QPalette, QPen, QBrush, QColor
from PyQt5.QtWidgets import (
QWidget,
QGraphicsView,
@ -19,22 +17,24 @@ from PyQt5.QtWidgets import (
QVBoxLayout,
QHBoxLayout,
QSlider,
QToolButton,
QComboBox
)
from noisicaa import devices
from noisicaa import music
from .qled import QLed
class PianoKey(QGraphicsRectItem):
WHITE = 0
BLACK = 1
def __init__(self, piano, x, name, type):
def __init__(self, piano, x, name, key_type):
super().__init__()
self._piano = piano
self._name = name
self._type = type
self._type = key_type
if self._type == self.WHITE:
self.setRect(x - 10, 0, 20, 100)
@ -59,10 +59,10 @@ class PianoKey(QGraphicsRectItem):
self.setBrush(QBrush(Qt.black))
self._piano.noteOff.emit(music.Pitch(self._name))
def mousePressEvent(self, event):
def mousePressEvent(self, event): # pylint: disable=unused-argument
self.press()
def mouseReleaseEvent(self, event):
def mouseReleaseEvent(self, event): # pylint: disable=unused-argument
self.release()
@ -80,24 +80,30 @@ class PianoKeys(QGraphicsView):
self.setScene(self._scene)
self._keys = {}
self._midi_to_key = {}
for octave in range(2, 7):
for idx, note in enumerate(['C', 'D', 'E', 'F', 'G', 'A', 'B']):
pitch_name = '%s%d' % (note, octave)
key = PianoKey(
parent,
140 * octave + 20 * idx,
'%s%d' % (note, octave),
pitch_name,
PianoKey.WHITE)
self._keys['%s%d' % (note, octave)] = key
self._keys[pitch_name] = key
self._midi_to_key[music.NOTE_TO_MIDI[pitch_name]] = key
self._scene.addItem(key)
for idx, note in enumerate(['C#', 'D#', '', 'F#', 'G#', 'A#', '']):
if not note: continue
if not note:
continue
pitch_name = '%s%d' % (note, octave)
key = PianoKey(
parent,
140 * octave + 20 * idx + 10,
'%s%d' % (note, octave),
pitch_name,
PianoKey.BLACK)
self._keys['%s%d' % (note, octave)] = key
self._keys[pitch_name] = key
self._midi_to_key[music.NOTE_TO_MIDI[pitch_name]] = key
self._scene.addItem(key)
@ -181,14 +187,28 @@ class PianoKeys(QGraphicsView):
else:
key.release()
def midiEvent(self, event):
if event.type in (devices.MidiEvent.NOTE_ON, devices.MidiEvent.NOTE_OFF):
try:
key = self._midi_to_key[event.note]
except KeyError:
pass
else:
if event.type == devices.MidiEvent.NOTE_ON:
key.press()
else:
key.release()
class PianoWidget(QWidget):
noteOn = pyqtSignal(music.Pitch, int)
noteOff = pyqtSignal(music.Pitch)
def __init__(self, parent):
def __init__(self, parent, app):
super().__init__(parent)
self._app = app
self.setFocusPolicy(Qt.StrongFocus)
layout = QVBoxLayout()
@ -204,6 +224,20 @@ class PianoWidget(QWidget):
toolbar.addSpacing(10)
self._keyboard_listener = None
self.keyboard_selector = QComboBox()
self.keyboard_selector.addItem("None", userData=None)
for device_id, port_info in self._app.midi_hub.list_devices():
self.keyboard_selector.addItem(
"%s (%s)" % (port_info.name, port_info.client_info.name),
userData=device_id)
self.keyboard_selector.currentIndexChanged.connect(
self.onKeyboardDeviceChanged)
toolbar.addWidget(self.keyboard_selector)
toolbar.addSpacing(10)
# speaker icon should go here...
#tb = QToolButton(self)
#tb.setIcon(QIcon.fromTheme('multimedia-volume-control'))
@ -222,6 +256,26 @@ class PianoWidget(QWidget):
self.piano_keys = PianoKeys(self)
layout.addWidget(self.piano_keys)
def close(self):
if not super().close(): # pragma: no coverage
return False
if self._keyboard_listener is not None:
self._keyboard_listener.remove()
self._keyboard_listener = None
return True
def onKeyboardDeviceChanged(self, index):
if self._keyboard_listener is not None:
self._keyboard_listener.remove()
self._keyboard_listener = None
device_id = self.keyboard_selector.itemData(index)
if device_id is not None:
self._keyboard_listener = self._app.midi_hub.listeners.add(
device_id, self.midiEvent)
def focusInEvent(self, event):
event.accept()
self.focus_indicator.setValue(True)
@ -236,3 +290,5 @@ class PianoWidget(QWidget):
def keyReleaseEvent(self, event):
self.piano_keys.keyReleaseEvent(event)
def midiEvent(self, event):
self.piano_keys.midiEvent(event)

@ -0,0 +1,157 @@
#/usr/bin/python3
import unittest
from unittest import mock
from PyQt5.QtCore import Qt, QEvent
from PyQt5.QtGui import QFocusEvent, QKeyEvent
if __name__ == '__main__':
import pyximport
pyximport.install()
from noisicaa import devices
from noisicaa import music
from . import uitest_utils
from . import piano
class PianoTest(uitest_utils.UITest):
def setUp(self):
super().setUp()
self.app.sequencer.add_port(
devices.PortInfo(
devices.ClientInfo(1, "test"),
1, "test", {'read'}, {'midi_generic'}))
def test_init(self):
p = piano.PianoWidget(None, self.app)
self.assertTrue(p.close())
def test_select_keyboard(self):
p = piano.PianoWidget(None, self.app)
p.keyboard_selector.setCurrentIndex(1)
p.keyboard_selector.setCurrentIndex(0)
p.keyboard_selector.setCurrentIndex(1)
self.assertTrue(p.close())
def test_focus_events(self):
p = piano.PianoWidget(None, self.app)
evt = QFocusEvent(QEvent.FocusIn)
p.event(evt)
self.assertTrue(evt.isAccepted())
self.assertTrue(p.focus_indicator.value)
evt = QFocusEvent(QEvent.FocusOut)
p.event(evt)
self.assertTrue(evt.isAccepted())
self.assertFalse(p.focus_indicator.value)
self.assertTrue(p.close())
def test_midi_events(self):
p = piano.PianoWidget(None, self.app)
# White key.
evt = devices.NoteOnEvent(0, '1/1', 0, 65, 120)
p.midiEvent(evt)
evt = devices.NoteOffEvent(0, '1/1', 0, 65, 0)
p.midiEvent(evt)
# Black key.
evt = devices.NoteOnEvent(0, '1/1', 0, 66, 120)
p.midiEvent(evt)
evt = devices.NoteOffEvent(0, '1/1', 0, 66, 0)
p.midiEvent(evt)
self.assertTrue(p.close())
def test_midi_event_out_of_range(self):
p = piano.PianoWidget(None, self.app)
evt = devices.NoteOnEvent(0, '1/1', 0, 1, 120)
p.midiEvent(evt)
self.assertTrue(p.close())
def test_midi_event_not_note(self):
p = piano.PianoWidget(None, self.app)
evt = devices.ControlChangeEvent(0, '1/1', 0, 1, 65)
p.midiEvent(evt)
self.assertTrue(p.close())
def test_key_events(self):
p = piano.PianoWidget(None, self.app)
on_listener = mock.Mock()
p.noteOn.connect(on_listener)
off_listener = mock.Mock()
p.noteOff.connect(off_listener)
evt = QKeyEvent(
QEvent.KeyPress, Qt.Key_R, Qt.NoModifier, 0x1b, 0, 0, "r")
p.event(evt)
self.assertEqual(
on_listener.call_args_list,
[mock.call(music.Pitch('C5'), 127)])
evt = QKeyEvent(
QEvent.KeyRelease, Qt.Key_R, Qt.NoModifier, 0x1b, 0, 0, "r")
p.event(evt)
self.assertEqual(
off_listener.call_args_list,
[mock.call(music.Pitch('C5'))])
self.assertTrue(p.close())
def test_key_events_unused_key(self):
p = piano.PianoWidget(None, self.app)
on_listener = mock.Mock()
p.noteOn.connect(on_listener)
off_listener = mock.Mock()
p.noteOff.connect(off_listener)
evt = QKeyEvent(
QEvent.KeyPress, Qt.Key_R, Qt.NoModifier, 0x1b, 0, 0, "r",
autorep=True)
p.event(evt)
on_listener.not_called()
evt = QKeyEvent(
QEvent.KeyRelease, Qt.Key_R, Qt.NoModifier, 0x1b, 0, 0, "r",
autorep=True)
p.event(evt)
off_listener.not_called()
self.assertTrue(p.close())
def test_key_events_ignore_auto_repeat(self):
p = piano.PianoWidget(None, self.app)
on_listener = mock.Mock()
p.noteOn.connect(on_listener)
off_listener = mock.Mock()
p.noteOff.connect(off_listener)
evt = QKeyEvent(
QEvent.KeyPress, Qt.Key_Apostrophe, Qt.NoModifier, 0x14, 0, 0, "'")
p.event(evt)
on_listener.not_called()
evt = QKeyEvent(
QEvent.KeyRelease, Qt.Key_Apostrophe, Qt.NoModifier, 0x14, 0, 0, "'")
p.event(evt)
off_listener.not_called()
self.assertTrue(p.close())
if __name__ == '__main__':
unittest.main()

@ -28,10 +28,39 @@ class MockSettings(object):
return list(self._data.keys())
class MockSequencer(object):
def __init__(self):
self._ports = []
def add_port(self, port_info):
self._ports.append(port_info)
def list_all_ports(self):
yield from self._ports
def get_pollin_fds(self):
return []
def connect(self, port_info):
pass
def disconnect(self, port_info):
pass
def close(self):
pass
def get_event(self):
return None
class MockApp(BaseEditorApp):
def __init__(self):
super().__init__(RuntimeSettings(), MockSettings())
def createSequencer(self):
return MockSequencer()
class UITest(unittest.TestCase):
# There are random crashes if we create and destroy the QApplication for

Loading…
Cancel
Save