How can peak detection + normalization be made faster with API?

hi, i’m wondering what the process of peak detection + normalizing could be optimized with?

i have the process slicer somehow set up, but i just can’t figure out how to optimize this better.

this function:
reads the selection of a slice, or if no selection and on slice, then that, or selection of a sample, or if no selection and on sample, then that.
it will normalize a selection of a slice, a complete slice, or a selection of a sample, or the whole sample.

but it just seems slow!

Normalizing 73119 frames (3.3 seconds at 22050Hz)

Peak amplitude: 0.766388 (-2.3 dB below full scale)
Will increase volume by 2.3 dB

Normalization complete:
Total time: 0.39 seconds (0.2M frames/sec)
Reading: 16.7%, Processing: 83.3%

Sample Selected is Sample Slot 1
Sample Frames Length is 1-23398260
Selection in Sample: 12451218-23398260
Normalizing: selection in sample

Normalizing 10947043 frames (496.5 seconds at 22050Hz)

Peak amplitude: 0.053680 (-25.4 dB below full scale)
Will increase volume by 25.4 dB

Normalization complete:
Total time: 26.76 seconds (0.4M frames/sec)
Reading: 36.1%, Processing: 63.9%

so, how do i optimize this? what do i do?

@martblek - i saw you locked your thread about a faster process slicer, but i’m not 100% where or how it was posted in full.

what’s the solution? @joule ? @Raul do any of you know what a process slicer is that could handle large files and peak detect + normalize?

