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.

706 lines
31KB

  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 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. #Standard Library
  19. from typing import List, Dict, Tuple, Iterable
  20. #Third Party Modules
  21. from calfbox import cbox
  22. #Template Modules
  23. from .data import Data
  24. from .metronome import Metronome
  25. from .duration import traditionalNumberToBaseDuration, MAXIMUM_TICK_DURATION
  26. #Client Modules
  27. from engine.config import * #includes METADATA only. No other environmental setup is executed.
  28. class Score(Data):
  29. """Manages and holds tracks
  30. Has a mutable list of Track instances. This is the official order. Rearranging happens here.
  31. Order ist reflected in JACK through metadata. UIs should adopt it as well.
  32. Score.TrackClass needs to be injected with your Track class.
  33. Score.TrackClass needs to have a SequencerInterface of type SequencerInterface
  34. self.tracks holds only active tracks. Which means tracks that produce sound
  35. That does not mean that they are visible or editable for the user.
  36. Does NOT hold deleted tracks in the undo storage. You need to hold these tracks in memory
  37. yourself before calling score.delete. For example Laborejo registers the Track instance
  38. in our history module which keeps the instance alive.
  39. Special Tracks do not need to be created here. E.g. a metronome can be just a track.
  40. """
  41. TrackClass = None
  42. def __init__(self, parentSession):
  43. assert Score.TrackClass
  44. super().__init__(parentSession)
  45. self.tracks = [] #see docstring
  46. self.tempoMap = TempoMap(parentData = self)
  47. self._template_processAfterInit()
  48. self._tracksFailedLookup = []
  49. def _template_processAfterInit(self): #needs a different name because there is an inherited class with the same method.
  50. """Call this after either init or instanceFromSerializedData"""
  51. if METADATA["metronome"]:
  52. self.metronome = Metronome(parentData=self) #Purely dynamic structure. No save/load. No undo/redo
  53. #Whole Score / Song
  54. def buildSongDuration(self, startEndTuple=None):
  55. """Set playback length for the entire score or a loop.
  56. Why is start the end-tick of the song?
  57. Starting from 0 would create an actual loop from the start to end.
  58. We want the song to play only once.
  59. The cbox way of doing that is to set the loop range to zero at the end of the track.
  60. Zero length is stop.
  61. """
  62. if startEndTuple is None:
  63. longestTrackDuration = max(track.sequencerInterface.cachedDuration for track in self.tracks)
  64. start = longestTrackDuration
  65. end = longestTrackDuration
  66. else:
  67. start, end = startEndTuple
  68. cbox.Document.get_song().set_loop(start, end)
  69. #Tracks
  70. def addTrack(self, name:str=""):
  71. """Create and add a new track. Not an existing one"""
  72. track = Score.TrackClass(parentData=self, name=name)
  73. assert track.sequencerInterface
  74. self.tracks.append(track)
  75. return track
  76. def deleteTrack(self, track):
  77. track.sequencerInterface.prepareForDeletion()
  78. self.tracks.remove(track)
  79. return track #for undo
  80. def updateJackMetadataSorting(self):
  81. """Add this to you "tracksChanged" or "numberOfTracksChanged" callback.
  82. Tell cbox to reorder the tracks by metadata. Deleted ports are automatically removed by JACK.
  83. It is advised to use this in a controlled manner. There is no Score-internal check if
  84. self.tracks changed and subsequent sorting. Multiple track changes in a row are common,
  85. therefore the place to update jack order is in the API, where the new track order is also
  86. sent to the UI.
  87. We also check if the track is 'deactivated' by probing track.cboxMidiOutUuid.
  88. Patroneo uses prepareForDeletion to deactive the tracks standalone track but keeps the
  89. interface around for later use.
  90. """
  91. order = {portName:index for index, portName in enumerate(track.sequencerInterface.cboxPortName() for track in self.tracks if track.sequencerInterface.cboxMidiOutUuid)}
  92. try:
  93. cbox.JackIO.Metadata.set_all_port_order(order)
  94. except Exception as e: #No Jack Meta Data or Error with ports.
  95. logger.error(e)
  96. def trackById(self, trackId:int):
  97. """Returns a track or None, if not found"""
  98. for track in self.tracks:
  99. if trackId == id(track):
  100. return track
  101. else:
  102. #Previously this crashed with a ValueError. However, after a rare bug that a gui widget focussed out because a track was deleted and then tried to send its value to the engine we realize that this lookup can gracefully return None.
  103. #Nothing will break: Functions that are not aware yet, that None is an option will crash when they try to access None as a track object. For this case we present the following logger error:
  104. if not trackId in self._tracksFailedLookup:
  105. logger.error(f"Track {trackId} not found. Current Tracks: {[id(tr) for tr in self.tracks]}")
  106. self._tracksFailedLookup.append(trackId) #prevent multiple error messages for the same track in a row.
  107. return None
  108. #raise ValueError(f"Track {trackId} not found. Current Tracks: {[id(tr) for tr in self.tracks]}")
  109. #Save / Load / Export
  110. def serialize(self)->dict:
  111. return {
  112. "tracks" : [track.serialize() for track in self.tracks],
  113. "tempoMap" : self.tempoMap.serialize(),
  114. }
  115. @classmethod
  116. def instanceFromSerializedData(cls, parentSession, serializedData):
  117. """The entry function to create a score from saved data. It is called by the session.
  118. This functions triggers a tree of other createInstanceFromSerializedData which finally
  119. return the score, which gets saved in the session.
  120. The serializedData is already converted to primitive python types from json,
  121. but nothing more. Here we create the actual objects."""
  122. self = cls.__new__(cls)
  123. Score.copyFromSerializedData(parentSession, serializedData, self)
  124. return self
  125. @staticmethod
  126. def copyFromSerializedData(parentSession, serializedData, childObject):
  127. """
  128. childObject is a Score or similar.
  129. Because this is an actual parent class we can't use instanceFromSerializedData in a child
  130. without actually creating an object. Long story short, use this to generate the data and
  131. use it in your child class. If the Data class is used standalone it still can be used."""
  132. childObject.parentSession = parentSession
  133. loadedTracks=[]
  134. for trackSrzData in serializedData["tracks"]:
  135. track = Score.TrackClass.instanceFromSerializedData(parentData=childObject, serializedData=trackSrzData)
  136. loadedTracks.append(track)
  137. childObject.tracks=loadedTracks
  138. childObject.tempoMap=TempoMap.instanceFromSerializedData(parentData=childObject, serializedData=serializedData["tempoMap"])
  139. childObject._template_processAfterInit()
  140. def export(self)->dict:
  141. return {
  142. "numberOfTracks" : len(self.tracks),
  143. #"duration" : self.
  144. }
  145. class _Interface(object):
  146. #no load or save. Do that in the child classes.
  147. def __init__(self, parentTrack, name=None):
  148. self.parentTrack = parentTrack
  149. self.parentData = parentTrack.parentData
  150. self._name = self._isNameAvailable(name) if name else str(id(self))
  151. self._enabled = True
  152. self._processAfterInit()
  153. def _processAfterInit(self):
  154. self._cachedPatterns = [] #makes undo after delete possible
  155. self.calfboxTrack = cbox.Document.get_song().add_track()
  156. self.calfboxTrack.set_name(self.name) #only cosmetic and cbox internal. Useful for debugging, not used in jack.
  157. #Caches and other Non-Saved attributes
  158. self.cachedDuration = 0 #used by parentData.buildSongDuration to calculate the overall length of the song by checking all tracks.
  159. @property
  160. def name(self):
  161. return self._name
  162. @property
  163. def enabled(self)->bool:
  164. return self._enabled
  165. def _isNameAvailable(self, name:str):
  166. """Check if the name is free. If not increment"""
  167. name = ''.join(ch for ch in name if ch.isalnum() or ch in (" ", "_", "-")) #sanitize
  168. name = " ".join(name.split()) #remove double spaces
  169. while name in [tr.sequencerInterface.name for tr in self.parentData.tracks]:
  170. beforeLastChar = name[-2]
  171. lastChar = name[-1]
  172. if beforeLastChar==" " and lastChar.isalnum() and lastChar not in ("9", "z", "Z"):
  173. #Pattern is "Trackname A" or "Trackname 1" which can be incremented.
  174. name = name[:-1] + chr(ord(name[-1]) +1)
  175. else:
  176. name = name + " A"
  177. return name
  178. def _updatePlayback(self):
  179. self.parentData.buildSongDuration()
  180. cbox.Document.get_song().update_playback()
  181. def setTrack(self, blobs:Iterable): #(bytes-blob, position, length)
  182. """Converts an Iterable of (bytes-blob, position, length) to cbox patterns, clips and adds
  183. them to an empty track, which replaces the current one.
  184. Simplest version is to send one blob at position 0 with its length."""
  185. #self.calfboxTrack.delete() #cbox clear data, not python structure
  186. #self.calfboxTrack = cbox.Document.get_song().add_track()
  187. #self.calfboxTrack.set_external_output(self.cboxMidiOutUuid)
  188. self.calfboxTrack.clear_clips()
  189. self._cachedPatterns = [] #makes undo after delete possible
  190. pos = 0
  191. for blob, pos, leng in blobs:
  192. if leng > 0:
  193. pat = cbox.Document.get_song().pattern_from_blob(blob, leng)
  194. t = (pat, pos, leng)
  195. self._cachedPatterns.append(t)
  196. length = 0
  197. for pattern, position, length in self._cachedPatterns:
  198. if length > 0:
  199. self.calfboxTrack.add_clip(position, 0, length, pattern) #pos, offset, length, pattern.
  200. self.cachedDuration = pos + length #use the last set values
  201. self._updatePlayback()
  202. def insertEmptyClip(self):
  203. """Convenience function to make recording into an empty song possible.
  204. Will be removed by self.setTrack."""
  205. blob = bytes()
  206. #We do not need any content #blob += cbox.Pattern.serialize_event(0, 0x80, 60, 64) # note off
  207. pattern = cbox.Document.get_song().pattern_from_blob(blob, MAXIMUM_TICK_DURATION) #blog, length
  208. self.calfboxTrack.add_clip(0, 0, MAXIMUM_TICK_DURATION, pattern) #pos, offset, length, pattern.
  209. self.cachedDuration = MAXIMUM_TICK_DURATION
  210. self._updatePlayback()
  211. class _Subtrack(object):
  212. """Generates its own midi data and caches the resulting track but does not have a name
  213. nor its own jack midi port. Instead it is attached to an SequencerInterface.
  214. It is SequencerInterface because that has a jack midi port, and not Interface itself.
  215. Only used by SequencerInterface internally. Creation is done by its methods.
  216. This is not a child class because a top level class' code is easier to read.
  217. Intended usecase is to add CC messages as one subtrack per CC number (e.g. CC7 = Volume).
  218. Of course you could just put CCs together with notes in the main Interface
  219. and not use SubTracks. But where is the fun in that?"""
  220. def __init__(self, parentSequencerInterface):
  221. self._cachedPatterns = [] #makes undo after delete possible
  222. self.parentSequencerInterface = parentSequencerInterface
  223. self.calfboxSubTrack = cbox.Document.get_song().add_track()
  224. self.calfboxSubTrack.set_external_output(parentSequencerInterface.cboxMidiOutUuid)
  225. def prepareForDeletion(self):
  226. self.calfboxSubTrack.delete() #in place self deletion.
  227. self.calfboxSubTrack = None
  228. cbox.Document.get_song().update_playback()
  229. def recreateThroughUndo(self):
  230. assert self.calfboxSubTrack is None, self.calfboxSubTrack
  231. self.calfboxSubTrack = cbox.Document.get_song().add_track()
  232. for pattern, position, length in self._cachedPatterns:
  233. self.calfboxSubTrack.add_clip(position, 0, length, pattern) #pos, offset, length, pattern.
  234. cbox.Document.get_song().update_playback()
  235. def setSubtrack(self, blobs:Iterable): #(bytes-blob, position, length)
  236. """Does not add to the parents cached duration. Therefore it will not send data beyond
  237. its parent track length, except if another track pushes the overall duration beyond."""
  238. self.calfboxSubTrack.clear_clips()
  239. self._cachedPatterns = [] #makes undo after delete possible
  240. pos = 0
  241. for blob, pos, leng in blobs:
  242. if leng > 0:
  243. pat = cbox.Document.get_song().pattern_from_blob(blob, leng)
  244. t = (pat, pos, leng)
  245. self._cachedPatterns.append(t)
  246. length = 0
  247. for pattern, position, length in self._cachedPatterns:
  248. if length > 0:
  249. self.calfboxSubTrack.add_clip(position, 0, length, pattern) #pos, offset, length, pattern.
  250. cbox.Document.get_song().update_playback()
  251. class SequencerInterface(_Interface): #Basically the midi part of a track.
  252. """A tracks name is the same as the jack midi-out ports name.
  253. The main purpose of the child class is to manage its musical data and regulary
  254. fill self.calfboxTrack with musical data:
  255. Create one ore more patterns, distribute them into clips, add clips to the cboxtrack.
  256. buffer = bytes()
  257. buffer += cbox.Pattern.serialize_event(startTick, 0x90, pitch, velocity) # note on
  258. buffer += cbox.Pattern.serialize_event(endTick-1, 0x80, pitch, velocity) # note off #-1 ticks to create a small logical gap. Does not affect next note on.
  259. pattern = cbox.Document.get_song().pattern_from_blob(buffer, oneMeasureInTicks)
  260. self.calfboxTrack.add_clip(index*oneMeasureInTicks, 0, oneMeasureInTicks, pattern) #pos, pattern-internal offset, length, pattern.
  261. Use caches to optimize performance. This is mandatory!
  262. self.cachedDuration = the maximum track length. Used to determine the song playback duration.
  263. """
  264. def _processAfterInit(self):
  265. #Create midi out and cbox track
  266. logger.info(f"Creating empty SequencerInterface instance for {self._name}")
  267. super()._processAfterInit()
  268. self.cboxMidiOutUuid = cbox.JackIO.create_midi_output(self._name)
  269. self.calfboxTrack.set_external_output(self.cboxMidiOutUuid)
  270. cbox.JackIO.rename_midi_output(self.cboxMidiOutUuid, self._name)
  271. self._subtracks = {} #arbitrary key: _Subtrack(). This is not in Interface itself because Subtracks assume a jack midi out.
  272. self.enable(self._enabled)
  273. def enable(self, enabled):
  274. """This is "mute", more or less. It only disables the note parts, not CCs or other subtracks.
  275. This means if you switch this on again during playback you will have the correct context."""
  276. if enabled:
  277. #self.calfboxTrack.set_external_output(self.cboxMidiOutUuid) #Old version. Does not prevent hanging notes.
  278. self.calfboxTrack.set_mute(0)
  279. else:
  280. #self.calfboxTrack.set_external_output("") #Old version. Does not prevent hanging notes.
  281. self.calfboxTrack.set_mute(1)
  282. self._enabled = bool(enabled)
  283. cbox.Document.get_song().update_playback()
  284. @_Interface.name.setter
  285. def name(self, value):
  286. if not value in (track.sequencerInterface.name for track in self.parentData.tracks):
  287. self._name = self._isNameAvailable(value)
  288. if self.cboxMidiOutUuid: #we could be deactivated
  289. cbox.JackIO.rename_midi_output(self.cboxMidiOutUuid, self._name)
  290. def cboxPortName(self)->str:
  291. """Return the complete jack portname: OurName:PortName"""
  292. portname = cbox.JackIO.status().client_name + ":" + self.name
  293. return portname
  294. def prepareForDeletion(self):
  295. """Called by score right before this track gets deleted.
  296. This does not mean the track is gone. It can be recovered by
  297. undo. That is why we bother setting calfboxTrack to None
  298. again.
  299. """
  300. if not self.calfboxTrack: #maybe non-template part deactivated it, like Patroneo groups
  301. return
  302. try:
  303. portlist = cbox.JackIO.get_connected_ports(self.cboxPortName())
  304. except: #port not found.
  305. portlist = []
  306. self._beforeDeleteThisJackMidiWasConnectedTo = portlist
  307. self.calfboxTrack.set_external_output("")
  308. cbox.JackIO.delete_midi_output(self.cboxMidiOutUuid)
  309. self.calfboxTrack.delete() #in place self deletion.
  310. self.calfboxTrack = None
  311. self.cboxMidiOutUuid = None
  312. #we leave cachedDuration untouched
  313. self._updatePlayback()
  314. def recreateThroughUndo(self):
  315. """Brings this track back from the dead, in-place.
  316. Assumes this track instance was not in the score but
  317. somewhere in memory. self.prepareForDeletion() was called
  318. in the past which deleted the midi output but not the cbox-midi
  319. data it generated and held"""
  320. #Recreate Calfbox Midi Data
  321. assert self.calfboxTrack is None, self.calfboxTrack
  322. self.calfboxTrack = cbox.Document.get_song().add_track()
  323. self.calfboxTrack.set_name(self.name) #only cosmetic and cbox internal. Useful for debugging, not used in jack.
  324. for pattern, position, length in self._cachedPatterns:
  325. self.calfboxTrack.add_clip(position, 0, length, pattern) #pos, offset, length, pattern.
  326. #self.cachedDuration is still valid
  327. #Create MIDI and reconnect Jack
  328. self.cboxMidiOutUuid = cbox.JackIO.create_midi_output(self.name)
  329. cbox.JackIO.rename_midi_output(self.cboxMidiOutUuid, self._name)
  330. self.calfboxTrack.set_external_output(self.cboxMidiOutUuid)
  331. for port in self._beforeDeleteThisJackMidiWasConnectedTo:
  332. try:
  333. cbox.JackIO.port_connect(self.cboxPortName(), port)
  334. except: #external connected synth is maybe gone. Prevent crash.
  335. logger.warning(f"Previously external connection {port} is gone. Can't connect anymore." )
  336. #Make it official
  337. self._updatePlayback()
  338. def setSubtrack(self, key, blobs:Iterable): #(bytes-blob, position, length)
  339. """Creates a new subtrack if key is unknown
  340. Forward data to the real function
  341. Simplest version is to send one blob at position 0 with its length"""
  342. if not key in self._subtracks:
  343. self._subtracks[key] = _Subtrack(parentSequencerInterface=self)
  344. assert self._subtracks[key], key
  345. assert isinstance(self._subtracks[key], _Subtrack), type(self._subtracks[key])
  346. self._subtracks[key].setSubtrack(blobs)
  347. def deleteSubtrack(self, key):
  348. """Remove a subtrack.
  349. Return for a potential undo"""
  350. self._subtracks[key].prepareForDeletion()
  351. toDelete = self._subtracks[key]
  352. del self._subtracks[key]
  353. return toDelete
  354. #Save / Load / Export
  355. def serialize(self)->dict:
  356. """Generate Data to save as json"""
  357. return {
  358. "name" : self.name,
  359. "enabled" : self._enabled,
  360. }
  361. @classmethod
  362. def instanceFromSerializedData(cls, parentTrack, serializedData):
  363. self = cls.__new__(cls)
  364. self._name = serializedData["name"]
  365. self._enabled = serializedData["enabled"]
  366. self.parentTrack = parentTrack
  367. self.parentData = parentTrack.parentData
  368. self._processAfterInit()
  369. return self
  370. def export(self)->dict:
  371. return {
  372. "id" : id(self),
  373. "name" : self.name,
  374. "index" : self.parentData.tracks.index(self.parentTrack) if self.parentTrack in self.parentData.tracks else None , #could be a special track, like the metronome
  375. "cboxPortName" : self.cboxPortName(),
  376. "cboxMidiOutUuid" : self.cboxMidiOutUuid,
  377. "enabled" : self._enabled,
  378. }
  379. class SfzInstrumentSequencerInterface(_Interface):
  380. """Like a midi output, only routes to an internal instrument.
  381. This is not a pure sfz sampler, but rather a track that ends in an instrument instead of a
  382. jack midi output."""
  383. def __init__(self, parentTrack, name:str, absoluteSfzPath:str):
  384. super().__init__(parentTrack, name) #includes processAfterInit
  385. self.scene = cbox.Document.get_engine().new_scene()
  386. self.scene.clear()
  387. self.scene.add_new_instrument_layer(name, "sampler") #"sampler" is the cbox sfz engine
  388. self.scene.status().layers[0].get_instrument().engine.load_patch_from_string(0, "", "", "") #fill with null instruments
  389. self.scene.set_enable_default_song_input(True)
  390. self.instrumentLayer = self.scene.status().layers[0].get_instrument()
  391. self.scene.status().layers[0].set_ignore_program_changes(1) #TODO: ignore different channels. We only want one channel per scene/instrument/port.
  392. newProgramNumber = 1
  393. program = self.instrumentLayer.engine.load_patch_from_file(newProgramNumber, absoluteSfzPath, name)
  394. self.instrumentLayer.engine.set_patch(10, newProgramNumber) #from 1. 10 is the channel #TODO: we want this to be on all channels.
  395. #TODO: Metronome is not compatible with current cbox. we need to route midi data from our cbox track explicitely to self.scene, which is not possible right now.
  396. self.calfboxTrack.set_external_output("")
  397. #Metadata
  398. portnameL = f"{cbox.JackIO.status().client_name}:out_1"
  399. portnameR = f"{cbox.JackIO.status().client_name}:out_2"
  400. cbox.JackIO.Metadata.set_pretty_name(portnameL, name.title() + "-L")
  401. cbox.JackIO.Metadata.set_pretty_name(portnameR, name.title() + "-R")
  402. def enable(self, enabled):
  403. if enabled:
  404. self.scene.status().layers[0].set_enable(True)
  405. else:
  406. self.scene.status().layers[0].set_enable(False)
  407. self._enabled = bool(enabled) #this is redundant in the SfzInstrument, but the normal midi outs need this. So we stick to the convention.
  408. cbox.Document.get_song().update_playback()
  409. @property
  410. def enabled(self)->bool:
  411. return self._enabled
  412. class TempoMap(object):
  413. """
  414. This is a singleton instance in Score. Don't subclass.
  415. Main data structure is self._tempoMap = {positionInTicks:(bpmAsFloat, timesigUpper, timesigLower)}
  416. The tempo map is only active if the whole program is JACK Transport Master (via Cbox).
  417. If not we simply follow jack sync.
  418. All values are floats.
  419. TempoMap itself handles this global switch if you set isTransportMaster=True (it is a property)
  420. For simplicity reasons the tempo map only deals with quarter notes per minute internally.
  421. There are functions to convert to and from a number of other tempo formats.
  422. There are three recommended ways to change the tempo map:
  423. 1) setTempoMap completely replaces the tempo map with a new supplied one
  424. 2) If you want just one tempo use the convenience function setQuarterNotesPerMinute.
  425. This will override and delete(!) the current tempo map.
  426. You can retrieve it with getQuarterNotePerMinute.
  427. 3) set isTransportMaster will trigger a rebuild of the tempo map as a side effect. It does not
  428. change the existing tempo map. Flipping the transport master back will reenable the old tempo Map
  429. If you want to incrementally change the tempo map, which is really not necessary because
  430. changing it completely is a very cheap operation, you can edit the dict _tempoMap directly.
  431. In case you have a complex tempo management yourself, like Laborejo, use it to setTempoMap and
  432. then don't worry about save and load. Treat it as a cache that conveniently restores the last
  433. setting after program startup. """
  434. def __init__(self, parentData):
  435. logger.info("Creating empty TempoMap instance")
  436. self.parentData = parentData
  437. self._tempoMap = {0:(120.0, 4, 4)} # 4/4, 120bpm. will not be used on startup, but is needed if transportMaster is switched on
  438. self._isTransportMaster = False
  439. self._processAfterInit()
  440. assert not cbox.Document.get_song().status().mtis
  441. def _processAfterInit(self):
  442. self.factor = 1.0 # not saved
  443. self.isTransportMaster = self._isTransportMaster #already triggers cbox settings through @setter.
  444. self._sanitize()
  445. def _updatePlayback(self):
  446. """A wrapper that not only calls update playback but forces JACK to call its BBT callback,
  447. so it gets the new tempo info even without transport running.
  448. That is a bit of a hack, but it works without disturbing anything too much."""
  449. cbox.Document.get_song().update_playback()
  450. pos = cbox.Transport.status().pos #can be None on program start
  451. if self.isTransportMaster and not cbox.Transport.status().playing: #pos can be 0
  452. if pos is None:
  453. #Yes, we destroy the current playback position. But we ARE timebase master, so that is fine.
  454. cbox.Transport.seek_samples(0)
  455. else: #default case
  456. cbox.Transport.seek_samples(pos)
  457. @property
  458. def isTransportMaster(self) -> bool:
  459. return self._isTransportMaster
  460. @isTransportMaster.setter
  461. def isTransportMaster(self, value:bool):
  462. logger.info(f"Jack Transport Master status: {value}")
  463. self._isTransportMaster = value
  464. if value:
  465. self._sendToCbox() #reactivate existing tempo map
  466. cbox.JackIO.external_tempo(False)
  467. cbox.JackIO.transport_mode(master = True, conditional = False) #conditional = only attempt to become a master (will fail if there is one already)
  468. else:
  469. self._clearCboxTempoMap() #clear cbox map but don't touch our own data.
  470. cbox.JackIO.external_tempo(True)
  471. try:
  472. cbox.JackIO.transport_mode(master = False)
  473. except Exception: #"Not a current timebase master"
  474. pass
  475. self._updatePlayback()
  476. def _sanitize(self):
  477. """Inplace modification of self.tempoMap. Remove zeros and convert to float values. """
  478. self._tempoMap = {int(key):(float(value), timesigNum, timesigDenom) for key, (value, timesigNum, timesigDenom) in self._tempoMap.items() if value > 0.0}
  479. #Don't use the following. Empty tempo maps are allowed, especially in jack transport slave mode. #Instead set a default tempo 120 on init explicitly
  480. #if not self._tempoMap:
  481. #logger.warning("Found invalid tempo map. Forcing to 120 bpm. Please correct manually")
  482. #self._tempoMap = {0, 120.0}
  483. def _clearCboxTempoMap(self):
  484. """Remove all cbox tempo values by iterating over all of them and set them to None, which is
  485. the secret cbox handshake to delete a tempo change on a specific position.
  486. Keep our own local data intact."""
  487. song = cbox.Document.get_song()
  488. for mti in song.status().mtis: #Creates a new temporary list with newly created objects, safe to iterate and delete.
  489. song.delete_mti(mti.pos)
  490. self._updatePlayback()
  491. assert not song.status().mtis, song.status().mtis
  492. def _sendToCbox(self):
  493. """Send to cbox"""
  494. assert self.isTransportMaster
  495. assert self._tempoMap
  496. song = cbox.Document.get_song()
  497. for pos, (value, timesigNum, timesigDenom) in self._tempoMap.items():
  498. song.set_mti(pos=pos, tempo=value*self.factor, timesig_denom=timesigDenom , timesig_num=timesigNum) #Tempo changes are fine to happen on the same tick as note on.
  499. #song.set_mti(pos=pos, tempo=value * self.factor) #Tempo changes are fine to happen on the same tick as note on.
  500. self._updatePlayback()
  501. def setTempoMap(self, tempoMap:dict):
  502. """All-in-one function for outside access"""
  503. if self._tempoMap != tempoMap:
  504. self._tempoMap = tempoMap
  505. self._sanitize()
  506. if self.isTransportMaster: #if not the data will be used later.
  507. self._clearCboxTempoMap() #keeps our own data so it can be send again.
  508. self._sendToCbox()
  509. def setFactor(self, factor:float):
  510. """Factor is from 1, not from the current one."""
  511. self.factor = round(factor, 4)
  512. self._sanitize()
  513. self._clearCboxTempoMap() #keeps our own data so it can be send again.
  514. self._sendToCbox() #uses the factor
  515. def setQuarterNotesPerMinute(self, quarterNotesPerMinute:float):
  516. """Simple tempo setter. Overrides all other tempo data.
  517. Works in tandem with self.setTimeSignature"""
  518. currentValue, timesigNum, timesigDenom = self._tempoMap[0]
  519. self.setTempoMap({0:(quarterNotesPerMinute, timesigNum, timesigDenom)})
  520. def setTimeSignature(self, timesigNum:int, timesigDenom:int):
  521. """Simple traditional timesig setter. Overrides all other timesig data.
  522. Works in tandem with self.setTimeSignature.
  523. """
  524. assert timesigNum > 0, timesigNum
  525. #assert timesigDenom in traditionalNumberToBaseDuration, (timesigDenom, traditionalNumberToBaseDuration) For Laborejo this makes sense, but Patroneo has a fallback option with irregular timesigs: 8 steps in groups of 3 to make a quarter is valid. Results in "8/12"
  526. currentValue, OLD_timesigNum, OLD_timesigDenom = self._tempoMap[0]
  527. self.setTempoMap({0:(currentValue, timesigNum, timesigDenom)})
  528. def getQuarterNotesPerMinute(self)->float:
  529. """This assumes there is only one tempo point"""
  530. if self.isTransportMaster:
  531. assert len(self._tempoMap) == 1, len(self._tempoMap)
  532. assert 0 in self._tempoMap, self._tempoMap
  533. return self._tempoMap[0][0] #second [0] is the tuple (tempo, timesig, timesig)
  534. else:
  535. logger.info("Requested Quarter Notes per Minute, but we are not transport master")
  536. return None
  537. #Save / Load / Export
  538. def serialize(self)->dict:
  539. """Generate Data to save as json"""
  540. return {
  541. "isTransportMaster" : self.isTransportMaster,
  542. "tempoMap" : self._tempoMap,
  543. }
  544. @classmethod
  545. def instanceFromSerializedData(cls, parentData, serializedData):
  546. logger.info("Loading TempoMap from saved file")
  547. self = cls.__new__(cls)
  548. self.parentData = parentData
  549. self._tempoMap = serializedData["tempoMap"] #json saves dict-keys as strings. We revert back in sanitize()
  550. self._isTransportMaster = serializedData["isTransportMaster"]
  551. self._processAfterInit()
  552. return self
  553. def export(self)->dict:
  554. return {
  555. "id" : id(self),
  556. "isTransportMaster" : self.isTransportMaster,
  557. "tempoMap" : self._tempoMap,
  558. "mtis" : cbox.Document.get_song().status().mtis,
  559. }