MIDI Synth Sequencer Suite
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

236 lines
9.7 KiB

  1. import mido
  2. import time
  3. import curses
  4. import midiDevice
  5. import math
  6. # If true, no console output will be shown (for performance)
  7. headless = False
  8. def cursesRingPrint(stdscr, stringToPrint):
  9. if not headless:
  10. stdscr.addstr(stringToPrint)
  11. stdscr.clrtoeol()
  12. cursorPos = curses.getsyx()
  13. newPos = [cursorPos[0] + 1, cursorPos[1]]
  14. screenHeightWidth = stdscr.getmaxyx()
  15. if newPos[0] > screenHeightWidth[0] - 1:
  16. newPos[0] = 1
  17. stdscr.move(newPos[0], newPos[1])
  18. stdscr.refresh()
  19. def manualNoteResetCH345(output):
  20. ch345KeyboardRange = [53, 84]
  21. for note in range(ch345KeyboardRange[0], ch345KeyboardRange[1] + 1):
  22. noteOffMessage = mido.Message(
  23. 'note_on', note=note, velocity=0, time=0.0)
  24. output.send(noteOffMessage)
  25. def simpleSequencer(stdscr):
  26. debugTiming = False
  27. if not headless:
  28. # Make getch() nonblocking
  29. stdscr.nodelay(1)
  30. stdscr.addstr("KeyKey --- Current mode: Simple Sequencer",
  31. curses.A_REVERSE)
  32. stdscr.move(1, 0)
  33. stdscr.refresh()
  34. with midiDevice.openOut('OP-1') as synthOut, midiDevice.openIn('CH345') as keyboardIn:
  35. if not synthOut or not keyboardIn:
  36. return
  37. # 16th notes at 240 bpm should be fine
  38. frameRate = (60 / 240) / 4
  39. # Prevent the program from locking up if the frame rate gets too bad
  40. maximumCatchupTime = 0.25
  41. timeRoomForError = 0.0001
  42. # Start with a click
  43. sequence = [(mido.Message(
  44. 'note_on', note=60, velocity=64, time=0.0), 0.0), (mido.Message(
  45. 'note_off', note=60, velocity=127, time=0.1), 0.1)]
  46. sequenceLastStartTime = 0.0
  47. sequenceTimeLength = 1.0
  48. sequenceLastNotePlayedTime = 0.0
  49. sequenceFirstStartTime = 0.0
  50. sequenceDriftStartTime = 0.0
  51. sequenceNumTimesPlayed = 0
  52. sequenceNumTimesMeasureDrift = 4
  53. isRecording = False
  54. isPlayback = True
  55. lastTime = time.time()
  56. timeAccumulated = 0.0
  57. shouldQuit = False
  58. try:
  59. while True:
  60. currentTime = time.time()
  61. sequenceTime = currentTime - sequenceLastStartTime
  62. frameDelta = currentTime - lastTime
  63. if frameDelta > maximumCatchupTime:
  64. frameDelta = maximumCatchupTime
  65. lastTime = currentTime
  66. timeAccumulated += frameDelta
  67. if debugTiming:
  68. cursesRingPrint(stdscr, str(frameDelta))
  69. while timeAccumulated >= frameRate:
  70. if debugTiming:
  71. cursesRingPrint(stdscr, 'Updated')
  72. # Poll MIDI input
  73. message = keyboardIn.poll()
  74. while message:
  75. cursesRingPrint(stdscr, str(message))
  76. if isRecording:
  77. sequence.append((message, sequenceTime))
  78. synthOut.send(message)
  79. message = keyboardIn.poll()
  80. # Poll keyboard input
  81. inputChar = stdscr.getch()
  82. # [Q]uit
  83. if inputChar == ord('q'):
  84. shouldQuit = True
  85. break
  86. # Toggle [P]layback
  87. elif inputChar == ord('p'):
  88. isPlayback = not isPlayback
  89. cursesRingPrint(
  90. stdscr, 'Playback' if isPlayback else 'Stopped Playback')
  91. if not isPlayback:
  92. isRecording = False
  93. # [C]lear sequence
  94. elif inputChar == ord('c'):
  95. sequence = []
  96. cursesRingPrint(stdscr, "Cleared sequence")
  97. # [R]ecord
  98. elif inputChar == ord('r'):
  99. isRecording = not isRecording
  100. if not isPlayback and isRecording:
  101. # TODO: keep the sequence looping and not play it?
  102. # Or restart seq on start play?
  103. cursesRingPrint(stdscr,
  104. 'Cannot record without playing back')
  105. isRecording = False
  106. else:
  107. cursesRingPrint(
  108. stdscr, 'Recording' if isRecording else 'Stopped recording')
  109. # Reset
  110. elif inputChar == ord('x'):
  111. synthOut.reset()
  112. synthOut.panic()
  113. manualNoteResetCH345(synthOut)
  114. cursesRingPrint(stdscr, "Reset output")
  115. if isPlayback:
  116. # Restart sequence if necessary
  117. if sequenceTime >= sequenceTimeLength - timeRoomForError:
  118. # TODO: Minimize drift over time
  119. if sequenceNumTimesPlayed and sequenceNumTimesPlayed % sequenceNumTimesMeasureDrift == 0:
  120. sequenceThisFrameDrift = (currentTime - sequenceDriftStartTime) - (
  121. sequenceTimeLength * sequenceNumTimesMeasureDrift)
  122. cursesRingPrint(stdscr, 'Sequence played ' + str(sequenceNumTimesPlayed)
  123. + ' times; drifted ' +
  124. str((currentTime - sequenceFirstStartTime)
  125. - (sequenceTimeLength * sequenceNumTimesPlayed)) + ', drifted '
  126. + (str(sequenceThisFrameDrift) if math.fabs(
  127. sequenceThisFrameDrift) > frameRate else ' -negligible- ')
  128. + ' this ' + str(sequenceNumTimesMeasureDrift) + ' drift frame')
  129. cursesRingPrint(stdscr, ' (Started at ' + str(sequenceFirstStartTime) + ', last sequence start time ' + str(sequenceLastStartTime) + ', expected last start time ' + str(
  130. sequenceFirstStartTime + (sequenceNumTimesPlayed * sequenceTimeLength)) + ', diff ' + str((sequenceFirstStartTime + (sequenceNumTimesPlayed * sequenceTimeLength)) - sequenceLastStartTime) + ')')
  131. sequenceDriftStartTime = currentTime
  132. sequenceLastStartTime = currentTime
  133. sequenceLastNotePlayedTime = 0.0
  134. sequenceNumTimesPlayed += 1
  135. # Play sequencer notes if it's time
  136. # TODO: sort notes by time? Also, out messages work
  137. # strangely
  138. for note in sequence:
  139. # TODO: this comparison should have a margin of error equal
  140. # to the frame rate
  141. if note[1] <= sequenceTime and note[1] >= sequenceLastNotePlayedTime:
  142. synthOut.send(note[0])
  143. sequenceLastNotePlayedTime = max(
  144. sequenceLastNotePlayedTime, note[1])
  145. if not sequenceFirstStartTime:
  146. sequenceFirstStartTime = currentTime
  147. timeAccumulated -= frameRate
  148. timeAccumulated = max(0.0, timeAccumulated)
  149. if shouldQuit:
  150. break
  151. sleepTime = frameRate - timeAccumulated - timeRoomForError
  152. if sleepTime > 0:
  153. if sequenceNumTimesPlayed:
  154. # Make sure we wake up and start the sequence at the right
  155. # time
  156. sequenceNextStartTime = sequenceLastStartTime + sequenceTimeLength
  157. if sleepTime + currentTime > sequenceNextStartTime:
  158. cursesRingPrint(stdscr, 'Instead of sleeping for ' + str(sleepTime) + ', sleep for ' +
  159. str(sequenceNextStartTime - currentTime - timeRoomForError) + ' (sequence starts soon)')
  160. sleepTime = max(
  161. 0.0, sequenceNextStartTime - currentTime - timeRoomForError)
  162. if debugTiming:
  163. cursesRingPrint(stdscr,
  164. 'Sleep ' + str(sleepTime))
  165. time.sleep(sleepTime)
  166. finally:
  167. cursesRingPrint(stdscr,
  168. 'Resetting synth due to exception')
  169. synthOut.reset()
  170. """ Sometimes notes hang because a note_off has not been sent. To (abruptly) stop all sounding
  171. notes, you can call:
  172. outport.panic()
  173. This will not reset controllers. Unlike reset(), the notes will not be turned off
  174. gracefully, but will stop immediately with no regard to decay time.
  175. http://mido.readthedocs.io/en/latest/ports.html?highlight=reset """
  176. synthOut.panic()
  177. manualNoteResetCH345(synthOut)
  178. # Note that key repeats mean that key holding is fucking weird
  179. def testKeyInput(stdscr):
  180. # Make getch() nonblocking
  181. stdscr.nodelay(1)
  182. while True:
  183. inputChar = stdscr.getch()
  184. if inputChar == ord('f'):
  185. stdscr.addstr("This is a test")
  186. elif inputChar == ord('q'):
  187. break
  188. time.sleep(0.05)
  189. def main():
  190. if headless:
  191. simpleSequencer(None)
  192. else:
  193. curses.wrapper(simpleSequencer)
  194. if __name__ == '__main__':
  195. main()