From 02ee1818a36857f8c9292c571496d7b3997d7f0d Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 02:26:20 +0100 Subject: [PATCH] feat(midi): export selected clip as an atomic Standard MIDI File (phase 103) --- Makefile | 10 +++++- docs/MIDI_WORKFLOWS.md | 11 +++++++ docs/ROADMAP.md | 8 ++--- include/xtc/xtc_midi_file.h | 16 +++++++++ src/gui/main_window.cpp | 17 ++++++++++ src/gui/main_window.hpp | 2 ++ src/midi/file.c | 60 ++++++++++++++++++++++++++++++++++ tests/integration/test_gui.cpp | 7 ++++ tests/unit/test_midi_file.c | 52 +++++++++++++++++++++++++++++ 9 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 include/xtc/xtc_midi_file.h create mode 100644 src/midi/file.c create mode 100644 tests/unit/test_midi_file.c diff --git a/Makefile b/Makefile index 179bc6e..0d6b601 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -CORE_SOURCES := src/midi/output.c src/project/crossfade.c src/control/tempo.c src/project/chain_tools.c src/project/chain_preset.c src/mixer/track_fx.c src/audio/sample.c src/persistence/collect.c 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 +CORE_SOURCES := src/midi/file.c src/midi/output.c src/project/crossfade.c src/control/tempo.c src/project/chain_tools.c src/project/chain_preset.c src/mixer/track_fx.c src/audio/sample.c src/persistence/collect.c 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) @@ -600,3 +600,11 @@ test-gui-creative: $(BUILD_DIR)/test_gui .PHONY: test-gui-midi-workflows test-gui-midi-workflows: $(BUILD_DIR)/test_gui XTC_TEST_MIDI_WORKFLOWS_ONLY=1 xvfb-run -a -s "-screen 0 1440x960x24" ./$(BUILD_DIR)/test_gui + +$(BUILD_DIR)/test_midi_file: $(BUILD_DIR)/tests/unit/test_midi_file.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm -ldl +.PHONY: test-midi-file +test-midi-file: $(BUILD_DIR)/test_midi_file + ./$(BUILD_DIR)/test_midi_file +test: test-midi-file +-include $(BUILD_DIR)/tests/unit/test_midi_file.d diff --git a/docs/MIDI_WORKFLOWS.md b/docs/MIDI_WORKFLOWS.md index cd6e744..8a11116 100644 --- a/docs/MIDI_WORKFLOWS.md +++ b/docs/MIDI_WORKFLOWS.md @@ -49,3 +49,14 @@ choice for retry. Reconnect audio after server loss, then retry Play. Defaults also apply when playback resumes after a loop edit. MIDI input/thru currently requires active transport. Existing editing and project_defaults tabs contain note length, velocity, channel, filters and new-project controller mappings. + +## Export a selected clip + +Select exactly one MIDI clip and choose File → Export selected clip as MIDI. +The save chooser confirms overwrites. Export writes an atomic Standard MIDI File +(type 0, 960 PPQN) with project tempo, clip name and audible notes relative to the +clip start, retaining channels and velocities. Notes crossing trimmed boundaries +are cropped and notes outside the trim are omitted. Trailing silence remains. +Export does not edit the project or undo history. Audio clips reject. Only note +messages are exported: instruments, opaque plugin data, controller maps, +automation and audio are excluded. Export is unavailable during recording. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 835897c..1fcc036 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2271,12 +2271,12 @@ Implemented: a MIDI preferences tab provides editable source/destination dropdow Export the audible part of a selected MIDI clip as an atomic Standard MIDI File with note timing, channels, velocities and project tempo. ### Tasks and acceptance criteria -- [ ] Implement and document the workflow with bounded validation and safe ownership. -- [ ] Pass meaningful core and GUI checks, including errors and persistence or undo. -- [ ] Commit this phase separately after validation. +- [x] Implement and document the workflow with bounded validation and safe ownership. +- [x] Pass meaningful core and GUI checks, including errors and persistence or undo. +- [x] Commit this phase separately after validation. ### Implementation status -Planned. +Implemented: a C99 atomic type-0/960-PPQN SMF exporter and GUI save chooser export one selected MIDI clip, preserving note channels/velocities, project tempo, clip name and trailing silence. Trim-crossing notes crop to audible boundaries; out-of-trim notes omit. Independent byte/event checks, invalid/audio/destination preservation checks, bounded external scheduling overflow checks and focused GUI export/nonmutation tests passed with warning-enabled builds. --- diff --git a/include/xtc/xtc_midi_file.h b/include/xtc/xtc_midi_file.h new file mode 100644 index 0000000..d720e79 --- /dev/null +++ b/include/xtc/xtc_midi_file.h @@ -0,0 +1,16 @@ +#ifndef XTC_MIDI_FILE_H +#define XTC_MIDI_FILE_H +#include "xtc_midi.h" +#ifdef __cplusplus +extern "C" { +#endif +/* Control thread only. Atomic type-0 SMF, 960 PPQN, project constant tempo. + * Exports only audible source notes of a MIDI clip, relative to its start, + * retaining channels/velocities, clamping crossing notes to clip boundaries. + * Includes clip name and trailing silence. No CC/program/plugin/audio data. + * Existing destination survives validation/write failures before atomic rename. */ +xtc_result xtc_project_export_midi_clip(const xtc_project *,uint64_t clip,const char *path); +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 78f0c95..bb05be8 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -1,4 +1,5 @@ #include "main_window.hpp" +#include "xtc/xtc_midi_file.h" #include "graph_widget.hpp" #include "sequencer_widget.hpp" #include "piano_roll.hpp" @@ -128,6 +129,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) menu->add("File/New", FL_CTRL + 'n', file_callback, this); menu->add("File/Open", FL_CTRL + 'o', file_callback, this); menu->add("File/Save", FL_CTRL + 's', file_callback, this); + menu->add("File/Export selected clip as MIDI...",0,[](Fl_Widget *,void *c){static_cast(c)->export_midi_dialog();},this); menu->add("File/Export WAV",0,[](Fl_Widget *,void *context){static_cast(context)->export_audio();},this); menu->add("File/Save As", FL_CTRL + FL_SHIFT + 's', file_callback, this); menu->add("File/Import WAV", FL_CTRL + 'i', audio_file_callback, this); @@ -2079,6 +2081,21 @@ xtc_result MainWindow::assign_instrument(uint64_t track, const char *path, unsig if (r != XTC_OK) xtc_project_destroy(candidate); else { sequencer_->select(track,clip); mixer_->select_track(track); mark_dirty(); } return r; } +xtc_result MainWindow::export_midi_clip(const char *path) +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + auto ids=sequencer_->selected_clips();if(ids.size()!=1)return XTC_ERR_INVALID_ARGUMENT; + return xtc_project_export_midi_clip(project_.get(),ids[0],path); +} +void MainWindow::export_midi_dialog() +{ + auto ids=sequencer_->selected_clips();xtc_clip_info clip{}; + if(ids.size()!=1 || xtc_project_find_clip(project_.get(),ids[0],&clip)!=XTC_OK || !clip.midi){report_error("Select one MIDI clip to export",XTC_ERR_INVALID_ARGUMENT);return;} + Fl_Native_File_Chooser chooser;chooser.title("Export selected MIDI clip");chooser.type(Fl_Native_File_Chooser::BROWSE_SAVE_FILE); + chooser.filter("Standard MIDI File\t*.mid");chooser.options(Fl_Native_File_Chooser::SAVEAS_CONFIRM);chooser.preset_file("clip.mid"); + if(chooser.show()!=0)return; + auto r=export_midi_clip(chooser.filename());if(r!=XTC_OK)report_error("MIDI export failed",r);else set_label(error_,"MIDI clip exported (notes, channels, velocities and tempo)."); +} void MainWindow::export_audio() { if(recorder_){report_error("Stop recording before export",XTC_ERR_INVALID_STATE);return;} diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index d78e55b..cfa7b76 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -78,6 +78,8 @@ class MainWindow : public Fl_Double_Window { xtc_result trim_sample_clip(uint64_t clip,unsigned int rate,size_t begin,size_t end); xtc_result open_sample_editor(); void export_audio(); + xtc_result export_midi_clip(const char *path); + void export_midi_dialog(); xtc_result load_sampler(uint64_t track, const char *path); xtc_result set_instrument_spec(uint64_t track, const xtc_plugin_spec &spec); xtc_result select_live_instrument(uint64_t track); diff --git a/src/midi/file.c b/src/midi/file.c new file mode 100644 index 0000000..c6a628e --- /dev/null +++ b/src/midi/file.c @@ -0,0 +1,60 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_midi_file.h" +#include "../project/project_internal.h" +#include +#include +#include +#include +#include +#define SMF_EXPORT_BYTES 65536U +typedef struct {uint64_t tick,id;unsigned char status,pitch,velocity;} smf_event; +static void be32(unsigned char *p,uint32_t v){p[0]=(unsigned char)(v>>24);p[1]=(unsigned char)(v>>16);p[2]=(unsigned char)(v>>8);p[3]=(unsigned char)v;} +static void vlq(unsigned char *data,size_t *size,uint64_t value) +{ + unsigned char bytes[4];size_t n=0; + bytes[n++]=(unsigned char)(value&127);while((value>>=7))bytes[n++]=(unsigned char)((value&127)|128); + while(n)data[(*size)++]=bytes[--n]; +} +static int event_compare(const void *a,const void *b) +{ + const smf_event *x=a,*y=b; + if(x->tick!=y->tick)return x->ticktick ? -1 : 1; + if((x->status&0xf0)!=(y->status&0xf0))return (x->status&0xf0)==0x80 ? -1 : 1; + return x->idid ? -1 : x->id!=y->id; +} +xtc_result xtc_project_export_midi_clip(const xtc_project *p,uint64_t id,const char *path) +{ + xtc_clip_info c;smf_event *events=NULL;unsigned char *data=NULL;size_t i,count=0,size=22;uint64_t previous=0; + char temporary[4096];int fd,n;FILE *f;xtc_result r;uint32_t tempo; + if(!p || !path || !*path)return XTC_ERR_INVALID_ARGUMENT; + r=xtc_project_find_clip(p,id,&c);if(r!=XTC_OK)return r;if(!c.midi)return XTC_ERR_INVALID_STATE; + events=calloc(XTC_PROJECT_MAX_NOTES*2,sizeof(*events));data=calloc(SMF_EXPORT_BYTES,1); + if(!events || !data){free(events);free(data);return XTC_ERR_OUT_OF_MEMORY;} + for(i=0;inote_count;++i)if(p->notes[i].clip_id==id){ + const xtc_note_info *note=p->notes+i;uint64_t start=note->start_tick,end=start+note->length_tick; + if(startc.offset_tick+c.length_tick)end=c.offset_tick+c.length_tick; + if(start>=end)continue; + events[count++]=(smf_event){start-c.offset_tick,note->id,(unsigned char)(0x90|note->channel),(unsigned char)note->pitch,(unsigned char)note->velocity}; + events[count++]=(smf_event){end-c.offset_tick,note->id,(unsigned char)(0x80|note->channel),(unsigned char)note->pitch,0}; + } + qsort(events,count,sizeof(*events),event_compare); + memcpy(data,"MThd",4);be32(data+4,6);data[11]=1;data[12]=3;data[13]=0xc0;memcpy(data+14,"MTrk",4); + data[size++]=0;data[size++]=0xff;data[size++]=3;vlq(data,&size,strlen(c.name));memcpy(data+size,c.name,strlen(c.name));size+=strlen(c.name); + tempo=(uint32_t)((UINT64_C(60000000000)+p->tempo/2)/p->tempo); + data[size++]=0;data[size++]=0xff;data[size++]=0x51;data[size++]=3; + data[size++]=(unsigned char)(tempo>>16);data[size++]=(unsigned char)(tempo>>8);data[size++]=(unsigned char)tempo; + for(i=0;i=sizeof(temporary)){free(data);return XTC_ERR_CAPACITY;} + fd=mkstemp(temporary);if(fd<0){free(data);return XTC_ERR_IO;} + f=fdopen(fd,"wb");if(!f){close(fd);unlink(temporary);free(data);return XTC_ERR_IO;} + r=fwrite(data,1,size,f)==size ? XTC_OK : XTC_ERR_IO;free(data); + if(fflush(f)!=0 || fsync(fd)!=0)r=XTC_ERR_IO; + if(fclose(f)!=0)r=XTC_ERR_IO; + if(r==XTC_OK && rename(temporary,path)!=0)r=XTC_ERR_IO; + if(r!=XTC_OK)unlink(temporary); + return r; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 8a1b918..b0eab5a 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -6,6 +6,7 @@ #include "../../src/gui/mixer_widget.hpp" #include "../../src/gui/plugin_dialog.hpp" #include "../../src/gui/preferences_dialog.hpp" +#include "xtc/xtc_midi_file.h" #include "../../src/gui/command_palette.hpp" #include #include "../../src/gui/plugin_library.hpp" @@ -366,6 +367,12 @@ int midi_workflow_tests() CHECK(window.settings().midi_output_channel==4); auto missing=settings;strcpy(missing.midi_input_port,"missing:port");CHECK(window.apply_preferences(missing)==XTC_OK); CHECK(window.start_configured_backend()==XTC_ERR_NOT_FOUND && !handle.running);CHECK(window.apply_preferences(settings)==XTC_OK); + CHECK(window.create_track(true)==XTC_OK && window.open_piano_roll()==XTC_OK); + CHECK(window.piano_roll()->add_note(0,240,64,90,2)==XTC_OK); + std::string midi=std::string(dir)+"/clip.mid";auto *original=s->project(); + CHECK(window.export_midi_clip(midi.c_str())==XTC_OK && s->project()==original); + {char signature[4];FILE *f=fopen(midi.c_str(),"rb");CHECK(f && fread(signature,1,4,f)==4 && !memcmp(signature,"MThd",4));if(f)fclose(f);} + unlink(midi.c_str());window.detach_piano(false); s->grid(480);window.show_track_inspector(true);window.resize(20,30,1300,820); CHECK(window.set_loop(960,3840,true)==XTC_OK);window.do_callback();CHECK(!window.shown()); } diff --git a/tests/unit/test_midi_file.c b/tests/unit/test_midi_file.c new file mode 100644 index 0000000..3de575d --- /dev/null +++ b/tests/unit/test_midi_file.c @@ -0,0 +1,52 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_midi_file.h" +#include "../../src/midi/output.h" +#include +#include +#include +#include +#include +static unsigned int read_vlq(const unsigned char *data,size_t size,size_t *at) +{ + unsigned int value=0;unsigned char byte;int n=0; + do{assert(*at=0);close(fd);assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Keys",&t)==XTC_OK); + assert(xtc_project_set_tempo(p,150000)==XTC_OK && xtc_project_add_midi_clip(p,t,1000,1920,&c)==XTC_OK); + assert(xtc_project_add_note(p,c,0,480,60,80,2,&n)==XTC_OK && xtc_project_add_note(p,c,480,960,64,100,3,&n)==XTC_OK); + assert(xtc_project_add_note(p,c,1600,100,72,70,0,&n)==XTC_OK); + assert(xtc_project_edit_clip(p,c,t,1000,960,240)==XTC_OK); + assert(xtc_project_export_midi_clip(p,c,path)==XTC_OK); + f=fopen(path,"rb");assert(f);size=fread(bytes,1,sizeof(bytes),f);assert(fclose(f)==0); + assert(size>22 && !memcmp(bytes,"MThd\0\0\0\6\0\0\0\1\3\300MTrk",18)); + while(at