feat: normalize selected audio clips to a bounded sample peak

This commit is contained in:
Johannes Findeisen 2026-11-06 00:46:14 +01:00
commit d676d74a11
10 changed files with 94 additions and 6 deletions

View file

@ -27,7 +27,7 @@ CXXFLAGS ?= -O2 -g
CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude
LDFLAGS ?=
CORE_SOURCES := src/project/sections.c src/project/editing.c src/audio/export.c src/plugin/builtin.c src/settings/settings.c src/control/model.c src/audio/waveform.c src/plugin/ladspa.c src/plugin/host.c src/plugin/modern.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c
CORE_SOURCES := src/audio/normalize.c src/project/sections.c src/project/editing.c src/audio/export.c src/plugin/builtin.c src/settings/settings.c src/control/model.c src/audio/waveform.c src/plugin/ladspa.c src/plugin/host.c src/plugin/modern.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c
JACK_AVAILABLE := $(shell $(PKG_CONFIG) --exists jack 2>/dev/null && echo yes)
ifeq ($(JACK_AVAILABLE),yes)
JACK_CFLAGS := $(shell $(PKG_CONFIG) --cflags jack)

View file

@ -38,3 +38,7 @@ Edit/MIDI tools/Build chords accepts 1..8 unique nonzero semitone intervals, suc
## Phase 66 — Offline Arpeggio Generation
Edit/MIDI tools/Generate arpeggio replaces source notes with a written pattern. Equal-onset notes on the same channel form a chord lasting until its longest note end. The pattern cycles sorted pitches upward or downward, with the selected step and gate. Each step inherits its chosen note velocity/channel. All source notes, including hidden trimmed notes, participate. This is offline generation with undo, not a live arpeggiator or tempo-synced MIDI effect.
## Phase 67 — Audio Peak Normalization
Edit/Audio tools/Normalize source peak accepts a target from -60 to 0 dBFS. The C API accepts a linear target above 0 and at most 1. Analysis uses the audible native-rate source slice and the same mono downmix as playback, before fades or mixer controls. It replaces clip gain without writing WAV data. Silence, missing/invalid media, MIDI selections or required gain above 4 reject the entire batch. This is sample-peak normalization, not true-peak limiting or LUFS loudness normalization. Analysis runs synchronously on the control thread.

View file

@ -1632,12 +1632,12 @@ Implemented: bounded offline ascending/descending arpeggio generation with equal
Analyze the audible source slice outside callbacks and adjust nondestructive clip gain to a requested sample-peak target; validate silence, missing media and gain limits.
### Tasks and acceptance criteria
- [ ] Implement bounded C99 model/control APIs and accessible GUI commands.
- [ ] Preserve transactional errors, history, compatibility and callback safety.
- [ ] Verify the feature with automated model/GUI tests and document limitations.
- [x] Implement bounded C99 model/control APIs and accessible GUI commands.
- [x] Preserve transactional errors, history, compatibility and callback safety.
- [x] Verify the feature with automated model/GUI tests and document limitations.
### Implementation status
Planned.
Implemented: native-rate audible-slice sample-peak analysis adjusts nondestructive clip gain atomically. GUI accepts dBFS targets. Core exact gain, missing-media rollback, silence/gain-limit checks and GUI normalization/undo tests pass.
---

View file

