The base-application for Laborejo, Fluajho, Patroneo, Vico etc.
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.

502 lines
19KB

  1. #! /usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Copyright 2021, Nils Hilbricht, Germany ( https://www.hilbricht.net )
  5. This file is part of the Laborejo Software Suite ( https://www.laborejo.org )
  6. This application is free software: you can redistribute it and/or modify
  7. it under the terms of the GNU General Public License as published by
  8. the Free Software Foundation, either version 3 of the License, or
  9. (at your option) any later version.
  10. This program is distributed in the hope that it will be useful,
  11. but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. GNU General Public License for more details.
  14. You should have received a copy of the GNU General Public License
  15. along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. """
  17. import logging; logger = logging.getLogger(__name__); logger.info("import")
  18. #Python Standard Library
  19. import os.path
  20. from datetime import timedelta
  21. #Third Party Modules
  22. from calfbox import cbox
  23. #Our own template modules
  24. from .session import Session
  25. from .duration import DB, DL, D1, D2, D4, D8, D16, D32, D64, D128, D256, D512, D1024, jackBBTicksToDuration
  26. from ..helper import nothing
  27. class Callbacks(object):
  28. """GUI methods register themselves here.
  29. These methods get called by us, the engine.
  30. None of these methods produce any return value.
  31. The lists may be unordered.
  32. We need the lists for audio feedbacks in parallel to GUI updates.
  33. Or whatever parallel representations we run."""
  34. def __init__(self):
  35. self.debugChanged = [] #only for testing and debug.
  36. self.setPlaybackTicks = []
  37. self.playbackStatusChanged = []
  38. self.bbtStatusChanged = []
  39. self.barBeatTempo = []
  40. self.clock = []
  41. self.historyChanged = []
  42. self.historySequenceStarted = []
  43. self.historySequenceStopped = []
  44. self.message = []
  45. #Live Midi Recording
  46. self.recordingModeChanged = []
  47. #Sequencer
  48. self.numberOfTracksChanged = []
  49. self.metronomeChanged = []
  50. #Sf2 Sampler
  51. self.soundfontChanged = []
  52. self.channelChanged = []
  53. self.channelActivity = []
  54. self.ignoreProgramChangesChanged = []
  55. #Not callbacks
  56. self._rememberPlaybackStatus = None #Once set it will be True or False
  57. self._rememberBBT = None #Will be a dict
  58. self._rememberBarBeatTempo = None #Specialized case of BBT
  59. def _dataChanged(self):
  60. """Only called from within the callbacks or template api.
  61. This is about data the user cares about. In other words this is the indicator if you need
  62. to save again.
  63. Insert, delete edit are real data changes. Cursor movement or playback ticks are not."""
  64. session.nsmClient.announceSaveStatus(False)
  65. self._historyChanged()
  66. def _historyChanged(self):
  67. """processQueue
  68. Only called from within the callbacks.
  69. sends two lists of strings.
  70. the first is the undoHistory, the last added item is [-1]. We can show that to a user to
  71. indicate what the next undo will do.
  72. the second is redoHistory, same as undo: [-1] shows the next redo action."""
  73. undoHistory, redoHistory = session.history.asList()
  74. for func in self.historyChanged:
  75. func(undoHistory, redoHistory)
  76. def _historySequenceStarted(self):
  77. """sends a signal when a sequence of high level api functions will be executed next.
  78. Also valid for their undo sequences.
  79. A GUI has the chance to disable immediate drawing, e.g. qt Graphics Scene could stop
  80. scene updates and allow all callbacks to come in first.
  81. historySequenceStopped will be sent when the sequence is over and a GUI could reactivate
  82. drawing to have the buffered changes take effect.
  83. This signal is automatically sent by the history sequence context"""
  84. for func in self.historySequenceStarted:
  85. func()
  86. def _historySequenceStopped(self):
  87. """see _historySequenceStarted"""
  88. for func in self.historySequenceStopped:
  89. func()
  90. def _message(self, title, text):
  91. """Send a message of any kind to get displayed.
  92. Enables an api function to display a message via the GUI.
  93. Does _not_ support translations, therefore ued for errors mostly"""
  94. for func in self.message:
  95. func(title, text)
  96. def _debugChanged(self):
  97. for func in self.debugChanged:
  98. func()
  99. self._dataChanged() #includes _historyChanged
  100. def _setPlaybackTicks(self):
  101. """This gets called very very often (~60 times per second).
  102. Any connected function needs to watch closely
  103. for performance issues"""
  104. ppqn = cbox.Transport.status().pos_ppqn
  105. status = playbackStatus()
  106. for func in self.setPlaybackTicks:
  107. func(ppqn, status)
  108. def _playbackStatusChanged(self):
  109. """Returns a bool if the playback is running.
  110. Under rare circumstances it may send the same status in a row, which means
  111. you actually need to check the result and not only toggle as a response.
  112. This callback cannot be called manually. Instead it will be called automatically to make
  113. it possible to react to external jack transport changes.
  114. This is deprecated. Append to _checkPlaybackStatusAndSendSignal which is checked by the
  115. event loop.
  116. """
  117. raise NotImplementedError("this function was deprecated. use _checkPlaybackStatusAndSendSignal")
  118. pass #only keep for the docstring and to keep the pattern.
  119. def _checkPlaybackStatusAndSendSignal(self):
  120. """Added to the event loop.
  121. We don'T have a jack callback to inform us of this so we drive our own polling system
  122. which in turn triggers our own callback, when needed."""
  123. status = playbackStatus()
  124. if not self._rememberPlaybackStatus == status:
  125. self._rememberPlaybackStatus = status
  126. for func in self.playbackStatusChanged:
  127. func(status)
  128. def _checkBBTAndSendSignal(self):
  129. """Added to the event loop.
  130. We don'T have a jack callback to inform us of this so we drive our own polling system
  131. which in turn triggers our own callback, when needed.
  132. We are interested in:
  133. bar
  134. beat #first index is 1
  135. tick
  136. bar_start_tick
  137. beats_per_bar [4.0]
  138. beat_type [4.0]
  139. ticks_per_beat [960.0] #JACK ticks, not cbox.
  140. beats_per_minute [120.0]
  141. int bar is the current bar.
  142. int beat current beat-within-bar
  143. int tick current tick-within-beat
  144. double bar_start_tick number of ticks that have elapsed between frame 0 and the first beat of the current measure.
  145. """
  146. data = cbox.JackIO.jack_transport_position() #this includes a lot of everchanging data. If no jack-master client set /bar and the others they will simply not be in the list
  147. t = (data.beats_per_bar, data.ticks_per_beat)
  148. if not self._rememberBBT == t: #new situation, but not just frame position update
  149. self._rememberBBT = t
  150. export = {}
  151. if data.beats_per_bar:
  152. offset = (data.beat-1) * data.ticks_per_beat + data.tick #if timing is good this is the same as data.tick because beat is 1.
  153. offset = jackBBTicksToDuration(data.beat_type, offset, data.ticks_per_beat)
  154. export["nominator"] = data.beats_per_bar
  155. export["denominator"] = jackBBTicksToDuration(data.beat_type, data.ticks_per_beat, data.ticks_per_beat) #the middle one is the changing one we are interested in
  156. export["measureInTicks"] = export["nominator"] * export["denominator"]
  157. export["offsetToMeasureBeginning"] = offset
  158. #export["tickposition"] = cbox.Transport.status().pos_ppqn #this is a different position than our current one because it takes a few cycles and ticks to calculate
  159. export["tickposition"] = cbox.Transport.samples_to_ppqn(data.frame)
  160. for func in self.bbtStatusChanged:
  161. func(export)
  162. #Send bar beats tempo, for displays
  163. #TODO: broken
  164. """
  165. bbtExport = {}
  166. if data.beat and not self._rememberBarBeatTempo == data.beat:
  167. bbtExport["timesig"] = f"{int(data.beats_per_bar)}/{int(data.beat_type)}" #for displays
  168. bbtExport["beat"] = data.beat #index from 1
  169. bbtExport["tempo"] = int(data.beats_per_minute)
  170. bbtExport["bar"] = int(data.bar)
  171. self._rememberBarBeatTempo = data.beat #this should be enough inertia to not fire every 100ms
  172. for func in self.barBeatTempo:
  173. func(bbtExport)
  174. elif not data.beat:
  175. for func in self.barBeatTempo:
  176. func(bbtExport)
  177. self._rememberBarBeatTempo = data.beat
  178. """
  179. clock = str(timedelta(seconds=data.frame / data.frame_rate))
  180. for func in self.clock:
  181. func(clock)
  182. #Live Midi Recording
  183. def _recordingModeChanged(self):
  184. if session.recordingEnabled:
  185. session.nsmClient.changeLabel("Recording")
  186. else:
  187. session.nsmClient.changeLabel("")
  188. for func in self.recordingModeChanged:
  189. func(session.recordingEnabled)
  190. #Sequencer
  191. def _numberOfTracksChanged(self):
  192. """New track, delete track, reorder
  193. Sent the current track order as list of ids, combined with their structure.
  194. This is also used when tracks get created or deleted, also on initial load.
  195. """
  196. session.data.updateJackMetadataSorting()
  197. lst = [track.export() for track in session.data.tracks]
  198. for func in self.numberOfTracksChanged:
  199. func(lst)
  200. self._dataChanged() #includes _historyChanged
  201. def _metronomeChanged(self):
  202. """returns a dictionary with meta data such as the mute-state and the track name"""
  203. exportDict = session.data.metronome.export()
  204. for func in self.metronomeChanged:
  205. func(exportDict)
  206. #Sf2 Sampler
  207. def _soundfontChanged(self):
  208. """User loads a new soundfont or on load. Resets everything."""
  209. exportDict = session.data.export()
  210. session.data.updateAllChannelJackMetadaPrettyname()
  211. session.nsmClient.changeLabel(exportDict["name"])
  212. if exportDict:
  213. for func in self.soundfontChanged:
  214. func(exportDict)
  215. def _channelChanged(self, channel):
  216. """A single channel changed its parameters. The soundfont stays the same."""
  217. exportDict = session.data.exportChannel(channel)
  218. session.data.updateChannelAudioJackMetadaPrettyname(channel)
  219. session.data.updateChannelMidiInJackMetadaPrettyname(channel)
  220. for func in self.channelChanged:
  221. func(channel, exportDict)
  222. def _ignoreProgramChangesChanged(self):
  223. state = session.data.midiInput.scene.status().layers[0].status().ignore_program_changes
  224. for func in self.ignoreProgramChangesChanged:
  225. func(state)
  226. def _channelActivity(self, channel):
  227. """send all note on to the GUI"""
  228. for func in self.channelActivity:
  229. func(channel)
  230. def startEngine(nsmClient):
  231. """
  232. This function gets called after initializing the GUI, calfbox
  233. and loading saved data from a file.
  234. It gets called by client applications before their own startEngine.
  235. Stopping the engine is done via pythons atexit in the session.
  236. """
  237. logger.info("Starting template api engine")
  238. assert session
  239. assert callbacks
  240. session.nsmClient = nsmClient
  241. session.eventLoop.fastConnect(callbacks._checkPlaybackStatusAndSendSignal)
  242. session.eventLoop.fastConnect(callbacks._setPlaybackTicks)
  243. session.eventLoop.fastConnect(cbox.get_new_events) #global cbox.get_new_events does not eat dynamic midi port events.
  244. session.eventLoop.slowConnect(callbacks._checkBBTAndSendSignal)
  245. #session.eventLoop.slowConnect(lambda: print(cbox.Transport.status().tempo))
  246. #asession.eventLoop.slowConnect(lambda: print(cbox.Transport.status()))
  247. cbox.Document.get_song().update_playback()
  248. callbacks._recordingModeChanged() #recording mode is in the save file.
  249. callbacks._historyChanged() #send initial undo status to the GUI, which will probably deactivate its undo/redo menu because it is empty.
  250. logger.info("Template api engine started")
  251. def isStandaloneMode():
  252. return session.standaloneMode
  253. def _deprecated_updatePlayback():
  254. """The only place in the program to update the cbox playback besides startEngine.
  255. We only need to update it after a user action, which always goes through the api.
  256. Even if triggered through a midi in or other command.
  257. Hence there is no need to update playback in the session or directly from the GUI."""
  258. #if session.nsmClient.cachedSaveStatus = False: #dirty #TODO: wait for cbox optimisations. The right place to cache and check if an update is necessary is in cbox, not here.
  259. cbox.Document.get_song().update_playback()
  260. def save():
  261. """Saves the file in place. This is mostly here for psychological reasons. Users like to hit
  262. Ctrl+S from muscle memory.
  263. But it can also be used if we run with fake NSM. In any case, it does not accept paths"""
  264. session.nsmClient.serverSendSaveToSelf()
  265. def undo():
  266. """No callbacks need to be called. Undo is done via a complementary
  267. function, already defined, which has all the callbacks in it."""
  268. session.history.undo()
  269. callbacks._dataChanged() #this is in most of the functions already but high-level scripts that use the history.sequence context do not trigger this and get no redo without.
  270. def redo():
  271. """revert undo if nothing new has happened so far.
  272. see undo"""
  273. session.history.redo()
  274. callbacks._dataChanged() #this is in most of the functions already but high-level scripts that use the history.sequence context do not trigger this and get no redo without.
  275. def getUndoLists():
  276. #undoHistory, redoHistory = session.history.asList()
  277. return session.history.asList()
  278. #Calfbox Sequencer Controls
  279. def playbackStatus()->bool:
  280. #status = "[Running]" if cbox.Transport.status().playing else "[Stopped]" #it is not that simple.
  281. cboxStatus = cbox.Transport.status().playing
  282. if cboxStatus == 1:
  283. #status = "[Running]"
  284. return True
  285. elif cboxStatus == 0:
  286. #status = "[Stopped]"
  287. return False
  288. elif cboxStatus == 2:
  289. #status = "[Stopping]"
  290. return False
  291. elif cboxStatus is None:
  292. #status = "[Uninitialized]"
  293. return False
  294. elif cboxStatus == "":
  295. #running with cbox dummy module
  296. return False
  297. else:
  298. raise ValueError("Unknown playback status: {}".format(cboxStatus))
  299. def playPause():
  300. """There are no internal callback to start and stop playback.
  301. The api, or the session, do not call that.
  302. Playback can be started externally via jack transport.
  303. We use the jack transport callbacks instead and trigger our own callbacks directly from them,
  304. in the callback class above"""
  305. if playbackStatus():
  306. cbox.Transport.stop()
  307. else:
  308. cbox.Transport.play()
  309. #It takes a few ms for playbackStatus to catch up. If you call it here again it will not be updated.
  310. def getPlaybackTicks()->int:
  311. return cbox.Transport.status().pos_ppqn
  312. def seek(value):
  313. if value < 0:
  314. value = 0
  315. cbox.Transport.seek_ppqn(value)
  316. def toStart():
  317. seek(0)
  318. def playFrom(ticks):
  319. seek(ticks)
  320. if not playbackStatus():
  321. cbox.Transport.play()
  322. def playFromStart():
  323. toStart()
  324. if not playbackStatus():
  325. cbox.Transport.play()
  326. def toggleRecordingMode():
  327. session.recordingEnabled = not session.recordingEnabled
  328. callbacks._recordingModeChanged()
  329. # Sequencer Metronome
  330. def setMetronome(data, label):
  331. session.data.metronome.generate(data, label)
  332. callbacks._metronomeChanged()
  333. def enableMetronome(value):
  334. session.data.metronome.setEnabled(value) #has side effects
  335. callbacks._metronomeChanged()
  336. def isMetronomeEnabled():
  337. return session.data.metronome.enabled
  338. def toggleMetronome():
  339. enableMetronome(not session.data.metronome.enabled) #handles callback etc.
  340. #Sf2 Sampler
  341. def loadSoundfont(filePath):
  342. """User callable function. Load from saved state is done directly in the session with callbacks
  343. in startEngine
  344. The filePath MUST be in our session dir.
  345. """
  346. filePathInOurSession = os.path.commonprefix([filePath, session.nsmClient.ourPath]) == session.nsmClient.ourPath
  347. if not filePathInOurSession:
  348. raise Exception("api loadSoundfont tried to load .sf2 from outside session dir. Forbidden")
  349. success, errormessage = session.data.loadSoundfont(filePath)
  350. if success:
  351. callbacks._soundfontChanged()
  352. session.history.clear()
  353. callbacks._historyChanged()
  354. callbacks._dataChanged()
  355. else:
  356. callbacks._message("Load Soundfont Error", errormessage)
  357. return success
  358. def setIgnoreProgramAndBankChanges(state):
  359. state = bool(state)
  360. #there is no session wrapper function. we use cbox directly. Save file and callbacks will fetch the current value on its own
  361. session.data.midiInput.scene.status().layers[0].set_ignore_program_changes(state)
  362. assert session.data.midiInput.scene.status().layers[0].status().ignore_program_changes == state
  363. callbacks._ignoreProgramChangesChanged()
  364. callbacks._dataChanged()
  365. def setPatch(channel, bank, program):
  366. if not 1 <= channel <= 16:
  367. raise ValueError (f"Channel must be a number between 1 and 16. Yours: {channel}")
  368. #Bank is split into CC0 and CC32. That makes it a 14bit value (2**14 or 128 * 128) = 16384
  369. if not 0 <= bank <= 16384:
  370. raise ValueError (f"Program must be a number between 0 and 16384. Yours: {bank}")
  371. if not 0 <= program <= 127:
  372. raise ValueError (f"Program must be a number between 0 and 127. Yours: {program}")
  373. session.data.setPatch(channel, bank, program)
  374. callbacks._channelChanged(channel)
  375. callbacks._dataChanged()
  376. #Debug, Test and Template Functions
  377. class TestValues(object):
  378. value = 0
  379. def history_test_change():
  380. """
  381. We simulate a function that gets its value from context.
  382. Here it is random, but it may be the cursor position in a real program."""
  383. from random import randint
  384. value = 0
  385. while value == 0:
  386. value = randint(-10,10)
  387. if value > 0:
  388. session.history.setterWithUndo(TestValues, "value", TestValues.value + value, "Increase Value", callback=callbacks._debugChanged) #callback includes dataChanged which inlucdes historyChanged
  389. else:
  390. session.history.setterWithUndo(TestValues, "value", TestValues.value + value, "Decrease Value", callback=callbacks._debugChanged) #callback includes dataChanged which inlucdes historyChanged
  391. def history_test_undoSequence():
  392. with session.history.sequence("Change Value Multiple Times"):
  393. history_test_change()
  394. history_test_change()
  395. history_test_change()
  396. history_test_change()
  397. callbacks._historyChanged()
  398. #Module Level Data
  399. callbacks = Callbacks() #This needs to be defined before startEngine() so a GUI can register its callbacks. The UI will then startEngine and wait to reveice the initial round of callbacks
  400. session = Session()
  401. session.history.apiCallback_historySequenceStarted = callbacks._historySequenceStarted
  402. session.history.apiCallback_historySequenceStopped = callbacks._historySequenceStopped
  403. #Import complete. Now the parent module, like a gui, will call startEngine() and provide an event loop.