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() |