Skip to content

Instantly share code, notes, and snippets.

@weex
Created March 10, 2026 07:16
Show Gist options
  • Select an option

  • Save weex/2dcb72f9a8baecad0130b48de35ac778 to your computer and use it in GitHub Desktop.

Select an option

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
# 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