function NormalizeSelectedSliceInSample()
  local song = renoise.song()
  local instrument = song.selected_instrument
  local current_slice = song.selected_sample_index
  local first_sample = instrument.samples[1]
  local current_sample = song.selected_sample
  
  -- Check if we have valid data
  if not current_sample or not current_sample.sample_buffer.has_sample_data then
    renoise.app():show_status("No sample available")
    return
  end

  print(string.format("\nSample Selected is Sample Slot %d", song.selected_sample_index))
  print(string.format("Sample Frames Length is 1-%d", current_sample.sample_buffer.number_of_frames))

  -- Case 1: No slice markers - work on current sample
  if #first_sample.slice_markers == 0 then
    local buffer = current_sample.sample_buffer
    local slice_start, slice_end
    
    -- Check for selection in current sample
    if buffer.selection_range[1] and buffer.selection_range[2] then
      slice_start = buffer.selection_range[1]
      slice_end = buffer.selection_range[2]
      print(string.format("Selection in Sample: %d-%d", slice_start, slice_end))
      print("Normalizing: selection in sample")
    else
      slice_start = 1
      slice_end = buffer.number_of_frames
      print("Normalizing: entire sample")
    end
    
    -- Create ProcessSlicer instance and dialog
    local slicer = nil
    local dialog = nil
    local vb = nil
    
    -- Define the process function
    local function process_func()
      local time_start = os.clock()
      local time_reading = 0
      local time_processing = 0
      local total_frames = slice_end - slice_start + 1
      
      print(string.format("\nNormalizing %d frames (%.1f seconds at %dHz)", 
          total_frames, 
          total_frames / buffer.sample_rate,
          buffer.sample_rate))
      
      -- First pass: Find peak
      local peak = 0
      local processed_frames = 0
      local CHUNK_SIZE = 524288  -- 512KB worth of frames
      
      -- Pre-allocate tables for better performance
      local channel_peaks = {}
      for channel = 1, buffer.number_of_channels do
          channel_peaks[channel] = 0
      end
      
      buffer:prepare_sample_data_changes()
      
      -- Process in blocks
      for frame = slice_start, slice_end, CHUNK_SIZE do
          local block_end = math.min(frame + CHUNK_SIZE - 1, slice_end)
          local block_size = block_end - frame + 1
          
          -- Read and process each channel
          for channel = 1, buffer.number_of_channels do
              local read_start = os.clock()
              local channel_peak = 0
              
              for i = 0, block_size - 1 do
                  local sample = math.abs(buffer:sample_data(channel, frame + i))
                  if sample > channel_peak then
                      channel_peak = sample
                  end
              end
              
              time_reading = time_reading + (os.clock() - read_start)
              if channel_peak > channel_peaks[channel] then
                  channel_peaks[channel] = channel_peak
              end
          end
          
          -- Update progress and yield
          processed_frames = processed_frames + block_size
          local progress = processed_frames / total_frames
          if dialog and dialog.visible then
              vb.views.progress_text.text = string.format("Finding peak... %.1f%%", progress * 100)
          end
          
          if slicer:was_cancelled() then
              buffer:finalize_sample_data_changes()
              return
          end
          
          coroutine.yield()
      end
      
      -- Find overall peak
      for _, channel_peak in ipairs(channel_peaks) do
          if channel_peak > peak then
              peak = channel_peak
          end
      end
      
      -- Check if sample is silent
      if peak == 0 then
          print("Sample is silent, no normalization needed")
          buffer:finalize_sample_data_changes()
          if dialog and dialog.visible then
              dialog:close()
          end
          return
      end
      
      -- Calculate and display normalization info
      local scale = 1.0 / peak
      local db_increase = 20 * math.log10(scale)
      print(string.format("\nPeak amplitude: %.6f (%.1f dB below full scale)", peak, -db_increase))
      print(string.format("Will increase volume by %.1f dB", db_increase))
      
      -- Reset progress for second pass
      processed_frames = 0
      
      -- Second pass: Apply normalization
      for frame = slice_start, slice_end, CHUNK_SIZE do
          local block_end = math.min(frame + CHUNK_SIZE - 1, slice_end)
          local block_size = block_end - frame + 1
          
          -- Process each channel
          for channel = 1, buffer.number_of_channels do
              local process_start = os.clock()
              
              for i = 0, block_size - 1 do
                  local current_frame = frame + i
                  buffer:set_sample_data(channel, current_frame, 
                      buffer:sample_data(channel, current_frame) * scale)
              end
              
              time_processing = time_processing + (os.clock() - process_start)
          end
          
          -- Update progress and yield
          processed_frames = processed_frames + block_size
          local progress = processed_frames / total_frames
          if dialog and dialog.visible then
              vb.views.progress_text.text = string.format("Normalizing... %.1f%%", progress * 100)
          end
          
          if slicer:was_cancelled() then
              buffer:finalize_sample_data_changes()
              return
          end
          
          coroutine.yield()
      end
      
      -- Finalize changes
      buffer:finalize_sample_data_changes()
      
      -- Calculate and display performance stats
      local total_time = os.clock() - time_start
      local frames_per_second = total_frames / total_time
      print(string.format("\nNormalization complete:"))
      print(string.format("Total time: %.2f seconds (%.1fM frames/sec)", 
          total_time, frames_per_second / 1000000))
      print(string.format("Reading: %.1f%%, Processing: %.1f%%", 
          (time_reading/total_time) * 100,
          ((total_time - time_reading)/total_time) * 100))
      
      -- Close dialog when done
      if dialog and dialog.visible then
          dialog:close()
      end
      
      if buffer.selection_range[1] and buffer.selection_range[2] then
        renoise.app():show_status("Normalized selection in " .. current_sample.name)
      else
        renoise.app():show_status("Normalized " .. current_sample.name)
      end
    end
    
    -- Create and start the ProcessSlicer
    slicer = ProcessSlicer(process_func)
    dialog, vb = slicer:create_dialog("Normalizing Sample")
    slicer:start()
    return
  end

  -- Case 2: Has slice markers
  local buffer = first_sample.sample_buffer
  local slice_start, slice_end
  local slice_markers = first_sample.slice_markers

  -- If we're on the first sample
  if current_slice == 1 then
    -- Check for selection in first sample
    if buffer.selection_range[1] and buffer.selection_range[2] then
      slice_start = buffer.selection_range[1]
      slice_end = buffer.selection_range[2]
      print(string.format("Selection in First Sample: %d-%d", slice_start, slice_end))
      print("Normalizing: selection in first sample")
    else
      slice_start = 1
      slice_end = buffer.number_of_frames
      print("Normalizing: entire first sample")
    end
  else
    -- Get slice boundaries
    slice_start = current_slice > 1 and slice_markers[current_slice - 1] or 1
    local slice_end_marker = slice_markers[current_slice] or buffer.number_of_frames
    local slice_length = slice_end_marker - slice_start + 1

    print(string.format("Selection is within Slice %d", current_slice))
    print(string.format("Slice %d length is %d-%d (length: %d), within 1-%d of sample frames length", 
      current_slice, slice_start, slice_end_marker, slice_length, buffer.number_of_frames))

    -- When in a slice, check the current_sample's selection range (slice view)
    local current_buffer = current_sample.sample_buffer
    
    -- Debug selection values
    print(string.format("Current sample selection range: start=%s, end=%s", 
      tostring(current_buffer.selection_range[1]), tostring(current_buffer.selection_range[2])))
    
    -- Check if there's a selection in the current slice view
    if current_buffer.selection_range[1] and current_buffer.selection_range[2] then
      local rel_sel_start = current_buffer.selection_range[1]
      local rel_sel_end = current_buffer.selection_range[2]
      
      -- Convert slice-relative selection to absolute position in sample
      local abs_sel_start = slice_start + rel_sel_start - 1
      local abs_sel_end = slice_start + rel_sel_end - 1
      
      print(string.format("Selection %d-%d in slice view converts to %d-%d in sample", 
        rel_sel_start, rel_sel_end, abs_sel_start, abs_sel_end))
          
      -- Use the converted absolute positions
      slice_start = abs_sel_start
      slice_end = abs_sel_end
      print("Normalizing: selection in slice")
    else
      -- No selection in slice view - normalize whole slice
      slice_end = slice_end_marker
      print("Normalizing: entire slice (no selection in slice view)")
    end
  end

  -- Ensure we don't exceed buffer bounds
  slice_start = math.max(1, math.min(slice_start, buffer.number_of_frames))
  slice_end = math.max(slice_start, math.min(slice_end, buffer.number_of_frames))
  print(string.format("Final normalize range: %d-%d\n", slice_start, slice_end))

  -- Create ProcessSlicer instance and dialog for sliced processing
  local slicer = nil
  local dialog = nil
  local vb = nil
  
  -- Define the process function for sliced processing
  local function process_func()
    local time_start = os.clock()
    local time_reading = 0
    local time_processing = 0
    local total_frames = slice_end - slice_start + 1
    
    print(string.format("\nNormalizing %d frames (%.1f seconds at %dHz)", 
        total_frames, 
        total_frames / buffer.sample_rate,
        buffer.sample_rate))
    
    -- First pass: Find peak
    local peak = 0
    local processed_frames = 0
    local CHUNK_SIZE = 524288  -- 512KB worth of frames
    
    -- Pre-allocate tables for better performance
    local channel_peaks = {}
    for channel = 1, buffer.number_of_channels do
        channel_peaks[channel] = 0
    end
    
    buffer:prepare_sample_data_changes()
    
    -- Process in blocks
    for frame = slice_start, slice_end, CHUNK_SIZE do
        local block_end = math.min(frame + CHUNK_SIZE - 1, slice_end)
        local block_size = block_end - frame + 1
        
        -- Read and process each channel
        for channel = 1, buffer.number_of_channels do
            local read_start = os.clock()
            local channel_peak = 0
            
            for i = 0, block_size - 1 do
                local sample = math.abs(buffer:sample_data(channel, frame + i))
                if sample > channel_peak then
                    channel_peak = sample
                end
            end
            
            time_reading = time_reading + (os.clock() - read_start)
            if channel_peak > channel_peaks[channel] then
                channel_peaks[channel] = channel_peak
            end
        end
        
        -- Update progress and yield
        processed_frames = processed_frames + block_size
        local progress = processed_frames / total_frames
        if dialog and dialog.visible then
            vb.views.progress_text.text = string.format("Finding peak... %.1f%%", progress * 100)
        end
        
        if slicer:was_cancelled() then
            buffer:finalize_sample_data_changes()
            return
        end
        
        coroutine.yield()
    end
    
    -- Find overall peak
    for _, channel_peak in ipairs(channel_peaks) do
        if channel_peak > peak then
            peak = channel_peak
        end
    end
    
    -- Check if sample is silent
    if peak == 0 then
        print("Sample is silent, no normalization needed")
        buffer:finalize_sample_data_changes()
        if dialog and dialog.visible then
            dialog:close()
        end
        return
    end
    
    -- Calculate and display normalization info
    local scale = 1.0 / peak
    local db_increase = 20 * math.log10(scale)
    print(string.format("\nPeak amplitude: %.6f (%.1f dB below full scale)", peak, -db_increase))
    print(string.format("Will increase volume by %.1f dB", db_increase))
    
    -- Reset progress for second pass
    processed_frames = 0
    
    -- Second pass: Apply normalization
    for frame = slice_start, slice_end, CHUNK_SIZE do
        local block_end = math.min(frame + CHUNK_SIZE - 1, slice_end)
        local block_size = block_end - frame + 1
        
        -- Process each channel
        for channel = 1, buffer.number_of_channels do
            local process_start = os.clock()
            
            for i = 0, block_size - 1 do
                local current_frame = frame + i
                buffer:set_sample_data(channel, current_frame, 
                    buffer:sample_data(channel, current_frame) * scale)
            end
            
            time_processing = time_processing + (os.clock() - process_start)
        end
        
        -- Update progress and yield
        processed_frames = processed_frames + block_size
        local progress = processed_frames / total_frames
        if dialog and dialog.visible then
            vb.views.progress_text.text = string.format("Normalizing... %.1f%%", progress * 100)
        end
        
        if slicer:was_cancelled() then
            buffer:finalize_sample_data_changes()
            return
        end
        
        coroutine.yield()
    end
    
    -- Finalize changes
    buffer:finalize_sample_data_changes()
    
    -- Calculate and display performance stats
    local total_time = os.clock() - time_start
    local frames_per_second = total_frames / total_time
    print(string.format("\nNormalization complete:"))
    print(string.format("Total time: %.2f seconds (%.1fM frames/sec)", 
        total_time, frames_per_second / 1000000))
    print(string.format("Reading: %.1f%%, Processing: %.1f%%", 
        (time_reading/total_time) * 100,
        ((total_time - time_reading)/total_time) * 100))
    
    -- Close dialog when done
    if dialog and dialog.visible then
        dialog:close()
    end
    
    -- Show appropriate status message
    if current_slice == 1 then
      if buffer.selection_range[1] and buffer.selection_range[2] then
        renoise.app():show_status("Normalized selection in " .. current_sample.name)
      else
        renoise.app():show_status("Normalized entire sample")
      end
    else
      if buffer.selection_range[1] and buffer.selection_range[2] then
        renoise.app():show_status(string.format("Normalized selection in slice %d", current_slice))
      else
        renoise.app():show_status(string.format("Normalized slice %d", current_slice))
      end
      -- Refresh view for slices
      song.selected_sample_index = song.selected_sample_index - 1 
      song.selected_sample_index = song.selected_sample_index + 1
    end
  end
  
  -- Create and start the ProcessSlicer for sliced processing
  slicer = ProcessSlicer(process_func)
  dialog, vb = slicer:create_dialog("Normalizing Sample")
  slicer:start()
end

and

--[[============================================================================
process_slicer.lua
============================================================================]]--

--[[
ProcessSlicer for Paketti - allows slicing up long-running operations into smaller chunks
to maintain UI responsiveness and show progress.

Main benefits:
- Shows current progress of operations
- Allows users to abort operations
- Prevents Renoise from thinking the script is frozen
- Maintains UI responsiveness during heavy operations
]]

class "ProcessSlicer"

function ProcessSlicer:__init(process_func, ...)
  assert(type(process_func) == "function", 
    "Expected a function as first argument")

  self.__process_func = process_func
  self.__process_func_args = {...}
  self.__process_thread = nil
  self.__cancelled = false
end

--------------------------------------------------------------------------------
-- Returns true when the current process is running

function ProcessSlicer:running()
  return (self.__process_thread ~= nil)
end

--------------------------------------------------------------------------------
-- Start a process

function ProcessSlicer:start()
  assert(not self:running(), "Process already running")
  
  self.__process_thread = coroutine.create(self.__process_func)
  
  renoise.tool().app_idle_observable:add_notifier(
    ProcessSlicer.__on_idle, self)
end

--------------------------------------------------------------------------------
-- Stop a running process

function ProcessSlicer:stop()
  assert(self:running(), "Process not running")

  renoise.tool().app_idle_observable:remove_notifier(
    ProcessSlicer.__on_idle, self)

  self.__process_thread = nil
end

--------------------------------------------------------------------------------
-- Cancel the process

function ProcessSlicer:cancel()
  self.__cancelled = true
end

--------------------------------------------------------------------------------
-- Check if process was cancelled

function ProcessSlicer:was_cancelled()
  return self.__cancelled
end

--------------------------------------------------------------------------------
-- Internal function called during idle to continue processing

function ProcessSlicer:__on_idle()
  assert(self.__process_thread ~= nil, "ProcessSlicer internal error: " ..
    "Expected no idle call with no thread running")
  
  -- Continue or start the process while it's still active
  if (coroutine.status(self.__process_thread) == 'suspended') then
    local succeeded, error_message = coroutine.resume(
      self.__process_thread, unpack(self.__process_func_args))
    
    if (not succeeded) then
      -- Stop the process on errors
      self:stop()
      -- Forward the error
      error(error_message) 
    end
    
  -- Stop when the process function completed
  elseif (coroutine.status(self.__process_thread) == 'dead') then
    self:stop()
  end
end

-- Helper function to create a progress dialog
function ProcessSlicer:create_dialog(title)
  local vb = renoise.ViewBuilder()
  local dialog = nil
  
  local DEFAULT_MARGIN = renoise.ViewBuilder.DEFAULT_CONTROL_MARGIN
  local DEFAULT_SPACING = renoise.ViewBuilder.DEFAULT_CONTROL_SPACING
  
  local dialog_content = vb:column {
    margin = DEFAULT_MARGIN,
    spacing = DEFAULT_SPACING,
    
    vb:text {
      id = "progress_text",
      text = "Processing..."
    },
    
    vb:button {
      id = "cancel_button",
      text = "Cancel",
      width = 80,
      notifier = function()
        self:cancel()
        if dialog and dialog.visible then
          dialog:close()
        end
      end
    }
  }
  
  dialog = renoise.app():show_custom_dialog(
    title or "Processing...", dialog_content)
    
  return dialog, vb
end 

what’s the right way?
@unless maybe knows something of this?

Just from a quick review, I can see the following potential optimizations:

  • Localization of math library functions. Not a huge deal, but why not.
  • You read the sample data twice. Sample_buffer access is a hanging fruit here, so if you can spare some memory you could save the sample_buffer data in a table upon first access to make the second pass read faster. Later when you multiply, you read from that cache instead of reading from sample_buffer once more.
  • local current_frame = frame + i looks misplaced, but it’s kind of a microoptimization. Initialize locals outside the loop when possible. Even take the liberty of reusing variables when possible, such as "local sample; loop starts; sample = abs(…)

Just guessing that these would cut 20%, but I could be way off.

PS. I never once had any use for process slicing.

1 Like

i tried your samplebuffer into table and it went to:

Normalizing 6267549 frames (284.2 seconds at 22050Hz)

Peak amplitude: 0.061127 (-24.3 dB below full scale)
Will increase volume by 24.3 dB

Normalization complete:
Total time: 13.15 seconds (0.5M frames/sec)
Reading: 44.9%, Processing: 55.1%

so that seems nice

thanks

You could try replacing math.abs(x) by pure inlined lua like x < 0 and -x or x as calling functions can be more expensive.

Not that relevant but if you have found a peak of 1 you can abort processing similar to a peak of 0.

1 Like

Here is another optimization. At first I thought it was a microoptimization, but it actually seems to be worthwhile. Except for the obvious lookup being avoided, I think it might save a bunch of additional ones internally (metatable related, possibly).

