From f37ad8987ae9e22823067d49e9e976313387400d Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 02:28:25 +0100 Subject: [PATCH] feat(midi): import Standard MIDI Files with transactional undo (phase 104) --- docs/MIDI_WORKFLOWS.md | 22 ++++++ docs/ROADMAP.md | 8 +-- include/xtc/xtc_midi_file.h | 7 ++ src/gui/main_window.cpp | 23 ++++++ src/gui/main_window.hpp | 2 + src/midi/file.c | 103 +++++++++++++++++++++++++++ tests/integration/test_gui.cpp | 11 +++ tests/integration/test_reliability.c | 22 ++++++ tests/unit/test_midi_file.c | 41 ++++++++++- 9 files changed, 234 insertions(+), 5 deletions(-) diff --git a/docs/MIDI_WORKFLOWS.md b/docs/MIDI_WORKFLOWS.md index 8a11116..347946d 100644 --- a/docs/MIDI_WORKFLOWS.md +++ b/docs/MIDI_WORKFLOWS.md @@ -60,3 +60,25 @@ 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. + +## Import MIDI files + +File → Import MIDI (also in the sequencer Create menu) adds a clip at the cursor +on the selected non-bus track, or creates a track when none is selected. Import +is one undoable prepared edit; malformed files, capacity errors and failed +backend preparation retain the old project and IDs. + +The reader supports Standard MIDI File types 0 and 1 with PPQN timing, including +running status and velocity-zero note-offs. File tracks merge into one clip; +channels and velocities remain. Timing rounds to 960 ticks per quarter note, +with positive sub-tick notes given one tick. Trailing silence remains. The project +tempo is retained: tempo maps, time signatures, CC/sustain, program changes, +pitch bend, SysEx and other metadata are not imported. This is a notes workflow, +not full-song or instrument reconstruction. + +Limits are 1 MiB per file, 64 file tracks, 65,536 parsed events and 4,096 notes +within the project's total note capacity. SMPTE timing and type 2 reject explicitly. +Unterminated notes, zero source duration and malformed events reject rather than +silently dropping notes. Unmatched note-offs are harmless and ignored. Overlapping +same-pitch notes pair FIFO within each file track/channel. Project save/reload +uses the existing format; no format migration or extra dependency is required. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1fcc036..e6d3a74 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2286,12 +2286,12 @@ Implemented: a C99 atomic type-0/960-PPQN SMF exporter and GUI save chooser expo Import type 0/1 PPQN MIDI notes onto the selected track or a new track, with safe parsing, explicit limits, tempo policy and one undoable prepared adoption. ### 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: bounded C99 type 0/1 PPQN notes import with running status, channel/velocity preservation, tick conversion, track merging and retained project tempo. File/Import MIDI and Create/Import MIDI add at the cursor on the current or a new track through one prepared undo edit. Export/import roundtrips, independent type-1 fixture, every truncation prefix, malformed/unsupported/bus/overflow rejection, allocation-failure rollback and GUI import/undo/redo/new-track/failed-adoption checks passed, together with the full core/reliability suite. Notes-only limits are explicit in MIDI_WORKFLOWS.md. --- diff --git a/include/xtc/xtc_midi_file.h b/include/xtc/xtc_midi_file.h index d720e79..e276a17 100644 --- a/include/xtc/xtc_midi_file.h +++ b/include/xtc/xtc_midi_file.h @@ -10,6 +10,13 @@ extern "C" { * 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); +/* Transactional notes-only type 0/1 PPQN import into an existing non-bus track. + * Merges file tracks, preserving channels, rounds timing to 960 PPQN and retains + * trailing silence. Keeps project tempo; ignores meta/CC/program/bend/SysEx data. + * Max 1 MiB, 64 tracks, 65536 events and 4096 notes. SMPTE/type 2 unsupported. + * Unclosed notes or malformed data reject; same-pitch overlap pairs FIFO per + * file track/channel. IDs/project/out_clip remain unchanged on every error. */ +xtc_result xtc_project_import_midi(xtc_project *,uint64_t track,uint64_t start,const char *path,uint64_t *out_clip); #ifdef __cplusplus } #endif diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index bb05be8..48d8e4a 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -129,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/Import MIDI...",0,[](Fl_Widget *,void *c){static_cast(c)->import_midi_dialog();},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); @@ -526,6 +527,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) add_track->add("Empty track",0,[](Fl_Widget *,void *c){static_cast(c)->add_track();},sequencer_); add_track->add("New MIDI track",0,midi_create,this); add_track->add("New audio track",0,audio_create,this); + add_track->add("Import MIDI",0,[](Fl_Widget *,void *c){static_cast(c)->import_midi_dialog();},this); add_track->add("Import WAV",0,audio_file_callback,this); add_track->add("Load instrument plugin",0,instrument_callback,this); add_track->add("Edit MIDI notes",0,piano_callback,this); @@ -2081,6 +2083,27 @@ 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::import_midi_file(const char *path) +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + uint64_t track=sequencer_->selected_track(),start=sequencer_->position(),clip=0;xtc_project *candidate=nullptr; + xtc_result r=before_arrangement_edit(this); + if(r==XTC_OK)r=xtc_project_clone(project_.get(),&candidate); + if(r==XTC_OK && track==UINT64_MAX)r=xtc_project_add_track(candidate,"Imported MIDI",&track); + if(r==XTC_OK)r=xtc_project_import_midi(candidate,track,start,path,&clip); + if(r==XTC_OK)r=install_project(candidate,false); + if(r!=XTC_OK)xtc_project_destroy(candidate); + else{sequencer_->select(track,clip);mixer_->select_track(track);mark_dirty();refresh_status();} + return r; +} +void MainWindow::import_midi_dialog() +{ + if(recorder_){report_error("Stop recording before importing MIDI",XTC_ERR_INVALID_STATE);return;} + Fl_Native_File_Chooser chooser;chooser.title("Import MIDI notes (keeps project tempo)");chooser.type(Fl_Native_File_Chooser::BROWSE_FILE);chooser.filter("Standard MIDI File\t*.{mid,midi}"); + if(chooser.show()!=0)return; + auto r=import_midi_file(chooser.filename());if(r!=XTC_OK)report_error("MIDI import failed (type 0/1 PPQN notes required)",r); + else set_label(error_,"Imported MIDI notes at cursor; file tracks merged and project tempo retained. Undo reverses import."); +} xtc_result MainWindow::export_midi_clip(const char *path) { if(recorder_)return XTC_ERR_INVALID_STATE; diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index cfa7b76..be1cd5f 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -80,6 +80,8 @@ class MainWindow : public Fl_Double_Window { void export_audio(); xtc_result export_midi_clip(const char *path); void export_midi_dialog(); + xtc_result import_midi_file(const char *path); + void import_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 index c6a628e..6fb7920 100644 --- a/src/midi/file.c +++ b/src/midi/file.c @@ -58,3 +58,106 @@ xtc_result xtc_project_export_midi_clip(const xtc_project *p,uint64_t id,const c if(r!=XTC_OK)unlink(temporary); return r; } + +#define SMF_INPUT_BYTES (1024U*1024U) +#define SMF_MAX_EVENTS 65536U +typedef struct { + xtc_note_info notes[XTC_PROJECT_MAX_NOTES]; + int next[XTC_PROJECT_MAX_NOTES],head[16][128],tail[16][128]; + size_t count,events; + uint64_t end; +} smf_import; +static uint32_t get32(const unsigned char *p){return ((uint32_t)p[0]<<24)|((uint32_t)p[1]<<16)|((uint32_t)p[2]<<8)|p[3];} +static unsigned int get16(const unsigned char *p){return ((unsigned int)p[0]<<8)|p[1];} +static int get_vlq(const unsigned char *data,size_t end,size_t *at,uint32_t *value) +{ + unsigned int i;uint32_t v=0; + for(i=0;i<4;++i){unsigned char byte;if(*at>=end)return 0;byte=data[(*at)++];v=(v<<7)|(byte&127);if(!(byte&128)){*value=v;return 1;}} + return 0; +} +static xtc_result parse_track(smf_import *s,const unsigned char *data,size_t begin,size_t end,unsigned int division) +{ + size_t at=begin,i;uint64_t tick=0;unsigned int running=0,ch,pitch;int eot=0; + for(ch=0;ch<16;++ch)for(pitch=0;pitch<128;++pitch)s->head[ch][pitch]=s->tail[ch][pitch]=-1; + while(atevents>SMF_MAX_EVENTS)return XTC_ERR_CAPACITY; + if(!get_vlq(data,end,&at,&delta) || at>=end)return XTC_ERR_FORMAT; + tick+=delta;if(tick>XTC_PROJECT_MAX_TICK*division/960)return XTC_ERR_CAPACITY; + status=data[at]; + if(status&128){++at;if(status<0xf0)running=status;else running=0;} + else {if(!running)return XTC_ERR_FORMAT;status=running;} + if(status==0xff){ + if(at>=end)return XTC_ERR_FORMAT; + type=data[at++];if(type>127 || !get_vlq(data,end,&at,&length) || length>end-at)return XTC_ERR_FORMAT; + if(type==0x2f){if(length || at!=end)return XTC_ERR_FORMAT;eot=1;break;} + if(type==0x51 && (length!=3 || !(data[at]|data[at+1]|data[at+2])))return XTC_ERR_FORMAT; + at+=length;continue; + } + if(status==0xf0 || status==0xf7){if(!get_vlq(data,end,&at,&length) || length>end-at)return XTC_ERR_FORMAT;at+=length;continue;} + if(status<0x80 || status>=0xf0 || at>=end)return XTC_ERR_FORMAT; + a=data[at++];if(a>127)return XTC_ERR_FORMAT; + type=status&0xf0;ch=status&15; + if(type!=0xc0 && type!=0xd0){if(at>=end)return XTC_ERR_FORMAT;b=data[at++];if(b>127)return XTC_ERR_FORMAT;} + if(type==0x90 && b){ + xtc_note_info *note;int index; + if(s->count==XTC_PROJECT_MAX_NOTES)return XTC_ERR_CAPACITY; + index=(int)s->count;note=s->notes+s->count++;*note=(xtc_note_info){0,0,tick,0,a,b,ch};s->next[index]=-1; + if(s->head[ch][a]<0)s->head[ch][a]=index;else s->next[s->tail[ch][a]]=index; + s->tail[ch][a]=index; + }else if(type==0x80 || (type==0x90 && !b)){ + int index=s->head[ch][a];if(index<0)continue; /* Harmless unmatched release. */ + if(tick<=s->notes[index].start_tick)return XTC_ERR_FORMAT; + s->notes[index].length_tick=tick-s->notes[index].start_tick; + s->head[ch][a]=s->next[index];if(s->head[ch][a]<0)s->tail[ch][a]=-1; + } + } + if(!eot)return XTC_ERR_FORMAT; + for(i=0;i<16;++i)for(pitch=0;pitch<128;++pitch)if(s->head[i][pitch]>=0)return XTC_ERR_FORMAT; + if(tick>s->end)s->end=tick; + return XTC_OK; +} +xtc_result xtc_project_import_midi(xtc_project *p,uint64_t track,uint64_t start,const char *path,uint64_t *out_clip) +{ + unsigned char *data;smf_import *s;xtc_project *copy=NULL;size_t size,at,i;FILE *f;xtc_result r=XTC_OK; + unsigned int format,tracks,division;uint64_t clip=0,length;size_t track_index; + if(!p || !path || !*path || !out_clip || start>XTC_PROJECT_MAX_TICK)return XTC_ERR_INVALID_ARGUMENT; + for(track_index=0;track_indextrack_count;++track_index)if(p->tracks[track_index].id==track)break; + if(track_index==p->track_count)return XTC_ERR_NOT_FOUND; + if(p->tracks[track_index].is_bus)return XTC_ERR_INVALID_STATE; + f=fopen(path,"rb");if(!f)return errno==ENOENT ? XTC_ERR_NOT_FOUND : XTC_ERR_IO; + data=malloc(SMF_INPUT_BYTES+1);s=calloc(1,sizeof(*s)); + if(!data || !s){fclose(f);free(data);free(s);return XTC_ERR_OUT_OF_MEMORY;} + size=fread(data,1,SMF_INPUT_BYTES+1,f);if(ferror(f))r=XTC_ERR_IO;if(fclose(f)!=0)r=XTC_ERR_IO; + if(r==XTC_OK && size>SMF_INPUT_BYTES)r=XTC_ERR_CAPACITY; + if(r==XTC_OK && (size<14 || memcmp(data,"MThd",4) || get32(data+4)<6 || get32(data+4)>size-8))r=XTC_ERR_FORMAT; + if(r!=XTC_OK)goto done; + format=get16(data+8);tracks=get16(data+10);division=get16(data+12);at=8+get32(data+4); + if(format>1 || (division&0x8000)){r=XTC_ERR_UNSUPPORTED;goto done;} + if(!division || !tracks || (format==0 && tracks!=1)){r=XTC_ERR_FORMAT;goto done;} + if(tracks>64){r=XTC_ERR_CAPACITY;goto done;} + for(i=0;r==XTC_OK && isize-at){r=XTC_ERR_FORMAT;break;} + r=parse_track(s,data,at,at+bytes,division);at+=bytes; + } + if(r==XTC_OK && at!=size)r=XTC_ERR_FORMAT; + if(r==XTC_OK && !s->count)r=XTC_ERR_NOT_FOUND; + if(r!=XTC_OK)goto done; + length=(s->end*960+division/2)/division;if(!length)length=1; + for(i=0;icount;++i){ + xtc_note_info *note=s->notes+i;uint64_t end=((note->start_tick+note->length_tick)*960+division/2)/division; + note->start_tick=(note->start_tick*960+division/2)/division; + if(end<=note->start_tick)end=note->start_tick+1; + note->length_tick=end-note->start_tick;if(end>length)length=end; + } + if(length>XTC_PROJECT_MAX_TICK-start){r=XTC_ERR_CAPACITY;goto done;} + r=xtc_project_clone(p,©); + if(r==XTC_OK)r=xtc_project_add_midi_clip(copy,track,start,length,&clip); + for(i=0;r==XTC_OK && icount;++i){xtc_note_info *note=s->notes+i;uint64_t id; + r=xtc_project_add_note(copy,clip,note->start_tick,note->length_tick,note->pitch,note->velocity,note->channel,&id); + } + if(r==XTC_OK){xtc_graph *old=p->graph;*p=*copy;copy->graph=old;*out_clip=clip;} +done: + xtc_project_destroy(copy);free(s);free(data);return r; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index b0eab5a..78260e8 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -372,6 +372,17 @@ int midi_workflow_tests() 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);} + size_t tracks=0,clips=0;CHECK(xtc_project_get_counts(s->project(),&tracks,&clips)==XTC_OK);auto original_clips=clips; + s->position(3840);CHECK(window.import_midi_file(midi.c_str())==XTC_OK); + CHECK(xtc_project_get_counts(s->project(),&tracks,&clips)==XTC_OK && clips==original_clips+1); + xtc_clip_info imported{};CHECK(xtc_project_find_clip(s->project(),s->selected_clip(),&imported)==XTC_OK && imported.start_tick==3840 && imported.midi); + CHECK(window.undo()==XTC_OK && xtc_project_get_counts(s->project(),&tracks,&clips)==XTC_OK && clips==original_clips); + original=s->project();submit_result=XTC_ERR_INVALID_STATE;CHECK(window.import_midi_file(midi.c_str())==XTC_ERR_INVALID_STATE && s->project()==original);submit_result=XTC_OK; + CHECK(window.import_midi_file("/no-such-xtc-file.mid")==XTC_ERR_NOT_FOUND && s->project()==original); + CHECK(window.new_project()==XTC_OK && window.import_midi_file(midi.c_str())==XTC_OK); + CHECK(xtc_project_get_counts(s->project(),&tracks,&clips)==XTC_OK && tracks==1 && clips==1); + CHECK(window.undo()==XTC_OK && xtc_project_get_counts(s->project(),&tracks,&clips)==XTC_OK && !tracks && !clips); + CHECK(window.undo(true)==XTC_OK && xtc_project_get_counts(s->project(),&tracks,&clips)==XTC_OK && tracks==1 && clips==1); 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/integration/test_reliability.c b/tests/integration/test_reliability.c index c76984f..56d79ac 100644 --- a/tests/integration/test_reliability.c +++ b/tests/integration/test_reliability.c @@ -8,6 +8,7 @@ #include "xtc/xtc_editing.h" #include "xtc/xtc_audio.h" #include "xtc/xtc_sample.h" +#include "xtc/xtc_midi_file.h" #include #include #include @@ -294,6 +295,26 @@ static void sample_failures(void) } xtc_sample_destroy(source);xtc_project_destroy(p);assert(rmdir(root)==0); } +static void midi_file_failures(void) +{ + xtc_project *p=NULL;uint64_t t,c,n;char path[]="/tmp/xtc-midi-failure-XXXXXX";int fd=mkstemp(path); + assert(fd>=0);close(fd);assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"MIDI",&t)==XTC_OK); + assert(xtc_project_add_midi_clip(p,t,0,1920,&c)==XTC_OK && xtc_project_add_note(p,c,0,480,60,100,2,&n)==XTC_OK); + assert(xtc_project_export_midi_clip(p,c,path)==XTC_OK); + for(int operation=0;operation<2;++operation){int succeeded=0; + for(int failure=0;failure<128;++failure){ + xtc_project *copy=NULL;uint64_t out=999;size_t tracks,clips,notes;xtc_result r; + assert(xtc_project_clone(p,©)==XTC_OK);fail_after=failure; + r=operation ? xtc_project_import_midi(copy,t,3840,path,&out) : xtc_project_export_midi_clip(copy,c,path);fail_after=-1; + if(r==XTC_OK)succeeded=1; + else{assert(r==XTC_ERR_OUT_OF_MEMORY && out==999); + assert(xtc_project_get_counts(copy,&tracks,&clips)==XTC_OK && tracks==1 && clips==1 && xtc_project_note_count(copy,¬es)==XTC_OK && notes==1); + {FILE *f=fopen(path,"rb");char signature[4];assert(f && fread(signature,1,4,f)==4 && !memcmp(signature,"MThd",4));fclose(f);} + } + xtc_project_destroy(copy);if(succeeded)break; + }assert(succeeded); + }xtc_project_destroy(p);unlink(path); +} static void strum_failures(void) { xtc_project *p=NULL;uint64_t t,c,n;int succeeded=0; @@ -340,6 +361,7 @@ static void track_insert_failures(void) } int main(void) { + midi_file_failures(); strum_failures(); track_insert_failures(); sample_failures(); diff --git a/tests/unit/test_midi_file.c b/tests/unit/test_midi_file.c index 3de575d..05b30bb 100644 --- a/tests/unit/test_midi_file.c +++ b/tests/unit/test_midi_file.c @@ -1,5 +1,6 @@ #define _POSIX_C_SOURCE 200809L #include "xtc/xtc_midi_file.h" +#include "xtc/xtc_mixer.h" #include "../../src/midi/output.h" #include #include @@ -24,6 +25,39 @@ static void output_tests(void) 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); } +static void write_bytes(const char *path,const unsigned char *data,size_t size) +{ + FILE *f=fopen(path,"wb");assert(f && fwrite(data,1,size,f)==size && fclose(f)==0); +} +static void import_tests(void) +{ + static const unsigned char song[]={ + 'M','T','h','d',0,0,0,6,0,1,0,2,1,0xe0, + 'M','T','r','k',0,0,0,12,0,0xff,0x51,3,7,0xa1,0x20,0x87,0x40,0xff,0x2f,0, + 'M','T','r','k',0,0,0,27, + 0,0xc1,10,0,0xf0,2,1,0xf7, + 0,0x91,60,90,0,64,80,0x81,0x70,60,0,0,64,0,0x81,0x70,0xff,0x2f,0 + }; + unsigned char bad[sizeof(song)];char path[]="/tmp/xtc-midi-import-XXXXXX";int fd=mkstemp(path); + xtc_project *p=NULL;uint64_t t,c=999;xtc_clip_info clip;xtc_note_info note;size_t tracks,clips,notes; + assert(fd>=0);close(fd);assert(xtc_project_create(&p)==XTC_OK && xtc_project_add_track(p,"Import",&t)==XTC_OK); + assert(xtc_project_set_tempo(p,135000)==XTC_OK);write_bytes(path,song,sizeof(song)); + assert(xtc_project_import_midi(p,t,3840,path,&c)==XTC_OK && xtc_project_tempo(p)==135000); + assert(xtc_project_find_clip(p,c,&clip)==XTC_OK && clip.start_tick==3840 && clip.length_tick==1920); + assert(xtc_project_note_count(p,¬es)==XTC_OK && notes==2); + assert(xtc_project_get_note(p,0,¬e)==XTC_OK && note.start_tick==0 && note.length_tick==480 && note.channel==1 && note.pitch==60 && note.velocity==90); + assert(xtc_project_get_note(p,1,¬e)==XTC_OK && note.pitch==64 && note.length_tick==480 && note.velocity==80); + for(size_t size=0;size