1"""
2MIDI input package
3provides a dock which allows to capture midi events and insert notes
4
5- input is static, not dynamic
6- special midi events (e. g. damper pedal) can modify notes (e. g. duration)
7  or insert elements (e. g. slurs)
8
9current limitations:
10- special events not implemented yet
11
12TODO:
13  dynamic input
14"""
15
16import re
17from PyQt5.QtGui import QTextCursor
18
19import time
20import weakref
21
22from PyQt5.QtCore import QObject, QSettings, QThread, pyqtSignal
23
24import midihub
25import midifile.event
26import midifile.parser
27import documentinfo
28
29from . import elements
30
31
32class MidiIn(object):
33    def __init__(self, widget):
34        self._widget = weakref.ref(widget)
35        self._portmidiinput = None
36        self._listener = None
37        self._chord = None
38
39    def __del__(self):
40        if isinstance(self._listener, Listener):
41            self.capturestop()
42
43    def widget(self):
44        return self._widget()
45
46    def open(self):
47        s = QSettings()
48        self._portname = s.value("midi/midi/input_port", midihub.default_input(), str)
49        self._pollingtime = s.value("midi/polling_time", 10, int)
50        self._portmidiinput = midihub.input_by_name(self._portname)
51
52        self._listener = Listener(self._portmidiinput, self._pollingtime)
53        self._listener.NoteEventSignal.connect(self.analyzeevent)
54
55    def close(self):
56        # self._portmidiinput.close()
57        # this will end in segfault with pyportmidi 0.0.7 in ubuntu
58        # see https://groups.google.com/d/msg/pygame-mirror-on-google-groups/UA16GbFsUDE/RkYxb9SzZFwJ
59        # so we cleanup ourself and invoke __dealloc__() by garbage collection
60        # so discard any reference to a pypm.Input instance
61        if self._portmidiinput:
62            self._portmidiinput._input = None
63        self._portmidiinput = None
64        self._listener = None
65
66    def capture(self):
67        if not self._portmidiinput:
68            self.open()
69            if not self._portmidiinput:
70                return
71        doc = self.widget().mainwindow().currentDocument()
72        self._language = documentinfo.docinfo(doc).language() or 'nederlands'
73        self._activenotes = 0
74        self._listener.start()
75
76    def capturestop(self):
77        self._listener.stop()
78        if not self._listener.isFinished():
79            self._listener.wait()
80        self._activenotes = 0
81        self.close()
82
83    def analyzeevent(self, event):
84        if isinstance(event, midifile.event.NoteEvent):
85            self.noteevent(event.type, event.channel, event.note, event.value)
86
87    def noteevent(self, notetype, channel, notenumber, value):
88        targetchannel = self.widget().channel()
89        if targetchannel == 0 or channel == targetchannel-1: # '0' captures all
90            # midi channels start at 1 for humans and 0 for programs
91            if notetype == 9 and value > 0:    # note on with velocity > 0
92                notemapping = elements.NoteMapping(self.widget().keysignature(), self.widget().accidentals()=='sharps')
93                note = elements.Note(notenumber, notemapping)
94                if self.widget().chordmode():
95                    if not self._chord:    # no Chord instance?
96                        self._chord = elements.Chord()
97                    self._chord.add(note)
98                    self._activenotes += 1
99                else:
100                    self.print_or_replace(note.output(self.widget().relativemode(), self._language))
101            elif (notetype == 8 or (notetype == 9 and value == 0)) and self.widget().chordmode():
102                self._activenotes -= 1
103                if self._activenotes <= 0:    # activenotes could get negative under strange conditions
104                    if self._chord:
105                        self.print_or_replace(self._chord.output(self.widget().relativemode(), self._language))
106                    self._activenotes = 0    # reset in case it was negative
107                    self._chord = None
108
109    def print_or_replace(self, text):
110
111        view = self.widget().mainwindow()
112        cursor = view.textCursor()
113
114        if self.widget().repitchmode():
115
116              music = cursor.document().toPlainText()[cursor.position() : ]
117
118              ly_reg_expr = r'(?<![a-zA-Z#_^\-\\])[a-ps-zA-PS-Z]{1,3}(?![a-zA-Z])[\'\,]*'\
119                           '|'\
120                           r'(?<![<\\])<[^<>]*>(?!>)'
121
122              notes = re.search(ly_reg_expr,music)
123              if notes != None :
124                    start = cursor.position() + notes.start()
125                    end = cursor.position() + notes.end()
126
127                    cursor.beginEditBlock()
128                    cursor.setPosition(start)
129                    cursor.setPosition(end, QTextCursor.KeepAnchor)
130                    cursor.insertText(text)
131                    cursor.endEditBlock()
132
133                    view.setTextCursor(cursor)
134
135        else:
136              # check if there is a space before cursor or beginning of line
137              posinblock = cursor.position() - cursor.block().position()
138              charbeforecursor = cursor.block().text()[posinblock-1:posinblock]
139
140              if charbeforecursor.isspace() or cursor.atBlockStart():
141                   cursor.insertText(text)
142              else:
143                   cursor.insertText(' ' +  text)
144
145
146
147
148class Listener(QThread):
149    NoteEventSignal = pyqtSignal(midifile.event.NoteEvent)
150    def __init__(self, portmidiinput, pollingtime):
151        QThread.__init__(self)
152        self._portmidiinput = portmidiinput
153        self._pollingtime = pollingtime
154
155    def run(self):
156        self._capturing = True
157        while self._capturing:
158            while not self._portmidiinput.poll():
159                time.sleep(self._pollingtime/1000.)
160                if not self._capturing:
161                    break
162            if not self._capturing:
163                break
164            data = self._portmidiinput.read(1)
165
166            # midifile.parser.parse_midi_events is a generator which expects a long "byte string" from a file,
167            # so we feed it one. But since it's just one event, we only need the first "generated" element.
168            # First byte is time, which is unnecessary in our case, so we feed a dummy byte 77
169            # and strip output by just using [1]. 77 is chosen randomly ;)
170            s = bytearray([77, data[0][0][0], data[0][0][1], data[0][0][2], data[0][0][3]])
171            event = next(midifile.parser.parse_midi_events(s))[1]
172
173            if isinstance(event,midifile.event.NoteEvent):
174                self.NoteEventSignal.emit(event)
175
176    def stop(self):
177        self._capturing = False
178