feat(midi): export selected clip as an atomic Standard MIDI File (phase 103)

This commit is contained in:
Johannes Findeisen 2026-11-06 02:26:20 +01:00
commit 02ee1818a3
9 changed files with 178 additions and 5 deletions

View file

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

View file

@ -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.

View file

@ -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.
---

View file

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

View file

@ -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<MainWindow *>(c)->export_midi_dialog();},this);
menu->add("File/Export WAV",0,[](Fl_Widget *,void *context){static_cast<MainWindow *>(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;}

View file

@ -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);

60
src/midi/file.c Normal file
View file

@ -0,0 +1,60 @@
#define _POSIX_C_SOURCE 200809L
#include "xtc/xtc_midi_file.h"
#include "../project/project_internal.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#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->tick<y->tick ? -1 : 1;
if((x->status&0xf0)!=(y->status&0xf0))return (x->status&0xf0)==0x80 ? -1 : 1;
return x->id<y->id ? -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;i<p->note_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(start<c.offset_tick)start=c.offset_tick;
if(end>c.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<count;++i){vlq(data,&size,events[i].tick-previous);previous=events[i].tick;data[size++]=events[i].status;data[size++]=events[i].pitch;data[size++]=events[i].velocity;}
vlq(data,&size,c.length_tick-previous);data[size++]=0xff;data[size++]=0x2f;data[size++]=0;
be32(data+18,(uint32_t)(size-22));free(events);
n=snprintf(temporary,sizeof(temporary),"%s.tmp-XXXXXX",path);
if(n<0 || (size_t)n>=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;
}

View file

@ -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 <FL/Fl_Value_Input.H>
#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());
}

View file

@ -0,0 +1,52 @@
#define _POSIX_C_SOURCE 200809L
#include "xtc/xtc_midi_file.h"
#include "../../src/midi/output.h"
#include <assert.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
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<size && ++n<=4);byte=data[(*at)++];value=(value<<7)|(byte&127);}while(byte&128);
return value;
}
static void output_tests(void)
{
xtc_project *p=NULL;xtc_midi_output_plan *plan=NULL;uint64_t t,c,n;xtc_midi_event events[XTC_MIDI_MAX_EVENTS];size_t count=999;
assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Output",&t)==XTC_OK);
assert(xtc_project_add_midi_clip(p,t,0,1920,&c)==XTC_OK);
for(size_t i=0;i<129;++i)assert(xtc_project_add_note(p,c,i*2,1,60,100,0,&n)==XTC_OK);
assert(xtc_midi_output_create(p,8000,&plan)==XTC_OK);
assert(xtc_midi_output_events(plan,0,4000,events,&count)==XTC_ERR_CAPACITY && count==999);
assert(xtc_midi_output_events(plan,0,1,events,&count)==XTC_OK && count==17 && events[16].type==XTC_MIDI_NOTE_ON);
assert(xtc_midi_output_events(plan,1,4,events,&count)==XTC_OK && count==1 && events[0].type==XTC_MIDI_NOTE_OFF && events[0].offset==3);
xtc_midi_output_destroy(plan);xtc_project_destroy(p);
}
int main(void)
{
xtc_project *p=NULL;uint64_t t,c,n,audio;char path[]="/tmp/xtc-midi-file-XXXXXX";int fd=mkstemp(path);FILE *f;unsigned char bytes[1024];size_t size,at=22,notes=0;unsigned int tick=0;
assert(fd>=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<size){unsigned int status,len;tick+=read_vlq(bytes,size,&at);assert(at<size);status=bytes[at++];
if(status==0xff){unsigned int type=bytes[at++];len=read_vlq(bytes,size,&at);assert(len<=size-at);
if(type==0x51)assert(len==3 && bytes[at]==6 && bytes[at+1]==0x1a && bytes[at+2]==0x80);
if(type==0x2f)assert(!len && tick==960 && at==size);
at+=len;
}else{unsigned int expected_tick[]={0,240,240,960},statuses[]={0x92,0x82,0x93,0x83},pitches[]={60,60,64,64},velocities[]={80,0,100,0};
assert(notes<4 && tick==expected_tick[notes] && status==statuses[notes] && at+2<=size && bytes[at]==pitches[notes] && bytes[at+1]==velocities[notes]);at+=2;++notes;
}
}
assert(notes==4);
assert(xtc_project_add_clip(p,t,"Audio",3840,960,&audio)==XTC_OK && xtc_project_export_midi_clip(p,audio,path)==XTC_ERR_INVALID_STATE);
{unsigned char preserved[1024];f=fopen(path,"rb");assert(f && fread(preserved,1,sizeof(preserved),f)==size && !memcmp(bytes,preserved,size));fclose(f);}
assert(xtc_project_export_midi_clip(p,c,"/no-such-xtc-directory/test.mid")==XTC_ERR_IO);
xtc_project_destroy(p);unlink(path);output_tests();puts("MIDI file and bounded external scheduling checks passed.");return 0;
}