├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── editor.png ├── example.py ├── example.xml ├── rocket ├── __init__.py ├── connectors │ ├── __init__.py │ ├── base.py │ ├── files.py │ ├── project.py │ └── socket.py ├── controllers │ ├── __init__.py │ ├── base.py │ └── time.py ├── rocket.py └── tracks.py ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.tox 3 | /env 4 | /data 5 | /ui 6 | __pycache__ 7 | /build 8 | /dist 9 | /pyrocket.egg-info 10 | .vscode/settings.json 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | sudo: false 4 | 5 | matrix: 6 | include: 7 | - env: TOXENV=pep8 8 | python: 3.6 9 | 10 | install: pip install tox 11 | 12 | script: tox 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2017 Contributors 2 | 3 | This software is provided 'as-is', without any express or implied 4 | warranty. In no event will the authors be held liable for any damages 5 | arising from the use of this software. 6 | 7 | Permission is granted to anyone to use this software for any purpose, 8 | including commercial applications, and to alter it and redistribute it 9 | freely, subject to the following restrictions: 10 | 11 | 1. The origin of this software must not be misrepresented; you must not 12 | claim that you wrote the original software. If you use this software 13 | in a product, an acknowledgment in the product documentation would be 14 | appreciated but is not required. 15 | 2. Altered source versions must be plainly marked as such, and must not be 16 | misrepresented as being the original software. 17 | 3. This notice may not be removed or altered from any source distribution. 18 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include rocket *.py 2 | 3 | include README.rst 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |pypi| |travis| 2 | 3 | pyrocket 4 | ======== 5 | 6 | A `rocket `__ client written in Python. 7 | 8 | - The port was inspired by `Moonlander `_ 9 | - Tested with `Rocket OpenGL editor `_ 10 | 11 | This project is written in python 3 and is verified to work on 12 | Windows, OS X and Linux. 13 | 14 | |editor| 15 | 16 | pyrocket was originally part of demosys-py_ and was separated into this project. 17 | The screenshot shows pyrocket with demosys-py and Rocket OpenGL editor. 18 | 19 | What is Rocket? 20 | =============== 21 | 22 | Rocket is a sync-tracker tool originally for synchronizing music and visuals in 23 | demoscene productions, but has later also be used for many other purposes where 24 | static sets of interpolating key frames are neeed. There are no requirements for 25 | music to be involved. 26 | 27 | It consists of an editor and a client that can either communicate 28 | with the editor over a network socket, or play back an exported data-set. 29 | 30 | This project is only a client (for now), so you will have to find an editor. You include 31 | this client in your application so it can easily talk to an external editor or play back 32 | existing key frame data from file(s). 33 | 34 | Hardcoding this data is doable, but when reaching a certain complexity it can get ugly 35 | pretty quick. Several datasets of keyframes can also be used in the same application 36 | to play back some static or semi-static snippet of events and interpolations. 37 | 38 | Converting other types of data and formats to rocket is also a use case 39 | as the rocket format is very simple and accessible and requires fairly little 40 | effort to include in your application. 41 | 42 | Contributing 43 | ============ 44 | 45 | Be free to post questions and create issues. Do not hesitate to open a pull request 46 | (completed or work in progress). The worst thing that can happen is that we learn something. 47 | 48 | Contributors: 49 | 50 | - `Einar Forselv `_ 51 | - `Arttu Tamminen `_ 52 | 53 | How Rocket Works 54 | ================ 55 | 56 | Rocket data is a collection of named groups ("tracks") containing key frames. Each key 57 | frame contains a row number (int), value (float) and interpolation type (enum/byte). 58 | The row number is a time unit. This is translated to seconds based on a configured rows 59 | per second value. Rows per second is normally adjusted based on the music such as beats 60 | per minute. The row resolution will then be a grid that helps the user to place key 61 | frames accurately in sync with the music. 62 | 63 | The rocket client can be used in three different modes: 64 | 65 | - **Editor mode**: Use the socket connector to connect to an external editor. The editor 66 | should ideally already be opened and you have loaded an xml file containing all the key 67 | frame data. When the client connects it will download all the key frames from the editor 68 | and will keep synchronizing the data as you edit the key frames. 69 | - **Playback: Editor Data**: The client will load the xml file created by the editor and 70 | play it back. This is a perfectly valid option if the xml project file has a reasonable 71 | size. This is the commonly used option. 72 | - **Playback: Exported**: In editor mode you can select "export remote" that will tell 73 | the client to save all the current tracks in separate files in a binary format. This 74 | mode loads and plays back this data. The main purpose of this option is to vastly 75 | reduce the size of all the key frame data if your xml project file gets unreasonably big. 76 | It can also add some obfuscation to your data. 77 | 78 | Interpolation Types 79 | =================== 80 | 81 | The client library will do all the interpolation calculations for you. 82 | The rocket protocol is supposed to be as simple as possible. If you need any other 83 | interpolation types you can for example use linear interpolation and apply 84 | a formula on these values. 85 | 86 | Supported interpolation modes are: 87 | - Step: Key frame produces a constant value) 88 | - Linear: Linear interpolation between key frames 89 | - Smooth: Interpolates key frames using: ``t * t * (3 - 2 * t)`` 90 | - Ramp: Interpolates key frame using: ``t^2`` 91 | 92 | Using the Client 93 | ================ 94 | 95 | First of all you need a controller. This class keeps track of the current 96 | time. We currently only implement a basic ``TimeController``. If you want music 97 | playback you will have to implement your own controller by extending the base 98 | ``Controller`` class. The reason for this is simply that we don't want to lock 99 | users into using a specific library. 100 | 101 | When setting up a rocket project it's important to chose the right ``rows_per_second``. 102 | This is the resolution of your key frame data. 103 | 104 | If music is involved we calculate a resolution that would fit the beats 105 | per minute. For 120 bpm music it may only be enough to use an rps of 106 | 20, 24 or 30. 107 | 108 | Quick draw loop setup: 109 | 110 | (Do note that both time and track row is interpolated as floats, 111 | so even low values for ``rows_per_second`` will yield smoothly interpolated 112 | key frame values) 113 | 114 | .. code:: python 115 | 116 | import time 117 | from rocket import Rocket 118 | from rocket.controllers import TimeController 119 | 120 | # Simple controller tracking time at 24 rows per second (50ms resolution) 121 | controller = TimeController(20) 122 | 123 | # Below is the tree different ways to initialize the client 124 | 125 | # Editor mode (track_path: where binary track data ends up when doing a remote export) 126 | rocket = Rocket.from_socket(controller, track_path="./data") 127 | 128 | # Playback using the editor file 129 | rocket = Rocket.from_project_file(controller, 'example.xml') 130 | 131 | # Playback using binary track data 132 | rocket = Rocket.from_files(controller, './data') 133 | 134 | # Register some tracks 135 | # Just register a track 136 | rocket.track("cube:rotation") 137 | # Register a track and store the reference for later 138 | size_track = rocket.track("cube:size") 139 | 140 | # Enter the draw loop 141 | rocket.start() 142 | while True: 143 | # Update inner states. The controller is mainly involved in that. 144 | rocket.update() 145 | 146 | # Get the cube rotation value at the current time (when update() was last called) 147 | cube_rot = rocket.value("cube:rotation") 148 | 149 | # Get the cube size by accessing the track directly (using second) 150 | # This can be the value from your own timer as well 151 | cube_size = size_track.time_value(rocket.time) 152 | 153 | # Get the cube size by accessing the track directly (using track time) 154 | # This can be the value from your own timer as well 155 | cube_size = size_track.track_value(rocket.track) 156 | 157 | # Emulate 60 fps 158 | time.sleep(1.0 / 1000 * 16) 159 | 160 | Track Names 161 | =========== 162 | 163 | The standard rocket editor support track names using utf-8, but this is not a 100% 164 | guarantee that other track editors also support this. 165 | 166 | Some editors such as `Rocket OpenGL editor `_ 167 | support track grouping. Grouping is done by adding a prefix in the track name 168 | followed by a colon. 169 | 170 | Example: 171 | :: 172 | 173 | cube:rot.x 174 | cube:rot.y 175 | cube:rot.z 176 | 177 | monkey:rot.x 178 | monkey:rot.y 179 | monkey:rot.z 180 | 181 | The uniqueness of the track is based on the entire name, so you can re-use 182 | the same name across different groups. 183 | 184 | Track names (after colon) should ideally be as short as possible. 12 characters is 185 | a good limit as editors either cut off the name or expand the column width with 186 | larger names. It's common to use dot as a separator in track names as well, but 187 | this is not enforced as far as we know. 188 | 189 | When tracks are serialized into binary format the colon is replaced with #. 190 | ``cube:rot.x`` track is save in the file ``cube#rot.x.track``. 191 | 192 | Logging 193 | ======= 194 | 195 | The default log level of the client is ``ERROR``. 196 | 197 | You can override the log level when initializing rocket: 198 | 199 | .. code:: python 200 | 201 | import logging 202 | 203 | rocket = Rocket.from_socket(controller, track_path="./data", log_level=logging.INFO) 204 | rocket = Rocket.from_project_file(controller, 'example.xml', log_level=logging.INFO) 205 | rocket = Rocket.from_files(controller, './data', log_level=logging.INFO) 206 | 207 | When adding custom controllers you can emit to the rocket logger: 208 | 209 | .. code:: python 210 | 211 | import logging 212 | from rocket.controllers import Controller 213 | 214 | logger = logging.getLogger("rocket") 215 | 216 | class MyController(Controller): 217 | def __init__(self, rows_per_second): 218 | logger.info("Hello, Rocket!") 219 | 220 | Format 221 | ====== 222 | 223 | Interpolation enum: 224 | 225 | .. code:: python 226 | 227 | STEP = 0 228 | LINEAR = 1 229 | SMOOTH = 2 230 | RAMP = 3 231 | 232 | The xml format is very simple. The example below shows three tracks containing a few keyframes. 233 | 234 | .. code:: xml 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | The binary format is also fairly straight forward. Each track is written to 255 | a separate file. These files should ideally be separated into their own directory. 256 | The file name is ``.track``. 257 | 258 | The track names above would be: 259 | 260 | .. code:: 261 | 262 | tracks/camera#fov.track 263 | tracks/camera#head.track 264 | tracks/camera#pitch.track 265 | 266 | The format of each track file is (all big endian): 267 | 268 | .. code:: 269 | 270 | int: number of keyframes 271 | for number of keyframes 272 | int: row 273 | float32: value 274 | byte: interpolation type 275 | 276 | .. |editor| image:: https://raw.githubusercontent.com/Contraz/pyrocket/master/editor.png 277 | .. |pypi| image:: https://img.shields.io/pypi/v/pyrocket.svg 278 | :target: https://pypi.python.org/pypi/pyrocket 279 | .. |travis| image:: https://travis-ci.org/Contraz/pyrocket.svg?branch=master 280 | :target: https://travis-ci.org/Contraz/pyrocket 281 | .. _demosys-py: https://github.com/Contraz/demosys-py 282 | -------------------------------------------------------------------------------- /editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Contraz/pyrocket/bc1129ba30b32a3324f8416a698f9d93555f9e35/editor.png -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from rocket.connectors import SocketConnError 4 | from rocket import Rocket 5 | from rocket.controllers import TimeController 6 | 7 | 8 | def main(): 9 | controller = TimeController(24) 10 | rocket = Rocket.from_socket(controller, track_path="./data", log_level=logging.ERROR) 11 | # rocket = Rocket.from_project_file(controller, 'example.xml') 12 | # rocket = Rocket.from_files(controller, './data') 13 | 14 | rocket.track("underwater:cam.x") 15 | rocket.track("underwater:cam.y") 16 | t1 = rocket.track("cube:size") 17 | t2 = rocket.track("cube:zoom") 18 | 19 | rocket.start() 20 | 21 | # Fake draw loop 22 | frame = 0 23 | while True: 24 | try: 25 | rocket.update() 26 | except SocketConnError: 27 | print("Editor probably closed..") 28 | break 29 | 30 | # Get track values from rocket 31 | v1 = rocket.value("underwater:cam.x") 32 | v2 = rocket.value("underwater:cam.y") 33 | # Get track values from track 34 | v3 = t1.row_value(rocket.row) 35 | v4 = t2.row_value(rocket.row) 36 | 37 | time.sleep(1.0 / 1000 * 16) 38 | frame += 1 39 | 40 | if frame % 60 == 0: 41 | print("frame", frame, "time", rocket.time, "row", rocket.row, "playing", controller.playing) 42 | print("values", v1, v2, v3, v4) 43 | 44 | 45 | if __name__ == '__main__': 46 | main() 47 | -------------------------------------------------------------------------------- /example.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /rocket/__init__.py: -------------------------------------------------------------------------------- 1 | from .rocket import Rocket # noqa 2 | -------------------------------------------------------------------------------- /rocket/connectors/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Connector # noqa 2 | from .socket import SocketConnector, SocketConnError # noqa 3 | from .project import ProjectFileConnector # noqa 4 | from .files import FilesConnector # noqa 5 | -------------------------------------------------------------------------------- /rocket/connectors/base.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class Connector: 4 | 5 | def update(self): 6 | pass 7 | 8 | def track_added(self, name): 9 | pass 10 | 11 | def controller_row_changed(self, row): 12 | pass 13 | -------------------------------------------------------------------------------- /rocket/connectors/files.py: -------------------------------------------------------------------------------- 1 | """ 2 | Connector reading track files in binary format. 3 | Each track is a separate file. 4 | """ 5 | import logging 6 | import os 7 | from .base import Connector 8 | from rocket.tracks import Track 9 | 10 | logger = logging.getLogger("rocket") 11 | 12 | 13 | class FilesConnector(Connector): 14 | """Loads individual track files in a specific path""" 15 | def __init__(self, track_path, controller=None, tracks=None): 16 | """ 17 | Load binary track files 18 | :param path: Path to track directory 19 | :param controller: The controller 20 | :param tracks: Track container 21 | """ 22 | logger.info("Initialize loading binary track data") 23 | self.controller = controller 24 | self.tracks = tracks 25 | self.path = track_path 26 | 27 | self.controller.connector = self 28 | self.tracks.connector = self 29 | 30 | if self.path is None: 31 | raise ValueError("track path is None") 32 | if not os.path.exists(self.path): 33 | raise ValueError("Track directory do not exist: {}".format(self.path)) 34 | 35 | logger.info("Looking for track files in '%s'", self.path) 36 | for f in os.listdir(self.path): 37 | if not f.endswith(".track"): 38 | continue 39 | name = Track.trackname(f) 40 | logger.info("Attempting to load ''", name) 41 | t = self.tracks.get_or_create(name) 42 | t.load(os.path.join(self.path, f)) 43 | -------------------------------------------------------------------------------- /rocket/connectors/project.py: -------------------------------------------------------------------------------- 1 | """ 2 | Connector reading tracks from the track editor xml file. 3 | """ 4 | import logging 5 | import re 6 | from xml.etree import ElementTree 7 | from .base import Connector 8 | 9 | logger = logging.getLogger("rocket") 10 | 11 | 12 | class ProjectFileConnector(Connector): 13 | """Reads editor project xml file""" 14 | def __init__(self, project_file, controller=None, tracks=None): 15 | logger.info("Initializing project file loader") 16 | self.controller = controller 17 | self.tracks = tracks 18 | self.controller.connector = self 19 | self.tracks.connector = self 20 | 21 | logger.info("Attempting to load '%s'", project_file) 22 | 23 | with open(project_file, 'r') as fd: 24 | xml = fd.read() 25 | # Hack in a root node as ET expects only one root node 26 | root = ElementTree.fromstring(re.sub(r"(<\?xml[^>]+\?>)", r"\1", xml) + "") 27 | 28 | # TODO: Consider using root attributes 29 | # root.attrib {'rows': '10000', 'startRow': '0', 'endRow': '10000', 'highlightRowStep': '8'} 30 | 31 | for track in root.iter('track'): 32 | t = self.tracks.get_or_create(track.attrib['name']) 33 | for key in track: 34 | t.add_or_update( 35 | int(key.attrib['row']), 36 | float(key.attrib['value']), 37 | int(key.attrib['interpolation'])) 38 | self.tracks.add(t) 39 | -------------------------------------------------------------------------------- /rocket/connectors/socket.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | import select 4 | import struct 5 | from .base import Connector 6 | 7 | logger = logging.getLogger("rocket") 8 | 9 | CLIENT_GREET = "hello, synctracker!" 10 | SERVER_GREET = "hello, demo!" 11 | 12 | SYNC_DEFAULT_PORT = 1338 13 | 14 | SET_KEY = 0 15 | DELETE_KEY = 1 16 | GET_TRACK = 2 17 | SET_ROW = 3 18 | PAUSE = 4 19 | SAVE_TRACKS = 5 20 | 21 | 22 | class SocketConnError(Exception): 23 | """Custom exception for detecting connection drop""" 24 | pass 25 | 26 | 27 | class SocketConnector(Connector): 28 | """Connection to the rocket editor/server""" 29 | def __init__(self, host=None, port=None, controller=None, tracks=None): 30 | logger.info("Initializing socket connector") 31 | self.controller = controller 32 | self.tracks = tracks 33 | self.controller.connector = self 34 | self.tracks.connector = self 35 | 36 | self.host = host or "127.0.0.1" 37 | self.port = port or SYNC_DEFAULT_PORT 38 | 39 | self.socket = None 40 | self.reader = None 41 | self.writer = None 42 | 43 | self.init_socket() 44 | self.greet_server() 45 | 46 | def init_socket(self): 47 | logger.info("Attempting to connect to %s:%s", self.host, self.port) 48 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 49 | self.socket.setblocking(True) 50 | self.socket.connect((self.host, self.port)) 51 | 52 | logger.info("Connected to rocket server.") 53 | self.reader = BinaryReader(self.socket) 54 | self.writer = BinaryWriter(self.socket) 55 | 56 | def greet_server(self): 57 | logger.info("Greeting server with: %s", CLIENT_GREET) 58 | self.writer.string(CLIENT_GREET) 59 | greet = self.reader.bytes(len(SERVER_GREET), blocking=True) 60 | 61 | data = greet.decode() 62 | logger.info("Server responded with: %s", data) 63 | 64 | if data != SERVER_GREET: 65 | raise ValueError("Invalid server response: {}".format(data)) 66 | 67 | def track_added(self, name): 68 | self.writer.byte(GET_TRACK) 69 | self.writer.int(len(name)) 70 | self.writer.string(name) 71 | 72 | def update(self): 73 | """Process all queued incoming commands""" 74 | while self.read_command(): 75 | pass 76 | 77 | def controller_row_changed(self, row): 78 | self.writer.byte(SET_ROW) 79 | self.writer.int(int(row)) 80 | logger.info(" <- row: %s", row) 81 | 82 | # # Not all editors support this (breaks compatibility) 83 | # def controller_pause_state(self, state): 84 | # self.writer.byte(PAUSE) 85 | # self.writer.byte(state) 86 | 87 | def read_command(self): 88 | """ 89 | Attempt to read the next command from the editor/server 90 | :return: boolean. Did we actually read a command? 91 | """ 92 | # Do a non-blocking read here so the demo can keep running if there is no data 93 | comm = self.reader.byte(blocking=False) 94 | if comm is None: 95 | return False 96 | 97 | cmds = { 98 | SET_KEY: self.handle_set_key, 99 | DELETE_KEY: self.handle_delete_key, 100 | SET_ROW: self.handle_set_row, 101 | PAUSE: self.handle_pause, 102 | SAVE_TRACKS: self.handle_save_tracks 103 | } 104 | 105 | func = cmds.get(comm) 106 | 107 | if func: 108 | func() 109 | else: 110 | logger.error("Unknown command: %s", comm) 111 | 112 | return True 113 | 114 | def handle_set_key(self): 115 | """Read incoming key from server""" 116 | track_id = self.reader.int() 117 | row = self.reader.int() 118 | value = self.reader.float() 119 | kind = self.reader.byte() 120 | logger.info(" -> track=%s, row=%s, value=%s, type=%s", track_id, row, value, kind) 121 | 122 | # Add or update track value 123 | track = self.tracks.get_by_id(track_id) 124 | track.add_or_update(row, value, kind) 125 | 126 | def handle_delete_key(self): 127 | """Read incoming delete key event from server""" 128 | track_id = self.reader.int() 129 | row = self.reader.int() 130 | logger.info(" -> track=%s, row=%s", track_id, row) 131 | 132 | # Delete the actual track value 133 | track = self.tracks.get_by_id(track_id) 134 | track.delete(row) 135 | 136 | def handle_set_row(self): 137 | """Read incoming row change from server""" 138 | row = self.reader.int() 139 | logger.info(" -> row: %s", row) 140 | self.controller.row = row 141 | 142 | def handle_pause(self): 143 | """Read pause signal from server""" 144 | flag = self.reader.byte() 145 | if flag > 0: 146 | logger.info(" -> pause: on") 147 | self.controller.playing = False 148 | else: 149 | logger.info(" -> pause: off") 150 | self.controller.playing = True 151 | 152 | def handle_save_tracks(self): 153 | logger.info("Remote export") 154 | self.tracks.save() 155 | 156 | 157 | class BinaryReader: 158 | """Helper namespace for reading binary data from a socket""" 159 | def __init__(self, sock): 160 | self.sock = sock 161 | 162 | def bytes(self, n, blocking=True): 163 | return self._read(n, blocking=blocking) 164 | 165 | def byte(self, blocking=True): 166 | data = self._read(1, blocking=blocking) 167 | if data is None: 168 | return None 169 | return int.from_bytes(data, byteorder='big') 170 | 171 | def int(self, blocking=True): 172 | return struct.unpack('>I', self._read(4, blocking=blocking))[0] 173 | 174 | def float(self, blocking=True): 175 | return struct.unpack('>f', self._read(4, blocking=blocking))[0] 176 | 177 | def _read(self, count, blocking=True): 178 | if blocking: 179 | return self.sock.recv(count) 180 | else: 181 | # The select will return a socket only if data is avaialble 182 | rd_sock, wrt_sock, err_sock = select.select([self.sock], [], [], 0) 183 | if not rd_sock: 184 | return None 185 | 186 | return rd_sock[0].recv(count) 187 | 188 | 189 | class BinaryWriter: 190 | """Helper for sending binary data""" 191 | def __init__(self, sock): 192 | self.sock = sock 193 | 194 | def byte(self, value): 195 | self.sock.send(value.to_bytes(1, byteorder='big', signed=False)) 196 | 197 | def bytes(self, data): 198 | self.sock.write(data) 199 | 200 | def string(self, data): 201 | self.sock.send(data.encode()) 202 | 203 | def int(self, value): 204 | self.sock.send(value.to_bytes(4, byteorder='big', signed=False)) 205 | -------------------------------------------------------------------------------- /rocket/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Controller # noqa 2 | from .time import TimeController # noqa 3 | -------------------------------------------------------------------------------- /rocket/controllers/base.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class Controller: 4 | def __init__(self, rows_per_second): 5 | self.rows_per_second = rows_per_second 6 | self._row = 0 7 | self._playing = False 8 | self.connector = None 9 | 10 | @property 11 | def time(self): 12 | return self._row / self.rows_per_second 13 | 14 | @property 15 | def playing(self): 16 | return self._playing 17 | 18 | @playing.setter 19 | def playing(self, value): 20 | # Update other states.. 21 | self._playing = value 22 | 23 | @property 24 | def row(self): 25 | return self._row 26 | 27 | @row.setter 28 | def row(self, value): 29 | self._row = value 30 | self.connector.controller_row_changed(self._row) 31 | 32 | # Not all editors support this (breaks compatibility) 33 | # def pause(self): 34 | # self._playing = not self._playing 35 | # print("Pause:", not self._playing) 36 | # self.connector.controller_pause_state(int(self._playing)) 37 | 38 | def update(self): 39 | pass 40 | -------------------------------------------------------------------------------- /rocket/controllers/time.py: -------------------------------------------------------------------------------- 1 | import time 2 | from .base import Controller 3 | 4 | 5 | class TimeController(Controller): 6 | def __init__(self, rows_per_second): 7 | self.last_meter_point = 0 8 | super().__init__(rows_per_second) 9 | 10 | def update(self): 11 | if not self.playing: 12 | self.last_meter_point = 0 13 | return 14 | 15 | if self.last_meter_point == 0: 16 | self.last_meter_point = time.time() 17 | 18 | meter = time.time() 19 | timespan = meter - self.last_meter_point 20 | self.last_meter_point = meter 21 | self.row = self.row + timespan * self.rows_per_second 22 | -------------------------------------------------------------------------------- /rocket/rocket.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from .connectors import SocketConnector 3 | from .connectors import ProjectFileConnector 4 | from .connectors import FilesConnector 5 | from .tracks import TrackContainer 6 | 7 | logger = logging.getLogger("rocket") 8 | 9 | 10 | class Rocket: 11 | def __init__(self, controller, track_path=None, log_level=logging.ERROR): 12 | """Create rocket instance without a connector""" 13 | # set up logging 14 | sh = logging.StreamHandler() 15 | sh.setLevel(log_level) 16 | formatter = logging.Formatter('%(name)s-%(levelname)s: %(message)s') 17 | sh.setFormatter(formatter) 18 | logger.addHandler(sh) 19 | logger.setLevel(log_level) 20 | 21 | self.controller = controller 22 | self.connector = None 23 | self.tracks = TrackContainer(track_path) 24 | # hack in reference so we can look up tracks_per_second 25 | self.tracks.controller = self.controller 26 | 27 | @staticmethod 28 | def from_files(controller, track_path, log_level=logging.ERROR): 29 | """Create rocket instance using project file connector""" 30 | rocket = Rocket(controller, track_path=track_path, log_level=log_level) 31 | rocket.connector = FilesConnector(track_path, 32 | controller=controller, 33 | tracks=rocket.tracks) 34 | return rocket 35 | 36 | @staticmethod 37 | def from_project_file(controller, project_file, track_path=None, log_level=logging.ERROR): 38 | """Create rocket instance using project file connector""" 39 | rocket = Rocket(controller, track_path=track_path, log_level=log_level) 40 | rocket.connector = ProjectFileConnector(project_file, 41 | controller=controller, 42 | tracks=rocket.tracks) 43 | return rocket 44 | 45 | @staticmethod 46 | def from_socket(controller, host=None, port=None, track_path=None, log_level=logging.ERROR): 47 | """Create rocket instance using socket connector""" 48 | rocket = Rocket(controller, track_path=track_path, log_level=log_level) 49 | rocket.connector = SocketConnector(controller=controller, 50 | tracks=rocket.tracks, 51 | host=host, 52 | port=port) 53 | return rocket 54 | 55 | @property 56 | def time(self): 57 | return self.controller.time 58 | 59 | @property 60 | def row(self): 61 | return self.controller.row 62 | 63 | def start(self): 64 | self.controller.playing = True 65 | 66 | # Not all editors support this (breaks compatibility) 67 | # def pause(self): 68 | # self.controller.pause() 69 | 70 | def update(self): 71 | self.controller.update() 72 | self.connector.update() 73 | 74 | def value(self, name): 75 | """get value of a track at the current time""" 76 | return self.tracks.get(name).row_value(self.controller.row) 77 | 78 | def int_value(self, name): 79 | return int(self.value(name)) 80 | 81 | def track(self, name): 82 | return self.tracks.get_or_create(name) 83 | -------------------------------------------------------------------------------- /rocket/tracks.py: -------------------------------------------------------------------------------- 1 | import bisect 2 | import logging 3 | import math 4 | import os 5 | import struct 6 | 7 | STEP = 0 8 | LINEAR = 1 9 | SMOOTH = 2 10 | RAMP = 3 11 | 12 | logger = logging.getLogger("rocket") 13 | 14 | 15 | class TrackContainer: 16 | """Keep track of tacks by their name and index""" 17 | def __init__(self, track_path): 18 | self.tracks = {} 19 | self.track_index = [] 20 | self.connector = None 21 | self.controller = None 22 | self.track_path = track_path 23 | 24 | def get(self, name): 25 | return self.tracks[name] 26 | 27 | def get_by_id(self, i): 28 | return self.track_index[i] 29 | 30 | def get_or_create(self, name): 31 | t = self.tracks.get(name) 32 | if not t: 33 | t = Track(name) 34 | self.add(t) 35 | 36 | self.connector.track_added(name) 37 | return t 38 | 39 | def add(self, obj): 40 | """ 41 | Add pre-created tracks. 42 | If the tracks are already created, we hijack the data. 43 | This way the pointer to the pre-created tracks are still valid. 44 | """ 45 | obj.controller = self.controller 46 | # Is the track already loaded or created? 47 | track = self.tracks.get(obj.name) 48 | if track: 49 | if track == obj: 50 | return 51 | # hijack the track data 52 | obj.keys = track.keys 53 | obj.controller = track.controller 54 | self.tracks[track.name] = obj 55 | self.track_index[self.track_index.index(track)] = obj 56 | else: 57 | # Add a new track 58 | obj.controller = self.controller 59 | self.tracks[obj.name] = obj 60 | self.track_index.append(obj) 61 | 62 | def save(self): 63 | logger.info("Saving tracks to: %s", self.track_path) 64 | # Check if the path is valid 65 | if self.track_path is None: 66 | logger.error("Track path is None") 67 | return 68 | 69 | if not os.path.exists(self.track_path): 70 | logger.error("FAILED: Path '%s' do not exist", self.track_path) 71 | return 72 | 73 | for t in self.track_index: 74 | t.save(self.track_path) 75 | 76 | 77 | # TODO: Insert and delete operations in keys list is expensive 78 | class Track: 79 | def __init__(self, name): 80 | self.name = name 81 | self.keys = [] 82 | # Shortcut to controller for tracks_per_second lookups 83 | self.controller = None 84 | 85 | def time_value(self, time): 86 | return self.row_value(time * self.controller.rows_per_second) 87 | 88 | def row_value(self, row): 89 | """Get the tracks value at row""" 90 | irow = int(row) 91 | i = self._get_key_index(irow) 92 | if i == -1: 93 | return 0.0 94 | 95 | # Are we dealing with the last key? 96 | if i == len(self.keys) - 1: 97 | return self.keys[-1].value 98 | 99 | return TrackKey.interpolate(self.keys[i], self.keys[i + 1], row) 100 | 101 | def add_or_update(self, row, value, kind): 102 | """Add or update a track value""" 103 | i = bisect.bisect_left(self.keys, row) 104 | 105 | # Are we simply replacing a key? 106 | if i < len(self.keys) and self.keys[i].row == row: 107 | self.keys[i].update(value, kind) 108 | else: 109 | self.keys.insert(i, TrackKey(row, value, kind)) 110 | 111 | def delete(self, row): 112 | """Delete a track value""" 113 | i = self._get_key_index(row) 114 | del self.keys[i] 115 | 116 | def _get_key_index(self, row): 117 | """Get the key that should be used as the first interpolation value""" 118 | # Don't bother with empty tracks 119 | if len(self.keys) == 0: 120 | return -1 121 | 122 | # No track values are defined yet 123 | if row < self.keys[0].row: 124 | return -1 125 | 126 | # Get the insertion index 127 | index = bisect.bisect_left(self.keys, row) 128 | # Index is within the array size? 129 | if index < len(self.keys): 130 | # Are we inside an interval? 131 | if row < self.keys[index].row: 132 | return index - 1 133 | return index 134 | 135 | # Return the last index 136 | return len(self.keys) - 1 137 | 138 | @staticmethod 139 | def filename(name): 140 | """Create a valid file name from track name""" 141 | return "{}{}".format(name.replace(':', '#'), '.track') 142 | 143 | @staticmethod 144 | def trackname(name): 145 | """Create track name from file name""" 146 | return name.replace('#', ':').replace('.track', '') 147 | 148 | def load(self, filepath): 149 | """Load the track file""" 150 | with open(filepath, 'rb') as fd: 151 | num_keys = struct.unpack(">i", fd.read(4))[0] 152 | for i in range(num_keys): 153 | row, value, kind = struct.unpack('>ifb', fd.read(9)) 154 | self.keys.append(TrackKey(row, value, kind)) 155 | 156 | def save(self, path): 157 | """Save the track""" 158 | name = Track.filename(self.name) 159 | with open(os.path.join(path, name), 'wb') as fd: 160 | fd.write(struct.pack('>I', len(self.keys))) 161 | for k in self.keys: 162 | fd.write(struct.pack('>ifb', k.row, k.value, k.kind)) 163 | 164 | def print_keys(self): 165 | for k in self.keys: 166 | logger.info(k) 167 | 168 | 169 | class TrackKey: 170 | def __init__(self, row, value, kind): 171 | self.row = row 172 | self.value = value 173 | self.kind = kind 174 | 175 | def update(self, value, kind): 176 | self.value = value 177 | self.kind = kind 178 | 179 | @staticmethod 180 | def interpolate(first, second, row): 181 | t = (row - first.row) / (second.row - first.row) 182 | 183 | if first.kind == STEP: 184 | return first.value 185 | elif first.kind == SMOOTH: 186 | t = t * t * (3 - 2 * t) 187 | elif first.kind == RAMP: 188 | t = math.pow(t, 2.0) 189 | 190 | return first.value + (second.value - first.value) * t 191 | 192 | def __lt__(self, other): 193 | if isinstance(other, int): 194 | return self.row < other 195 | else: 196 | return self.row < other.row 197 | 198 | def __ge__(self, other): 199 | if isinstance(other, int): 200 | return self.row > other 201 | else: 202 | return self.row > other.row 203 | 204 | def __repr__(self): 205 | return "TrackKey(row={} value={} type={})".format(self.row, self.value, self.kind) 206 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name="pyrocket", 6 | version="0.2.9", 7 | description="Rocket sync-tracker client", 8 | long_description=open('README.rst').read(), 9 | url="https://github.com/Contraz/pyrocket", 10 | author="Einar Forselv", 11 | author_email="eforselv@gmail.com", 12 | maintainer="Einar Forselv", 13 | maintainer_email="eforselv@gmail.com", 14 | include_package_data=True, 15 | keywords=['synchronizing', 'music', 'rocket'], 16 | packages=['rocket'], 17 | classifiers=[ 18 | 'Programming Language :: Python', 19 | 'Intended Audience :: Developers', 20 | 'Topic :: Multimedia :: Graphics', 21 | 'License :: OSI Approved :: zlib/libpng License', 22 | 'Programming Language :: Python :: 3', 23 | 'Programming Language :: Python :: 3.6', 24 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 25 | ], 26 | ) 27 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Ensure that this file do not contain non-ascii characters 2 | # as flake8 can fail to parse the file on OS X and Windows 3 | 4 | [tox] 5 | skipsdist = True 6 | envlist = 7 | pep8 8 | 9 | [testenv:pep8] 10 | usedevelop = false 11 | deps = flake8 12 | basepython = python3.6 13 | commands = flake8 14 | 15 | [pytest] 16 | norecursedirs = env/* .tox/* build/* dist/* 17 | 18 | [flake8] 19 | # H405: multi line docstring summary not separated with an empty line 20 | # D100: Missing docstring in public module 21 | # D101: Missing docstring in public class 22 | # D102: Missing docstring in public method 23 | # D103: Missing docstring in public function 24 | # D104: Missing docstring in public package 25 | # D105: Missing docstring in magic method 26 | # D200: One-line docstring should fit on one line with quotes 27 | # D202: No blank lines allowed after function docstring 28 | # D203: 1 blank required before class docstring. 29 | # D204: 1 blank required after class docstring 30 | # D205: Blank line required between one-line summary and description. 31 | # D207: Docstring is under-indented 32 | # D208: Docstring is over-indented 33 | # D211: No blank lines allowed before class docstring 34 | # D301: Use r""" if any backslashes in a docstring 35 | # D400: First line should end with a period. 36 | # D401: First line should be in imperative mood. 37 | # *** E302 expected 2 blank lines, found 1 38 | # *** W503 line break before binary operator 39 | ignore = H405,D100,D101,D102,D103,D104,D105,D200,D202,D203,D204,D205,D207,D208,D211,D301,D400,D401,W503 40 | show-source = True 41 | max-line-length = 120 42 | exclude = .tox,env,setup.py,build 43 | --------------------------------------------------------------------------------