@ -40,6 +40,11 @@ void xtc_waveform_destroy(xtc_waveform *waveform);
xtc_result xtc_waveform_info(const xtc_waveform *waveform, unsigned int *rate, size_t *frames, size_t *bins);
xtc_result xtc_waveform_peak(const xtc_waveform *waveform, size_t bin, float *minimum, float *maximum);
/* Control-thread sample-peak normalization of audible native-rate source slices
* (same mono downmix as playback). target 0<peak<=1, replaces clip gain, ignores
* fades/track mix. Gain >4, silence, missing/invalid media or MIDI reject the
* entire unique nonempty selection. Reads media; no source writes. */
xtc_result xtc_project_normalize_audio(xtc_project *project,const uint64_t *ids,size_t count,float target);
/* Control-thread WAV import: PCM16/24/32 or float32, mono/stereo downmixed to
* mono, 1..768000 Hz. Absolute canonical references, <=511 bytes.
* Adds a timeline source -> output route if no timeline source exists.

29
src/audio/normalize.c Normal file
View file

@ -0,0 +1,29 @@
#include "../project/project_internal.h"
#include "wave_internal.h"
#include <stdlib.h>
#include <math.h>
xtc_result xtc_project_normalize_audio(xtc_project *p,const uint64_t *ids,size_t count,float target)
{
xtc_project *copy=NULL;size_t i,j;xtc_result r;
if(!p || !ids || !count || count>XTC_PROJECT_MAX_CLIPS || !isfinite(target) || target<=0 || target>1)return XTC_ERR_INVALID_ARGUMENT;
for(i=0;i<count;++i)for(j=0;j<i;++j)if(ids[i]==ids[j])return XTC_ERR_INVALID_ARGUMENT;
r=xtc_project_clone(p,&copy);if(r!=XTC_OK)return r;
for(i=0;r==XTC_OK && i<count;++i) {
xtc_clip_info c;xtc_wave wave={0};uint64_t begin=0,end=0;float peak=0,gain;
r=xtc_project_find_clip(copy,ids[i],&c);if(r!=XTC_OK)break;
if(c.midi || !c.asset_path[0]){r=XTC_ERR_INVALID_STATE;break;}
r=xtc_wave_load(c.asset_path,&wave);
if(r==XTC_OK)r=xtc_project_tick_to_sample(copy,c.offset_tick,wave.rate,&begin);
if(r==XTC_OK)r=xtc_project_tick_to_sample(copy,c.offset_tick+c.length_tick,wave.rate,&end);
if(r==XTC_OK) {
if(end>wave.frames)end=wave.frames;
for(;begin<end;++begin){float value=fabsf(wave.samples[(size_t)begin]);if(value>peak)peak=value;}
if(peak<=0)r=XTC_ERR_INVALID_STATE;
else {gain=target/peak;if(!isfinite(gain) || gain>4)r=XTC_ERR_CAPACITY;else r=xtc_project_set_clip_audio(copy,c.id,gain,c.fade_in_tick,c.fade_out_tick);}
}
free(wave.samples);
}
if(r==XTC_OK){xtc_graph *old=p->graph;*p=*copy;copy->graph=old;}
xtc_project_destroy(copy);return r;
}

View file

@ -329,6 +329,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration)
Fl_Group::current(parent);
menu->add("Edit/Nudge selection left",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->nudge_selection(-1);},sequencer_);
menu->add("Edit/Nudge selection right",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->nudge_selection(1);},sequencer_);
menu->add("Edit/Audio tools/Normalize source peak...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->normalize_dialog();},sequencer_);
menu->add("Edit/Audio tools/Set boundary fades...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->fades_dialog();},sequencer_);
menu->add("Edit/Audio tools/Remove boundary fades",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->transform_selection(XTC_CLIPS_FADE,0);},sequencer_);
menu->add("Edit/MIDI tools/Generate arpeggio...",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->arpeggio_dialog();},sequencer_);
@ -1591,6 +1592,7 @@ xtc_result MainWindow::import_audio(const char *path)
}
project_.reset(candidate);
sequencer_->set_project(candidate, false);
sequencer_->select(track,clip);
graph_widget_->set_graph(xtc_project_graph(candidate));
mark_dirty();
refresh_status();

View file

@ -1,3 +1,4 @@
#include <cmath>
#include <sstream>
#include "sequencer_widget.hpp"
#include "theme.hpp"
@ -630,6 +631,19 @@ xtc_result SequencerWidget::arpeggiate(uint64_t step,unsigned int gate,bool desc
if(r==XTC_OK)r=xtc_project_arpeggiate(project_,ids.data(),ids.size(),step,gate,descending);
changed(r);return r;
}
xtc_result SequencerWidget::normalize_audio(float target)
{
auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK;
if(r==XTC_OK)r=xtc_project_normalize_audio(project_,ids.data(),ids.size(),target);
changed(r);return r;
}
void SequencerWidget::normalize_dialog()
{
const char *input=fl_input("Source sample peak target in dBFS (-60..0; excludes fades and mixer)","-1");if(!input)return;
float db=0;char extra;std::istringstream values(input);
if(!(values>>db) || (values>>extra) || !std::isfinite(db) || db < -60 || db>0){fl_alert("Enter a finite sample peak target from -60 to 0 dBFS.");return;}
normalize_audio(std::pow(10.0f,db/20.0f));
}
void SequencerWidget::arpeggio_dialog()
{
const char *input=fl_input("Step ticks (1..3840), gate percent (1..100), direction (0=up, 1=down)","240 80 0");if(!input)return;

View file

@ -55,6 +55,8 @@ class SequencerWidget : public Fl_Group {
void chord_dialog();
xtc_result arpeggiate(uint64_t step,unsigned int gate,bool descending);
void arpeggio_dialog();
xtc_result normalize_audio(float target);
void normalize_dialog();
void fades_dialog();
xtc_result repeat_selection(unsigned int copies);
void repeat_dialog();

View file

@ -1392,6 +1392,10 @@ int main(int argc, char **argv)
size_t notes=0;xtc_project_note_count(s->project(),&notes);CHECK(notes==3);
CHECK(s->arpeggiate(80,50,false)==XTC_OK);xtc_project_note_count(s->project(),&notes);CHECK(notes==3);
CHECK(window.undo()==XTC_OK);xtc_project_note_count(s->project(),&notes);CHECK(notes==3);
char sample[4096];CHECK(realpath("examples/projects/original-pluck.wav",sample)!=nullptr);
CHECK(window.import_audio(sample)==XTC_OK);CHECK(s->normalize_audio(.25f)==XTC_OK);
xtc_clip_info normalized{};xtc_project_find_clip(s->project(),s->selected_clip(),&normalized);CHECK(normalized.gain>0 && normalized.gain<=4);
CHECK(window.undo()==XTC_OK);
window.do_callback();
}
if (failures != 0) return 1;

View file

@ -8,7 +8,15 @@
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <math.h>
static void put32(unsigned char *p,uint32_t value){p[0]=(unsigned char)value;p[1]=(unsigned char)(value>>8);p[2]=(unsigned char)(value>>16);p[3]=(unsigned char)(value>>24);}
static void test_wave(const char *path,float amplitude)
{
unsigned char h[44]={0},bytes[4];FILE *f=fopen(path,"wb");assert(f);
memcpy(h,"RIFF",4);put32(h+4,52);memcpy(h+8,"WAVEfmt ",8);put32(h+16,16);h[20]=3;h[22]=1;put32(h+24,1000);put32(h+28,4000);h[32]=4;h[34]=32;memcpy(h+36,"data",4);put32(h+40,16);
assert(fwrite(h,1,44,f)==44);for(size_t i=0;i<4;++i){uint32_t bits;float v=i%2 ? -amplitude*.5f : amplitude;memcpy(&bits,&v,4);put32(bytes,bits);assert(fwrite(bytes,1,4,f)==4);}assert(fclose(f)==0);
}
static void markers(void)
{
xtc_project *p=NULL,*q=NULL;xtc_marker m;size_t count;char path[]="/tmp/xtc-sections-XXXXXX";
@ -163,4 +171,24 @@ static void arpeggio(void)
assert(xtc_project_arpeggiate(p,&c,1,1,100,0)==XTC_ERR_CAPACITY);
assert(xtc_project_note_count(p,&count)==XTC_OK && count==1);xtc_project_destroy(p);
}
int main(void){markers();sections();ripple();duplicate_track();split_midi();merge_midi();humanize();chords();arpeggio();puts("Next DAW workflow tests passed.");return 0;}
static void normalize(void)
{
xtc_project *p=NULL;uint64_t t,ids[2];xtc_clip_info c;char dir[]="/tmp/xtc-normal-XXXXXX",path[512];
assert(mkdtemp(dir));snprintf(path,sizeof(path),"%s/source.wav",dir);test_wave(path,.5f);
assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Track",&t)==XTC_OK);
assert(xtc_project_import_audio(p,t,path,0,&ids[0])==XTC_OK);
assert(xtc_project_normalize_audio(p,ids,1,.25f)==XTC_OK);
assert(xtc_project_find_clip(p,ids[0],&c)==XTC_OK && c.gain==.5f);
assert(xtc_project_edit_clip(p,ids[0],t,0,2,2)==XTC_OK);
assert(xtc_project_normalize_audio(p,ids,1,.25f)==XTC_OK);
assert(xtc_project_find_clip(p,ids[0],&c)==XTC_OK && c.gain==1);
assert(xtc_project_edit_clip(p,ids[0],t,0,8,0)==XTC_OK && xtc_project_set_clip_audio(p,ids[0],.5f,0,0)==XTC_OK);
assert(xtc_project_add_clip(p,t,"Missing",100,960,&ids[1])==XTC_OK && xtc_project_set_clip_asset(p,ids[1],"/nonexistent/xtc.wav")==XTC_OK);
assert(xtc_project_normalize_audio(p,ids,2,1)!=XTC_OK);
assert(xtc_project_find_clip(p,ids[0],&c)==XTC_OK && c.gain==.5f);
test_wave(path,.001f);assert(xtc_project_normalize_audio(p,ids,1,1)==XTC_ERR_CAPACITY);
test_wave(path,0);assert(xtc_project_normalize_audio(p,ids,1,1)==XTC_ERR_INVALID_STATE);
assert(xtc_project_normalize_audio(p,ids,1,NAN)==XTC_ERR_INVALID_ARGUMENT);
xtc_project_destroy(p);assert(unlink(path)==0 && rmdir(dir)==0);
}
int main(void){markers();sections();ripple();duplicate_track();split_midi();merge_midi();humanize();chords();arpeggio();normalize();puts("Next DAW workflow tests passed.");return 0;}