Created
March 10, 2026 07:16
-
-
Save weex/2dcb72f9a8baecad0130b48de35ac778 to your computer and use it in GitHub Desktop.
Updating Banging Cuts for Blender 5, with faster peak detection via numpy, tested for the remove silence feature on one audio and two video channels
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Banging Cuts addon for Blender VSE | |
| # Copyright (C) 2022 Funkster (funkster.org) | |
| # | |
| # This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. | |
| # | |
| # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. | |
| # | |
| # You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/> | |
| bl_info = { | |
| 'name': 'Banging Cuts', | |
| 'author': 'Funkster', | |
| 'version': (0, 9), | |
| 'blender': (4, 0, 0), | |
| 'description': 'Banging Cuts addon for Blender VSE. Chop bits out of your strips in sync with audio peaks!', | |
| 'category': 'Sequencer', | |
| } | |
| DEBUG = False | |
| # how many audio samples to use for debounce | |
| TRIGGER_DEBOUNCE_COUNT_FALLING = 50 | |
| import bpy | |
| import aud | |
| import numpy | |
| def get_sequences(sequence_editor): | |
| """Get all sequences, compatible across Blender versions.""" | |
| sequences = [] | |
| for attr in ('strips', 'sequences', 'strips_all', 'sequences_all'): | |
| if hasattr(sequence_editor, attr): | |
| col = getattr(sequence_editor, attr) | |
| try: | |
| sequences = [s for s in col] | |
| except Exception: | |
| pass | |
| if len(sequences) > 0: | |
| return sequences | |
| return sequences | |
| def remove_sequence(sequence_editor, strip): | |
| """Remove a sequence strip, compatible across Blender versions.""" | |
| for attr in ('strips', 'sequences', 'strips_all', 'sequences_all'): | |
| if hasattr(sequence_editor, attr): | |
| try: | |
| getattr(sequence_editor, attr).remove(strip) | |
| return | |
| except Exception: | |
| pass | |
| def find_edits_numpy(dataarray, startsample, endsample, thresh_rising, thresh_falling, frames_preroll, frames_postroll, actual_fps, strip_samplerate, operation_mode, timeline_start_frame): | |
| """Use numpy to rapidly find threshold crossings and build edit list.""" | |
| edits = [] | |
| # work on the slice we care about | |
| chunk = dataarray[startsample:endsample, 0] | |
| # build a boolean array: True where sample is above threshold (either polarity) | |
| above = (chunk > thresh_rising) | (chunk < -thresh_rising) | |
| # apply a simple debounce for falling edge by using a rolling max over TRIGGER_DEBOUNCE_COUNT_FALLING samples | |
| # use cumsum trick for fast rolling any() | |
| cumsum = numpy.cumsum(above.astype(numpy.int32)) | |
| cumsum = numpy.concatenate(([0], cumsum)) | |
| rolling_sum = cumsum[TRIGGER_DEBOUNCE_COUNT_FALLING:] - cumsum[:-TRIGGER_DEBOUNCE_COUNT_FALLING] | |
| # pad the end to match original length | |
| rolling_sum = numpy.concatenate((rolling_sum, numpy.zeros(TRIGGER_DEBOUNCE_COUNT_FALLING - 1, dtype=numpy.int32))) | |
| debounced = rolling_sum > 0 | |
| # find rising edges (False -> True transitions) and falling edges (True -> False) | |
| padded = numpy.concatenate(([False], debounced, [False])) | |
| rising_edges = numpy.where(~padded[:-1] & padded[1:])[0] | |
| falling_edges = numpy.where(padded[:-1] & ~padded[1:])[0] | |
| preroll_samples = int((frames_preroll / actual_fps) * strip_samplerate) | |
| postroll_samples = int((frames_postroll / actual_fps) * strip_samplerate) | |
| # ensure timeline_start_frame is an int to avoid float contamination | |
| timeline_start_frame = int(timeline_start_frame) | |
| for i in range(len(rising_edges)): | |
| rise = rising_edges[i] | |
| fall = falling_edges[i] if i < len(falling_edges) else len(chunk) | |
| # rise and fall are relative to the chunk (i.e. startsample), so convert | |
| # back to absolute sample index first, then to timeline frames | |
| abs_rise_sample = int(rise) + startsample | |
| abs_fall_sample = int(fall) + startsample | |
| inpoint_sample = max(0, abs_rise_sample - preroll_samples) | |
| outpoint_sample = abs_fall_sample + postroll_samples | |
| # convert from sample index to frame number, then offset by timeline_start_frame | |
| # to correctly handle clips that start before frame 0 | |
| inpoint = int(inpoint_sample / strip_samplerate * actual_fps) + timeline_start_frame | |
| outpoint = int(outpoint_sample / strip_samplerate * actual_fps) + timeline_start_frame | |
| if operation_mode == 'REMSILENCE': | |
| # merge with previous edit if overlapping | |
| if len(edits) > 0 and inpoint <= edits[-1][1]: | |
| edits[-1][1] = outpoint | |
| else: | |
| edits.append([inpoint, outpoint]) | |
| elif operation_mode == 'BANG': | |
| edits.append([inpoint, outpoint]) | |
| elif operation_mode == 'NAIVE': | |
| edits.append([inpoint, outpoint]) | |
| return edits | |
| class BANGING_CUTS_OT_make_cuts(bpy.types.Operator): | |
| bl_description = 'Use peaks above given threshold to isolate sections of selected strips' | |
| bl_idname = 'banging_cuts.make_cuts' | |
| bl_label = 'Make Cuts' | |
| audio_thresh_db: bpy.props.FloatProperty( | |
| name='Trigger threshold dB', | |
| description='audio level threshold for trigger', | |
| default=-15.0, | |
| max=-0.1, | |
| ) | |
| frames_preroll: bpy.props.IntProperty( | |
| name='Preroll frames', | |
| description='how many frames to keep before the trigger', | |
| default=1, | |
| min=0, | |
| ) | |
| frames_postroll: bpy.props.IntProperty( | |
| name='Postroll', # noqa: F821 | |
| description='how many frames to keep after the trigger', | |
| default=5, | |
| min=1, | |
| ) | |
| operation_mode: bpy.props.EnumProperty( | |
| items=( ('BANG', 'Bang (Auto Holdoff)', 'Isolate fixed-length clips at start of sections above threshold, don\'t retrigger'), # noqa: F821 | |
| ('REMSILENCE', 'Remove Silence', 'Isolate variable-length clips where audio remains above threshold'), # noqa: F821 | |
| ('NAIVE', 'Naive', 'Bang but with no auto-holdoff. Maybe never useful.')), # noqa: F821 | |
| name = "Operation Mode", | |
| default='BANG', # noqa: F821 | |
| ) | |
| def invoke(self, context, event): | |
| wm = context.window_manager | |
| return wm.invoke_props_dialog(self) | |
| def execute(self, context): | |
| thresh_rising = pow(10, self.audio_thresh_db / 20.0) | |
| # falling threshold is a little lower than rising, for a little bit of hysteresis | |
| thresh_falling = pow(10, (self.audio_thresh_db - 2) / 20.0) | |
| scene = context.scene | |
| actual_fps = scene.render.fps / scene.render.fps_base | |
| scene_samplerate = scene.render.ffmpeg.audio_mixrate | |
| reference_start = 0 | |
| wm = context.window_manager | |
| if not scene.sequence_editor: | |
| self.report({'WARNING'}, 'No sequence editor found') | |
| return {'CANCELLED'} | |
| se = scene.sequence_editor | |
| if DEBUG: | |
| self.report({'INFO'}, 'sequence_editor type: {}'.format(type(se))) | |
| self.report({'INFO'}, 'sequence_editor ALL attrs: {}'.format([a for a in dir(se) if not a.startswith('__')])) | |
| sequences = get_sequences(se) | |
| if DEBUG: | |
| self.report({'INFO'}, 'Found {} sequences'.format(len(sequences))) | |
| for s in sequences: | |
| self.report({'INFO'}, ' strip: {} type: {} selected: {}'.format(s.name, s.type, s.select)) | |
| if len(sequences) == 0: | |
| self.report({'WARNING'}, 'No sequences found') | |
| return {'CANCELLED'} | |
| soundstrips = [] | |
| for strip in sequences: | |
| if strip.select and strip.type == 'SOUND': | |
| soundstrips.append(strip) | |
| if len(soundstrips) == 0: | |
| self.report({'WARNING'}, 'No sound strips selected') | |
| return {'CANCELLED'} | |
| ref_strip = soundstrips[0] | |
| if len(soundstrips) > 1: | |
| # need to figure out which strip has the highest channel number | |
| for strip in soundstrips: | |
| if strip.channel > ref_strip.channel: | |
| ref_strip = strip | |
| strip_hard_start = ref_strip.frame_start | |
| reference_start = strip_hard_start | |
| start_offset = ref_strip.frame_offset_start | |
| # can't access audio samples directly from a sequencer audio strip, so instead we make a copy as an Aud sound object... | |
| wm.progress_begin(0, 100) | |
| wm.progress_update(0) | |
| audsound = aud.Sound(bpy.path.abspath(ref_strip.sound.filepath)) | |
| cachedsound = audsound.cache() | |
| dataarray = cachedsound.data() | |
| audiochannels = dataarray.shape[1] | |
| numsamples = dataarray.shape[0] | |
| wm.progress_update(5) | |
| # can't get the strip's samplerate directly, so work it out the hard way... | |
| strip_samplerate = numsamples / (ref_strip.frame_duration / actual_fps) | |
| if DEBUG: | |
| self.report({'INFO'}, 'Ref strip has {} channels, {} samples per channel!'.format(audiochannels, numsamples)) | |
| self.report({'INFO'}, 'start_offset is {}, actual_fps is {}, scene_samplerate is {}, strip_samplerate is {}, frame_final_duration is {}'.format(start_offset, actual_fps, scene_samplerate, strip_samplerate, ref_strip.frame_final_duration)) | |
| # only start looking where the ref_strip actually starts | |
| startsample = int((start_offset / actual_fps) * strip_samplerate) | |
| # and only look until where the ref strip ends, even if the audio itself is longer | |
| endsample = startsample + int((ref_strip.frame_final_duration / actual_fps) * strip_samplerate) | |
| # leave room for the first preroll | |
| startsample += int(((self.frames_preroll + 1) / actual_fps) * strip_samplerate) | |
| # add some room at the end so that we always have room for the last post-roll | |
| endsample -= int(((self.frames_postroll + 1) / actual_fps) * strip_samplerate) | |
| if DEBUG: | |
| self.report({'INFO'}, 'startsample is {}, endsample is {}'.format(startsample, endsample)) | |
| # timeline_start_frame is the frame on the timeline that corresponds to sample 0 of the audio file. | |
| # When frame_start is negative, the audio file starts before frame 0 on the timeline. | |
| # frame_offset_start accounts for any trimming at the start of the clip. | |
| timeline_start_frame = strip_hard_start - start_offset | |
| # use numpy to rapidly find edits | |
| wm.progress_update(10) | |
| edits = find_edits_numpy(dataarray, startsample, endsample, thresh_rising, thresh_falling, | |
| self.frames_preroll, self.frames_postroll, actual_fps, strip_samplerate, | |
| self.operation_mode, timeline_start_frame) | |
| wm.progress_update(50) | |
| if len(edits) == 0: | |
| wm.progress_end() | |
| self.report({'WARNING'}, 'No peaks found above threshold') | |
| return {'CANCELLED'} | |
| if DEBUG: | |
| self.report({'INFO'}, 'Found {} edits'.format(len(edits))) | |
| # work out the final timeline positions for each of the clips once shuffled | |
| clip_starts = [] | |
| clip_starts.append(edits[0][0]) | |
| for edit_index in range(1, len(edits)): | |
| clip_starts.append(clip_starts[edit_index - 1] + (edits[edit_index - 1][1] - edits[edit_index - 1][0])) | |
| if DEBUG: | |
| self.report({'INFO'}, 'Final position {} start {}'.format(edit_index, clip_starts[edit_index])) | |
| # make the edits, updating progress as we go | |
| selected_strips = [strip for strip in sequences if strip.select] | |
| num_strips = len(selected_strips) | |
| for strip_index, strip in enumerate(selected_strips): | |
| progress = 50 + int(50 * strip_index / max(num_strips, 1)) | |
| wm.progress_update(progress) | |
| if DEBUG: | |
| self.report({'INFO'}, 'Strip {} start {}'.format(strip.name, strip.frame_start + strip.frame_offset_start)) | |
| keeps = [] | |
| strip_hard_start = strip.frame_start | |
| newstrip_keep = strip | |
| newstrip_bin = strip | |
| begin_index_offset = 0 | |
| for edit_index in range(len(edits)): | |
| if edits[edit_index][0] >= (newstrip_keep.frame_start + newstrip_keep.frame_offset_start + newstrip_keep.frame_final_duration): | |
| # inpoint is beyond the end of this strip, we are done here | |
| break | |
| if DEBUG: | |
| self.report({'INFO'}, 'Edit {}, in {} out {}'.format(edit_index, edits[edit_index][0], edits[edit_index][1])) | |
| if edits[edit_index][1] <= (newstrip_bin.frame_start + newstrip_bin.frame_offset_start): | |
| # entire edit is before the start of this clip, ignore it (keeping a note of the offset for later correct positioning) | |
| begin_index_offset += 1 | |
| continue | |
| if edits[edit_index][0] <= (newstrip_bin.frame_start + newstrip_bin.frame_offset_start): | |
| # nothing to trim off and bin from before the good bit | |
| newstrip_bin = None | |
| else: | |
| newstrip_keep = newstrip_keep.split(frame=int(edits[edit_index][0]), split_method='SOFT') | |
| remove_sequence(scene.sequence_editor, newstrip_bin) | |
| keeps.append(newstrip_keep) | |
| if edits[edit_index][1] >= (newstrip_keep.frame_start + newstrip_keep.frame_offset_start + newstrip_keep.frame_final_duration): | |
| # the outpoint is beyond the end of the remaining strip, we are done here. | |
| newstrip_bin = None | |
| break | |
| # make the cut at the outpoint of the good bit, and set the clip to be binned next time round | |
| newstrip_bin = newstrip_keep.split(frame=int(edits[edit_index][1]), split_method='SOFT') | |
| newstrip_keep = newstrip_bin | |
| # delete the final unused bit, if it exists | |
| if newstrip_bin is not None and newstrip_bin.frame_final_duration > 0: | |
| remove_sequence(scene.sequence_editor, newstrip_bin) | |
| # shuffle kept bits together | |
| for bit_index in range(len(keeps)): | |
| keeps[bit_index].frame_start = clip_starts[bit_index + begin_index_offset] - keeps[bit_index].frame_offset_start | |
| wm.progress_end() | |
| self.report({'INFO'}, 'Banged {} Cuts!'.format(len(edits))) | |
| return {'FINISHED'} | |
| class BANGING_CUTS_MT_main(bpy.types.Menu): | |
| bl_description = 'BANGING CUTS from Funkster. Chop bits out of your strips in sync with audio peaks!' | |
| bl_label = 'Banging Cuts' | |
| def draw(self, context): | |
| layout = self.layout | |
| layout.operator(BANGING_CUTS_OT_make_cuts.bl_idname, text="Make Cuts", icon='ALIGN_FLUSH') | |
| def menu_draw(self, context): | |
| layout = self.layout | |
| layout.menu('BANGING_CUTS_MT_main') | |
| def register(): | |
| bpy.utils.register_class(BANGING_CUTS_OT_make_cuts) | |
| bpy.utils.register_class(BANGING_CUTS_MT_main) | |
| bpy.types.SEQUENCER_HT_header.append(menu_draw) | |
| def unregister(): | |
| bpy.utils.unregister_class(BANGING_CUTS_OT_make_cuts) | |
| bpy.utils.unregister_class(BANGING_CUTS_MT_main) | |
| bpy.types.SEQUENCER_HT_header.remove(menu_draw) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment