diff --git a/Makefile b/Makefile index af1f4f2..9974cd1 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/docs/COMPOSITION_WORKFLOWS.md b/docs/COMPOSITION_WORKFLOWS.md index 3035d55..6d24db6 100644 --- a/docs/COMPOSITION_WORKFLOWS.md +++ b/docs/COMPOSITION_WORKFLOWS.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7b6a4e0..fb2a82a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. --- diff --git a/include/xtc/xtc_audio.h b/include/xtc/xtc_audio.h index b087708..7f6d555 100644 --- a/include/xtc/xtc_audio.h +++ b/include/xtc/xtc_audio.h @@ -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 04, 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. diff --git a/src/audio/normalize.c b/src/audio/normalize.c new file mode 100644 index 0000000..2a57f53 --- /dev/null +++ b/src/audio/normalize.c @@ -0,0 +1,29 @@ +#include "../project/project_internal.h" +#include "wave_internal.h" +#include +#include + +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;iwave.frames)end=wave.frames; + for(;beginpeak)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; +} diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index f0d7f19..c34cf92 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -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(c)->nudge_selection(-1);},sequencer_); menu->add("Edit/Nudge selection right",0,[](Fl_Widget *,void *c){static_cast(c)->nudge_selection(1);},sequencer_); + menu->add("Edit/Audio tools/Normalize source peak...",0,[](Fl_Widget *,void *c){static_cast(c)->normalize_dialog();},sequencer_); menu->add("Edit/Audio tools/Set boundary fades...",0,[](Fl_Widget *,void *c){static_cast(c)->fades_dialog();},sequencer_); menu->add("Edit/Audio tools/Remove boundary fades",0,[](Fl_Widget *,void *c){static_cast(c)->transform_selection(XTC_CLIPS_FADE,0);},sequencer_); menu->add("Edit/MIDI tools/Generate arpeggio...",0,[](Fl_Widget *,void *c){static_cast(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(); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 4cf0112..63a61aa 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -1,3 +1,4 @@ +#include #include #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; diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 2d0e8eb..2b4a205 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -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(); diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 5a62955..331fd10 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1392,6 +1392,10 @@ int main(int argc, char **argv) size_t notes=0;xtc_project_note_count(s->project(),¬es);CHECK(notes==3); CHECK(s->arpeggiate(80,50,false)==XTC_OK);xtc_project_note_count(s->project(),¬es);CHECK(notes==3); CHECK(window.undo()==XTC_OK);xtc_project_note_count(s->project(),¬es);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; diff --git a/tests/unit/test_next_daw.c b/tests/unit/test_next_daw.c index 94ea047..2be3132 100644 --- a/tests/unit/test_next_daw.c +++ b/tests/unit/test_next_daw.c @@ -8,7 +8,15 @@ #include #include #include +#include +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;}