Build musical samplers with blender, splended helps you!
put splended.py into ~/Application Support/Blender/2.xx/scripts/
in the Video Sequencer when you've added sound strips, you can now shrink the gaps between two selected tracks!
| #!/bin/sh | |
| os=`uname -s` | |
| case $os in | |
| Darwin ) | |
| root="$HOME/Library/Application Support/Blender/" | |
| ;; | |
| Linux ) | |
| root="$HOME/.config/blender/" | |
| ;; | |
| esac | |
| latest=`ls -r "$root" | head -n 1` | |
| scripts="${root}/${latest}/scripts/startup" | |
| mkdir -vp "${scripts}" | |
| cp -v ./splended.py "${scripts}/" |
| import bpy | |
| import cmd | |
| import os | |
| import subprocess | |
| #### BLENDER #### | |
| def has_sequencer(context): | |
| seq_types = ['SEQUENCER', 'SEQUENCER_PREVIEW'] | |
| return context.space_data.view_type in seq_types | |
| def get_first(iterable, default=None): | |
| if iterable: | |
| for item in iterable: | |
| return item | |
| return default | |
| def has_sampler_and_sequencer(context): | |
| return has_sequencer(context) and has_sequences(context) | |
| def has_sequences(context): | |
| return context.sequences is not None and len(context.sequences) > 0 | |
| def update_frame_region(context): | |
| # Set Region to span all Sequences | |
| context.scene.frame_start = 0 | |
| if has_sequences(context): | |
| sampler = Sampler() | |
| sampler.fill(context.sequences) | |
| context.scene.frame_end = sampler.last(context.sequences).frame_final_end | |
| def deselect_all(): | |
| bpy.ops.sequencer.select_all(action="DESELECT") | |
| def select_all(): | |
| bpy.ops.sequencer.select_all(action="SELECT") | |
| def select(sequences): | |
| for seq in sequences: | |
| seq.select = True | |
| def make_meta(): | |
| bpy.ops.sequencer.meta_make() | |
| def break_meta(): | |
| bpy.ops.sequencer.meta_separate() | |
| def convert(source, target): | |
| subprocess.check_call(["/usr/local/bin/ffmpeg", "-i", source, "-ar", "44100", "-ac", "2", "-sample_fmt", "s16", target]) | |
| return target | |
| class Sampler(): | |
| '''Enhanced access to Sequences (maintains order)''' | |
| def __init__(self, raw_sampler=[]): | |
| '''accepts raw index list of existing sequences''' | |
| self.positions = raw_sampler | |
| def get(self, sequences, position): | |
| '''return SoundSequence at given position''' | |
| return sequences[self.positions[position]] | |
| def slice(self, sequences, start=0, end=None): | |
| '''return all SoundSequences in a given Range, by default all''' | |
| print("slicing", sequences, start) | |
| return [sequences[index] for index in self.positions[start:end]] | |
| def first(self, sequences): | |
| return self.get(sequences, 0) | |
| def last(self, sequences): | |
| return self.get(sequences, -1) | |
| def index(self, sequences, needle): | |
| '''returns the position of a given sequence (needle)''' | |
| return self.positions.index(sequences.index(needle)) | |
| def size(self): | |
| return len(self.positions) | |
| def rest(self, sequences, threshold): | |
| '''returns all SoundSequences after threshold''' | |
| print("threshold", threshold) | |
| start = self.index(sequences, threshold) + 1 | |
| size = self.size() | |
| if start == size: | |
| return [] | |
| else: | |
| return self.slice(sequences, start) | |
| def fill(self, sequences): | |
| '''fills sampler with given sequences in order''' | |
| original = [self._wrap(seq, i) for i, seq in enumerate(sequences)] | |
| original.sort(key=self._by_start_frame) | |
| self.positions = [seq["index"] for seq in original] | |
| def rotate(self, sequences): | |
| '''rotates the sequences, moving the first one to the back''' | |
| # print("rotating %d sequences", len(sequences), sequences) | |
| first = self.first(sequences) | |
| offset = 0 | |
| remaining = self.slice(sequences, 1) | |
| for seq in remaining: | |
| seq.frame_start -= first.frame_final_duration | |
| print("moving first to last", remaining[-1]) | |
| first.frame_start = remaining[-1].frame_final_end | |
| def distribute(self, sequences): | |
| '''Distribute all Sequences across Timeline, fill gaps, remove overlaps''' | |
| offset = 0 | |
| for position in range(len(self.positions)): | |
| seq = self.get(sequences, position) | |
| seq.frame_start = offset | |
| offset = seq.frame_final_end | |
| def align_channels(self, sequences): | |
| even = True | |
| for pos in range(len(self.positions)): | |
| seq = self.get(sequences, pos) | |
| if even: | |
| seq.channel = 1 | |
| even = False | |
| else: | |
| seq.channel = 2 | |
| even = True | |
| def to_list(self): | |
| '''convert Sampler to plain array of Sequence indices''' | |
| return self.positions | |
| def _by_start_frame(self, seq): | |
| return seq["start"] | |
| def _wrap(self, sequence, index): | |
| '''convert and return SoundSequence to useful Dict with index''' | |
| return {"start": sequence.frame_start, "index": index} | |
| class SamplerPanel(): | |
| bl_space_type = "SEQUENCE_EDITOR" | |
| bl_region_type = "UI" | |
| @classmethod | |
| def poll(cls, context): | |
| return has_sequencer(context) | |
| class OP_SAMPLER_read_playlist(bpy.types.Operator): | |
| '''Read a Playlist as Sampler''' | |
| bl_label = "Read Playlist" | |
| bl_idname = "sampler.read_playlist" | |
| filepath = bpy.props.StringProperty(subtype="FILE_PATH") | |
| def execute(self, context): | |
| print("Reading playlist from %s" % self.filepath) | |
| lines = open(self.filepath, encoding='utf-8').read().splitlines() | |
| current_meta = None | |
| current_frame = None | |
| for line in lines: | |
| if line.startswith("#EXTM3U"): | |
| pass | |
| elif line.startswith("#EXTINF"): | |
| current_meta = line.replace("#EXTINF:", "").split(",").pop() | |
| else: | |
| bpy.ops.sequencer.sound_strip_add(filepath=line) | |
| addition = get_first(context.selected_sequences) | |
| if current_frame is not None: | |
| addition.frame_start = current_frame | |
| addition.show_waveform = True | |
| addition.name = current_meta | |
| current_frame = addition.frame_final_end | |
| update_frame_region(context) | |
| select_all() | |
| bpy.ops.sequencer.view_all() | |
| return {'FINISHED'} | |
| def invoke(self, context, event): | |
| context.window_manager.fileselect_add(self) | |
| return {'RUNNING_MODAL'} | |
| class OP_SAMPLER_adjust_gain(bpy.types.Operator): | |
| '''Adjust gain for all Tracks''' | |
| bl_label = "Adjust Gain" | |
| bl_idname = "sampler.adjust_gain" | |
| @classmethod | |
| def poll(cls, context): | |
| return has_sequencer(context) and has_sequences(context) | |
| def invoke(self, context, event): | |
| files = [] | |
| for seq in context.sequences: | |
| original = bpy.path.abspath(seq.filepath) | |
| (root, ext) = os.path.splitext(original) | |
| if ext == "flac": | |
| print("FLAC file, skipping conversion/swap '%s'" % original) | |
| files.append(original) | |
| else: | |
| flac = ".".join([root, "flac"]) | |
| if not os.path.exists(flac): | |
| print("FLAC needed: '%s'" % seq.filepath) | |
| convert(original, flac) | |
| deselect_all() | |
| bpy.ops.sequencer.sound_strip_add(filepath=flac) | |
| flac_seq = get_first(context.selected_sequences) | |
| flac_seq.frame_start = seq.frame_start | |
| flac_seq.frame_final_end = seq.frame_final_end | |
| deselect_all() | |
| seq.select = True | |
| bpy.ops.sequencer.delete() | |
| files.append(flac) | |
| cmd = ["/usr/local/bin/metaflac", "--add-replay-gain"] | |
| cmd += files | |
| print(",".join(cmd)) | |
| subprocess.call(cmd) | |
| for seq in context.sequences: | |
| read_gain_cmd = ["/usr/local/bin/metaflac", "--list", "--block-type=VORBIS_COMMENT", bpy.path.abspath(seq.filepath)] | |
| grep_gain_cmd = ["grep", "REPLAYGAIN_TRACK_GAIN"] | |
| cut_gain_cmd = ["cut", "-f", "2", "-d", "="] | |
| cut_db_cmd = ["cut", "-f", "1", "-d", " "] | |
| read_gain = subprocess.Popen(read_gain_cmd, stdout=subprocess.PIPE) | |
| grep_gain = subprocess.Popen(grep_gain_cmd, stdin=read_gain.stdout, stdout=subprocess.PIPE) | |
| cut_gain = subprocess.Popen(cut_gain_cmd, stdin=grep_gain.stdout, stdout=subprocess.PIPE) | |
| cut_db = subprocess.Popen(cut_db_cmd, stdin=cut_gain.stdout, stdout=subprocess.PIPE) | |
| read_gain.stdout.close() | |
| grep_gain.stdout.close() | |
| cut_gain.stdout.close() | |
| gain, err = cut_db.communicate() | |
| if err is None: | |
| print(gain.strip()) | |
| seq.volume = pow(10, (float(gain.strip())/20.0)) | |
| else: | |
| raise "Error on pipe command" % err | |
| return {'FINISHED'} | |
| # Global Operators for Sequencer | |
| class OP_SAMPLER_mixdown(bpy.types.Operator): | |
| '''Mixdown Sampler to File''' | |
| bl_label = "Mixdown Sampler" | |
| bl_idname = "sampler.mixdown" | |
| filepath = bpy.props.StringProperty(subtype="FILE_PATH") | |
| # FIXME: This isn't read, whysoever | |
| filename = bpy.props.StringProperty(subtype="FILE_NAME", default="Sampler.flac") | |
| @classmethod | |
| def poll(cls, context): | |
| return (context.scene.sequence_editor is not None) and has_sequences(context) | |
| def execute(self, context): | |
| bpy.ops.sound.mixdown(filepath=self.filepath) | |
| return {'FINISHED'} | |
| def invoke(self, context, event): | |
| update_frame_region(context) | |
| context.window_manager.fileselect_add(self) | |
| return {'RUNNING_MODAL'} | |
| class OP_SAMPLER_create(bpy.types.Operator): | |
| '''Create a new sampler from all Sequences''' | |
| bl_label = "Create Sampler" | |
| bl_idname = "sampler.create" | |
| sampler = [] | |
| @classmethod | |
| def poll(cls, context): | |
| return has_sequencer(context) and (context.sequences is not None) and (len(context.sequences) is not 0) | |
| def execute(self, context): | |
| self.sampler = Sampler() | |
| self.sampler.fill(context.sequences) | |
| self.sampler.distribute(context.sequences) | |
| context.scene["Sampler"] = self.sampler.to_list() | |
| return {'FINISHED'} | |
| class OP_SAMPLER_destroy(bpy.types.Operator): | |
| '''Break up an existing Sampler''' | |
| bl_label = "Break up Sampler" | |
| bl_idname = "sampler.destroy" | |
| @classmethod | |
| def poll(cls, context): | |
| return has_sequencer(context) and ("Sampler" in context.scene) | |
| def execute(self, context): | |
| context.scene["Sampler"] = [] | |
| return {'FINISHED'} | |
| # Pair Operators available if two adjacent Sequences are selected | |
| class SamplerPairOperator(): | |
| def selected(self, context): | |
| return context.selected_sequences | |
| def first(self, context): | |
| sampler = Sampler() | |
| selection = self.selected(context) | |
| sampler.fill(self.selected(context)) | |
| return sampler.first(selection) | |
| def last(self, context): | |
| sampler = Sampler() | |
| selection = self.selected(context) | |
| sampler.fill(selection) | |
| return sampler.last(selection) | |
| # get(1) | |
| def rest(self, context): | |
| '''Returns all sequences after the first selected''' | |
| sampler = Sampler() | |
| sampler.fill(context.sequences) | |
| return sampler.rest(context.sequences, self.first(context)) | |
| def has_two_selected(context): | |
| return has_sampler_and_sequencer(context) and len(context.selected_sequences) == 2 | |
| class OP_SAMPLER_scope(bpy.types.Operator): | |
| bl_label = "Scope" | |
| bl_idname = "sampler.scope" | |
| @classmethod | |
| def poll(cls, context): | |
| return True | |
| def execute(self, context): | |
| print(context.region.type) | |
| return {'FINISHED'} | |
| class OP_SAMPLER_shrink(SamplerPairOperator, bpy.types.Operator): | |
| ''' | |
| Shrink (overlap) the selected Sequences by moving them (and all following) | |
| nearer to each other | |
| ''' | |
| bl_label = "Shrink Gap" | |
| bl_idname = "sampler.shrink" | |
| jump = False | |
| @classmethod | |
| def poll(cls, context): | |
| return cls.has_two_selected(context) | |
| def __init__(self): | |
| print("Shrink Start") | |
| def __del__(self): | |
| print("Shrink End") | |
| def execute(self, context): | |
| # OPTIMIZE: speed this up by first grouping all moved strips into one | |
| if self.jump: | |
| self.movable.frame_start = self.jump | |
| self.jump = False | |
| else: | |
| self.movable.frame_start += self.delta | |
| return {'FINISHED'} | |
| def modal(self, context, event): | |
| if event.type == 'SPACE' and event.value == 'PRESS': | |
| print("Jumping to %d, value: %s" % (bpy.context.scene.frame_current, event.value)) | |
| self.jump = bpy.context.scene.frame_current | |
| self.execute(context) | |
| elif event.type == 'MOUSEMOVE': | |
| print("x:%d, x_prev:%d, x_reg: %d" % (event.mouse_x, event.mouse_prev_x, event.mouse_region_x)) | |
| factor = 2 | |
| if event.shift: | |
| factor = 0.25 | |
| self.delta = (event.mouse_x - self.mouse_prev_x) * factor | |
| self.mouse_prev_x = event.mouse_x | |
| if event.alt: | |
| self._preview() | |
| else: | |
| self._cancel_preview() | |
| self.execute(context) | |
| elif event.type == 'WHEELUPMOUSE': | |
| print("mousewheel up") | |
| self._update_preview_range(context, delta=10) | |
| elif event.type == 'WHEELDOWNMOUSE': | |
| print("mousewheel down") | |
| self._update_preview_range(context, delta=-10) | |
| elif event.type == 'TRACKPADPAN': | |
| self._update_preview_range(context, delta=event.mouse_y - event.mouse_prev_y) | |
| elif event.type == 'LEFTMOUSE': | |
| self._unmeta(context) | |
| return {'FINISHED'} | |
| elif event.type in {'LEFT_ALT', 'RIGHT_ALT'}: | |
| if event.value == 'RELEASE': | |
| self._cancel_preview() | |
| print("release x:%d, x_prev:%d, x_reg: %d" % (event.mouse_x, event.mouse_prev_x, event.mouse_region_x)) | |
| elif event.value == 'PRESS': | |
| self._preview() | |
| elif event.type in {'RIGHTMOUSE', 'ESC'}: | |
| self.movable.frame_start = self.movable_origin | |
| self._unmeta(context) | |
| return {'CANCELLED'} | |
| elif event.type == 'TAB' and event.value == 'RELEASE': | |
| self._rotate(context) | |
| return {'RUNNING_MODAL'} | |
| def invoke(self, context, event): | |
| self.origin = event.mouse_x | |
| self.delta = 0 | |
| self.mouse_prev_x = self.origin | |
| self.original_selection = self.selected(context) | |
| self.preview_range = 500 | |
| self.preview_origin = self.first(context).frame_final_end | |
| update_frame_region(context) | |
| self._meta(context) | |
| self._update_preview_range(context) | |
| self.execute(context) | |
| context.window_manager.modal_handler_add(self) | |
| return {'RUNNING_MODAL'} | |
| def _rotate(self, context): | |
| break_meta() | |
| sampler = Sampler() | |
| sampler.fill(context.selected_sequences) | |
| sampler.rotate(context.selected_sequences) | |
| make_meta() | |
| self.movable = context.selected_sequences[0] | |
| def _meta(self, context): | |
| context.scene.use_preview_range = True | |
| movable_sequences = self.rest(context) | |
| deselect_all() | |
| select(movable_sequences) | |
| make_meta() | |
| self.movable = context.selected_sequences[0] | |
| self.movable_origin = self.movable.frame_start | |
| def _unmeta(self, context): | |
| context.scene.use_preview_range = False | |
| break_meta() | |
| deselect_all() | |
| select(self.original_selection) | |
| sampler = Sampler() | |
| sampler.fill(context.sequences) | |
| sampler.align_channels(context.sequences) | |
| def _playing(self): | |
| return bpy.context.screen.is_animation_playing | |
| def _preview(self): | |
| if not self._playing(): | |
| bpy.ops.screen.frame_jump(end=False) | |
| bpy.ops.screen.animation_play() | |
| def _cancel_preview(self): | |
| if self._playing(): | |
| bpy.ops.screen.animation_cancel() | |
| def _update_preview_range(self, context, **args): | |
| if "delta" in args: | |
| self.preview_range += args["delta"] | |
| origin = self.preview_origin | |
| context.scene.frame_preview_start = origin - self.preview_range | |
| context.scene.frame_preview_end = origin + self.preview_range | |
| class SEQUENCER_PT_sampler(SamplerPanel, bpy.types.Panel): | |
| """Creates a Panel in the Object properties window""" | |
| bl_label = "Sampler" | |
| sampler = None | |
| def draw(self, context): | |
| layout = self.layout | |
| sequences = context.sequences | |
| # row = layout.row() | |
| # if has_sampler(context): | |
| # row.operator("sampler.destroy", text="Break up Sampler") | |
| # else: | |
| # row.operator("sampler.create", text="Group as Sampler") | |
| row = layout.row() | |
| print(row.operator("sequencer.view_ghost_border", text="View Ghost")) | |
| row = layout.row() | |
| # row.label(text="Number of Tracks: %s" % len(sequences)) | |
| layout.operator("sampler.mixdown", text="Save Sampler") | |
| layout.operator("sequencer.view_selected", "View Pair") | |
| layout.operator("sampler.shrink", "Shrink Gap") | |
| layout.operator("sampler.adjust_gain", "Adjust Gain") | |
| layout.operator("sampler.read_playlist", "Read Playlist") | |
| def register(): | |
| bpy.utils.register_class(OP_SAMPLER_mixdown) | |
| bpy.utils.register_class(OP_SAMPLER_destroy) | |
| bpy.utils.register_class(OP_SAMPLER_create) | |
| bpy.utils.register_class(OP_SAMPLER_shrink) | |
| bpy.utils.register_class(OP_SAMPLER_scope) | |
| bpy.utils.register_class(OP_SAMPLER_adjust_gain) | |
| bpy.utils.register_class(OP_SAMPLER_read_playlist) | |
| bpy.utils.register_class(SEQUENCER_PT_sampler) | |
| wm = bpy.context.window_manager | |
| km = wm.keyconfigs.addon.keymaps.new('Sequencer', space_type='SEQUENCE_EDITOR', region_type='WINDOW', modal=False) | |
| km.keymap_items.new(OP_SAMPLER_shrink.bl_idname, "S", 'PRESS', oskey=True, alt=True) | |
| km.keymap_items.new("sequencer.view_selected", "P", 'PRESS', oskey=True, alt=True) | |
| # kmi.properties.total = 4 | |
| addon_keymaps.append(km) | |
| def unregister(): | |
| bpy.utils.unregister_class(SEQUENCER_PT_sampler) | |
| bpy.utils.unregister_class(OP_SAMPLER_read_playlist) | |
| bpy.utils.unregister_class(OP_SAMPLER_adjust_gain) | |
| bpy.utils.unregister_class(OP_SAMPLER_scope) | |
| bpy.utils.unregister_class(OP_SAMPLER_shrink) | |
| bpy.utils.unregister_class(OP_SAMPLER_create) | |
| bpy.utils.unregister_class(OP_SAMPLER_destroy) | |
| bpy.utils.unregister_class(OP_SAMPLER_mixdown) | |
| wm = bpy.context.window_manager | |
| for km in addon_keymaps: | |
| wm.keyconfigs.addon.keymaps.remove(km) | |
| addon_keymaps.clear() | |
| bl_info = { | |
| "name": "splended", | |
| "description": "Help building music samplers.", | |
| "author": "Lennart Melzer", | |
| "version": (1, 0), | |
| "blender": (2, 66, 0), | |
| "location": "Sequencer > View", | |
| "warning": "", # used for warning icon and text in addons panel | |
| "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.5/Py/" | |
| "Scripts/My_Script", | |
| "tracker_url": "http://projects.blender.org/tracker/index.php?" | |
| "func=detail&aid=<number>", | |
| "category": "System" | |
| } | |
| addon_keymaps = [] | |
| if __name__ == "__main__": | |
| #bpy.ops.screen.new() | |
| #bpy.context.screen.areas[0].type = "SEQUENCE_EDITOR" | |
| #bpy.ops.screen.screen_full_area() | |
| register() |