local buffer = renoise.song().selected_sample.sample_buffer
local buffer_set = buffer.set_sample_data
local buffer_get = buffer.sample_data

for i = 1, lots_of_frames do
  -- optimal get
  test = buffer_get(buffer, 1, 1)
  -- optimal set
  buffer_set(buffer, 1, 1, 1)
end

Lemme know if this made any difference. WIth a clean loop and lots of frames it saved me 50% of the time here.

1 Like
Normalizing 6267549 frames (284.2 seconds at 22050Hz)

Peak amplitude: 0.061127 (-24.3 dB below full scale)
Will increase volume by 24.3 dB

Normalization complete:
Total time: 12.05 seconds (0.5M frames/sec)
Reading: 48.6%, Processing: 51.4%

and
if i load a 20minute sample, let’s see how it goes

Sample Selected is Sample Slot 1
Sample Frames Length is 1-59294462
Selection in Sample: 1-59294462
Normalizing: selection in sample

Normalizing 59294462 frames (1344.5 seconds at 44100Hz, at 32-bit)

Peak amplitude: 0.484802 (-6.3 dB below full scale)
Will increase volume by 6.3 dB

Normalization complete:
Total time: 149.06 seconds (0.4M frames/sec)
Reading: 46.4%, Processing: 53.6%

it seems the frames/sec has gone from 0.3m to 0.4m so that’s already pretty good, right? i just wish it was possible to make it faster.

and thanks @unless - i set it up so that if peak1 is detected, it will immediately abort the mission - which of course makes 100% sense.

i wonder what else can be done to optimize this.

Would luajit potentially speed things up?

no idea but what is luajit anyway and do i need to install it somewhere? it’s a 3rd party thing not in renoise? or?

Your coroutine.yield() is bottleneck.
Make yield time based.
localize math and others variables.
some[#some + 1] is faster method to add item at the end.
too much gui refreshes.
smaller blocksize ?

1 Like

this is what i’m working with right now. gui refreshes are for every percentage change, which are like every 7.1% - otherwise the dialog looks like it’s frozen.

i have localized the math, i think, but maybe some variables in addition to those can be localized.
i’m not sure what shorthand such as some[#some+1] is faster method to add item at the end - where is that going on?

-- Global constants for processing
--CHUNK_SIZE = 16777216
CHUNK_SIZE = 4194304
PROCESS_YIELD_INTERVAL = 4.53

-- Localize math library functions for efficiency
local math_log10 = math.log10
local math_abs   = math.abs

function NormalizeSelectedSliceInSample()
  local song = renoise.song()
  local instrument = song.selected_instrument
  local current_slice = song.selected_sample_index
  local first_sample = instrument.samples[1]
  local current_sample = song.selected_sample
  local start_time = os.clock()

  if not current_sample or not current_sample.sample_buffer.has_sample_data then
    renoise.app():show_status("No sample available")
    return
  end

  print(string.format("\nSample Selected is Sample Slot %d", song.selected_sample_index))
  print(string.format("Sample Frames Length is 1-%d", current_sample.sample_buffer.number_of_frames))

  -----------------------------
  -- CASE 1: No slice markers – process entire sample.
  if #first_sample.slice_markers == 0 then
    local slicer, dialog, vb  -- declare upvalues so process_func can refer to them
    local function process_func()
      local buffer = current_sample.sample_buffer
      local sel_range = buffer.selection_range
      local slice_start, slice_end

      if sel_range[1] and sel_range[2] then
        slice_start = sel_range[1]
        slice_end   = sel_range[2]
        print(string.format("Selection in Sample: %d-%d", slice_start, slice_end))
        print("Normalizing: selection in sample")
      else
        slice_start = 1
        slice_end   = buffer.number_of_frames
        print("Normalizing: entire sample")
      end

      -- Localize properties for efficiency.
      local num_channels = buffer.number_of_channels
      local sample_rate  = buffer.sample_rate
      local bit_depth    = buffer.bit_depth
      local total_frames = slice_end - slice_start + 1
      local get_sample   = buffer.sample_data
      local set_sample   = buffer.set_sample_data

      -- Preallocate flat cache table and per‑channel peak table.
      local channel_peaks = {}
      local sample_cache  = {}
      for ch = 1, num_channels do
        channel_peaks[ch] = 0
        sample_cache[ch]  = {}  -- sample index = absolute frame - slice_start + 1
      end

      buffer:prepare_sample_data_changes()

      local next_yield_time = os.clock() + PROCESS_YIELD_INTERVAL
      local function yield_if_needed()
        if os.clock() >= next_yield_time then
          coroutine.yield()
          next_yield_time = os.clock() + PROCESS_YIELD_INTERVAL
        end
      end

      local processed_frames = 0
      local time_reading = 0

      print(string.format("\nNormalizing %d frames (%.1f sec at %dHz, %d‑bit)", 
            total_frames, total_frames/sample_rate, sample_rate, bit_depth))

      -- First Pass: Cache sample data and compute peak.
      for frame = slice_start, slice_end, CHUNK_SIZE do
        local block_end = math.min(frame + CHUNK_SIZE - 1, slice_end)
        local block_size = block_end - frame + 1
        local t_block = os.clock()
        for ch = 1, num_channels do
          for i = 0, block_size - 1 do
            local f = frame + i
            local idx = f - slice_start + 1
            local value = get_sample(buffer, ch, f)
            sample_cache[ch][idx] = value
            local abs_val = value < 0 and -value or value
            if abs_val > channel_peaks[ch] then
              channel_peaks[ch] = abs_val
              if channel_peaks[ch] >= 1.0 then
                print("Found peak of 1.0 - no normalization needed")
                buffer:finalize_sample_data_changes()
                if dialog and dialog.visible then
                  dialog:close()
                end
                return
              end
            end
          end
        end
        time_reading = time_reading + (os.clock() - t_block)
        processed_frames = processed_frames + block_size
        if dialog and dialog.visible then
          vb.views.progress_text.text = string.format("Finding peak... %.1f%%", (processed_frames/total_frames)*100)
        end
        if slicer and slicer:was_cancelled() then
          buffer:finalize_sample_data_changes()
          return
        end
        yield_if_needed()
      end

      -- Find overall peak.
      local peak = 0
      for _, p in ipairs(channel_peaks) do
        if p > peak then peak = p end
      end
      if peak == 0 then
        print("Sample is silent, no normalization needed")
        buffer:finalize_sample_data_changes()
        if dialog and dialog.visible then
          dialog:close()
        end
        return
      end

      local scale = 1.0 / peak
      local db_increase = 20 * math_log10(scale)
      print(string.format("\nPeak amplitude: %.6f (%.1f dB below full scale)", peak, -db_increase))
      print(string.format("Will increase volume by %.1f dB", db_increase))

      -- Second Pass: Apply normalization.
      processed_frames = 0
      local time_processing = 0
      next_yield_time = os.clock() + PROCESS_YIELD_INTERVAL  -- reset yield timer

      for frame = slice_start, slice_end, CHUNK_SIZE do
        local block_end = math.min(frame + CHUNK_SIZE - 1, slice_end)
        local block_size = block_end - frame + 1
        local t_block = os.clock()
        for ch = 1, num_channels do
          for i = 0, block_size - 1 do
            local f = frame + i
            local idx = f - slice_start + 1
            local cached_value = sample_cache[ch][idx]
            set_sample(buffer, ch, f, cached_value * scale)
          end
        end
        time_processing = time_processing + (os.clock() - t_block)
        processed_frames = processed_frames + block_size
        if dialog and dialog.visible then
          vb.views.progress_text.text = string.format("Normalizing... %.1f%%", (processed_frames/total_frames)*100)
        end
        if slicer and slicer:was_cancelled() then
          buffer:finalize_sample_data_changes()
          return
        end
        yield_if_needed()
      end

      sample_cache = nil
      buffer:finalize_sample_data_changes()

      local total_time = os.clock() - start_time
      local frames_per_sec = total_frames / total_time
      print(string.format("\nNormalization complete:"))
      print(string.format("Total time: %.2f seconds (%.5fM frames/sec)", total_time, frames_per_sec/1000000))
      print(string.format("Reading: %.3f%%, Processing: %.3f%%", 
             (time_reading/total_time)*100, ((total_time-time_reading)/total_time)*100))
      
      if dialog and dialog.visible then
        dialog:close()
      end

      if sel_range[1] and sel_range[2] then
        renoise.app():show_status("Normalized selection in " .. current_sample.name)
      else
        renoise.app():show_status("Normalized " .. current_sample.name)
      end
    end

    slicer = ProcessSlicer(process_func)
    dialog, vb = slicer:create_dialog("Normalizing Sample")
    slicer:start()
    return
  end

  -----------------------------
  -- CASE 2: Slice markers exist – process based on current slice.
  do
    local slicer, dialog, vb  -- declare upvalues
    local function process_func()
      local buffer = first_sample.sample_buffer
      local slice_markers = first_sample.slice_markers
      local slice_start, slice_end

      if current_slice == 1 then
        local sel = buffer.selection_range
        if sel[1] and sel[2] then
          slice_start = sel[1]
          slice_end   = sel[2]
          print(string.format("Selection in First Sample: %d-%d", slice_start, slice_end))
          print("Normalizing: selection in first sample")
        else
          slice_start = 1
          slice_end   = buffer.number_of_frames
          print("Normalizing: entire first sample")
        end
      else
        slice_start = current_slice > 1 and slice_markers[current_slice - 1] or 1
        local slice_end_marker = slice_markers[current_slice] or buffer.number_of_frames
        print(string.format("Selection is within Slice %d", current_slice))
        print(string.format("Slice %d bounds: %d-%d", current_slice, slice_start, slice_end_marker))
        local current_buffer = current_sample.sample_buffer
        print(string.format("Current sample selection range: start=%s, end=%s", 
              tostring(current_buffer.selection_range[1]), tostring(current_buffer.selection_range[2])))
        if current_buffer.selection_range[1] and current_buffer.selection_range[2] then
          local rel_sel_start = current_buffer.selection_range[1]
          local rel_sel_end   = current_buffer.selection_range[2]
          local abs_sel_start = slice_start + rel_sel_start - 1
          local abs_sel_end   = slice_start + rel_sel_end - 1
          print(string.format("Selection %d-%d in slice view converts to %d-%d in sample", 
                rel_sel_start, rel_sel_end, abs_sel_start, abs_sel_end))
          slice_start = abs_sel_start
          slice_end   = abs_sel_end
          print("Normalizing: selection in slice")
        else
          slice_end = slice_end_marker
          print("Normalizing: entire slice (no selection in slice view)")
        end
      end

      slice_start = math.max(1, math.min(slice_start, buffer.number_of_frames))
      slice_end   = math.max(slice_start, math.min(slice_end, buffer.number_of_frames))
      print(string.format("Final normalize range: %d-%d", slice_start, slice_end))

      local num_channels = buffer.number_of_channels
      local sample_rate  = buffer.sample_rate
      local total_frames = slice_end - slice_start + 1
      local get_sample   = buffer.sample_data
      local set_sample   = buffer.set_sample_data

      local channel_peaks = {}
      local sample_cache  = {}
      for ch = 1, num_channels do
        channel_peaks[ch] = 0
        sample_cache[ch]  = {}
      end

      buffer:prepare_sample_data_changes()

      local next_yield_time = os.clock() + PROCESS_YIELD_INTERVAL
      local function yield_if_needed()
        if os.clock() >= next_yield_time then
          coroutine.yield()
          next_yield_time = os.clock() + PROCESS_YIELD_INTERVAL
        end
      end

      local processed_frames = 0
      local time_reading = 0

      print(string.format("\nNormalizing %d frames (%.1f sec at %dHz)", total_frames, total_frames/sample_rate, sample_rate))

      -- First Pass: cache and find the peak.
      for frame = slice_start, slice_end, CHUNK_SIZE do
        local block_end = math.min(frame + CHUNK_SIZE - 1, slice_end)
        local block_size = block_end - frame + 1
        local t_block = os.clock()
        for ch = 1, num_channels do
          for i = 0, block_size - 1 do
            local f = frame + i
            local idx = f - slice_start + 1
            local value = get_sample(buffer, ch, f)
            sample_cache[ch][idx] = value
            local abs_val = value < 0 and -value or value
            if abs_val > channel_peaks[ch] then
              channel_peaks[ch] = abs_val
              if channel_peaks[ch] >= 1.0 then
                print("Found peak of 1.0 - no normalization needed")
                renoise.app():show_status("Found a peak of 1.0 - no normalization, doing nothing.")
                buffer:finalize_sample_data_changes()
                if dialog and dialog.visible then
                  dialog:close()
                end
                return
              end
            end
          end
        end
        time_reading = time_reading + (os.clock() - t_block)
        processed_frames = processed_frames + block_size
        if dialog and dialog.visible then
          vb.views.progress_text.text = string.format("Finding peak... %.1f%%", (processed_frames/total_frames)*100)
        end
        if slicer and slicer:was_cancelled() then
          buffer:finalize_sample_data_changes()
          return
        end
        yield_if_needed()
      end

      local peak = 0
      for _, channel_peak in ipairs(channel_peaks) do
        if channel_peak > peak then
          peak = channel_peak
        end
      end

      if peak == 0 then
        print("Sample is silent, no normalization needed")
        buffer:finalize_sample_data_changes()
        if dialog and dialog.visible then
          dialog:close()
        end
        return
      end

      local scale = 1.0 / peak
      local db_increase = 20 * math_log10(scale)
      print(string.format("\nPeak amplitude: %.6f (%.1f dB below full scale)", peak, -db_increase))
      print(string.format("Will increase volume by %.1f dB", db_increase))

      processed_frames = 0
      local time_processing = 0
      next_yield_time = os.clock() + PROCESS_YIELD_INTERVAL

      -- Second Pass: normalization.
      for frame = slice_start, slice_end, CHUNK_SIZE do
        local block_end = math.min(frame + CHUNK_SIZE - 1, slice_end)
        local block_size = block_end - frame + 1
        local t_block = os.clock()
        for ch = 1, num_channels do
          for i = 0, block_size - 1 do
            local f = frame + i
            local idx = f - slice_start + 1
            local value = sample_cache[ch][idx]
            set_sample(buffer, ch, f, value * scale)
          end
        end
        time_processing = time_processing + (os.clock() - t_block)
        processed_frames = processed_frames + block_size
        if dialog and dialog.visible then
          vb.views.progress_text.text = string.format("Normalizing... %.1f%%", (processed_frames/total_frames)*100)
        end
        if slicer and slicer:was_cancelled() then
          buffer:finalize_sample_data_changes()
          return
        end
        yield_if_needed()
      end

      sample_cache = nil
      buffer:finalize_sample_data_changes()

      local total_time = os.clock() - start_time
      local frames_per_sec = total_frames / total_time
      print(string.format("\nNormalization complete:"))
      print(string.format("Total time: %.2f seconds (%.1fM frames/sec)", total_time, frames_per_sec/1000000))
      print(string.format("Reading: %.1f%%, Processing: %.1f%%", 
            (time_reading/total_time)*100, ((total_time-time_reading)/total_time)*100))
      
      if dialog and dialog.visible then
        dialog:close()
      end

      if current_slice == 1 then
        local sel = buffer.selection_range
        if sel[1] and sel[2] then
          renoise.app():show_status("Normalized selection in " .. current_sample.name)
        else
          renoise.app():show_status("Normalized entire sample")
        end
      else
        local sel = buffer.selection_range
        if sel[1] and sel[2] then
          renoise.app():show_status(string.format("Normalized selection in slice %d", current_slice))
        else
          renoise.app():show_status(string.format("Normalized slice %d", current_slice))
        end
        song.selected_sample_index = song.selected_sample_index - 1 
        song.selected_sample_index = song.selected_sample_index + 1
      end
    end

    slicer = ProcessSlicer(process_func)
    dialog, vb = slicer:create_dialog("Normalizing Sample")
    slicer:start()
  end
end


-- Add keybinding and menu entries
renoise.tool():add_keybinding{name="Sample Editor:Paketti:Normalize Selected Sample or Slice",invoke=NormalizeSelectedSliceInSample}
renoise.tool():add_keybinding{name="Global:Paketti:Normalize Selected Sample or Slice",invoke=NormalizeSelectedSliceInSample}
renoise.tool():add_menu_entry{name="Sample Editor:Paketti..:Process..:Normalize Selected Sample or Slice",invoke=NormalizeSelectedSliceInSample}
renoise.tool():add_menu_entry{name="Sample Navigator:Paketti..:Process..:Normalize Selected Sample or Slice",invoke=NormalizeSelectedSliceInSample}
renoise.tool():add_midi_mapping{name="Paketti:Normalize Selected Sample or Slice",invoke=function(message) if message:is_trigger() then NormalizeSelectedSliceInSample() end end}


function normalize_all_samples_in_instrument()
  local instrument = renoise.song().selected_instrument
  if not instrument then
    renoise.app():show_warning("No instrument selected")
    return
  end

  local total_samples = #instrument.samples
  if total_samples == 0 then
    renoise.app():show_warning("No samples in selected instrument")
    return
  end

  local dialog = renoise.app():show_status("Normalizing samples...")
  dialog:add_line("Processing samples...")

  local process = ProcessSlicer(function()
    local processed_samples = 0
    local skipped_samples = 0

    for sample_idx = 1, total_samples do
      do
        local sample = instrument.samples[sample_idx]
        if not sample or not sample.sample_buffer.has_sample_data then
          skipped_samples = skipped_samples + 1
          break  -- breaks the do..end block, continues the for loop
        end

        dialog:add_line(string.format("Processing sample %d of %d", sample_idx, total_samples))

        local buffer = sample.sample_buffer
        local num_channels = buffer.number_of_channels
        local num_frames = buffer.number_of_frames

        -- Find peak value across all channels
        local max_peak = 0
        for frame = 1, num_frames, CHUNK_SIZE do
          local chunk_size = math.min(CHUNK_SIZE, num_frames - frame + 1)
          for channel = 1, num_channels do
            local data = buffer:sample_data(channel, frame, frame + chunk_size - 1)
            for i = 1, #data do
              max_peak = math.max(max_peak, math.abs(data[i]))
            end
          end
          coroutine.yield()
        end

        -- Skip if already normalized
        if math.abs(max_peak - 1.0) < 0.0001 then
          skipped_samples = skipped_samples + 1
          break  -- breaks the do..end block, continues the for loop
        end

        -- Apply normalization
        local scale = 1.0 / max_peak
        for frame = 1, num_frames, CHUNK_SIZE do
          local chunk_size = math.min(CHUNK_SIZE, num_frames - frame + 1)
          for channel = 1, num_channels do
            local data = buffer:sample_data(channel, frame, frame + chunk_size - 1)
            for i = 1, #data do
              data[i] = data[i] * scale
            end
            buffer:set_sample_data(channel, frame, data)
          end
          coroutine.yield()
        end

        processed_samples = processed_samples + 1
      end -- end of do block
    end

    dialog:close()
    local msg = string.format("Normalized %d samples. Skipped %d samples.", 
      processed_samples, skipped_samples)
    renoise.app():show_message(msg)
  end)

  process:start()
end

Do you normalize left and right channels independently? Is that needed?

There’s lots of room for microoptimizations here, that together would improve performance a bit. Mainly cachable lookups and avoiding to create new variables in inner loops.

Just as an example:

for i = 0, block_size - 1 do
  local f = frame + i
  local idx = f - slice_start + 1
  local value = sample_cache[ch][idx]
  set_sample(buffer, ch, f, value * scale)
end

should be the same as this, right?

local channel_cache = sample_cache[ch]
for i = frame, frame+block_size - 1 do
  set_sample(buffer, ch, i, channel_cache[i - slice_start + 1] * scale)
end