Skip to content

Instantly share code, notes, and snippets.

@lennart
Last active June 24, 2019 19:11
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save lennart/afc7b9d145d1d1bc09e7 to your computer and use it in GitHub Desktop.
Splended, a simple Sampler Builder based on Blender… It is, though, just a script.

splended

Build musical samplers with blender, splended helps you!

Installation

OS X

put splended.py into ~/Application Support/Blender/2.xx/scripts/

Usage

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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment