feat(midi): export selected clip as an atomic Standard MIDI File (phase 103)
This commit is contained in:
parent
e9d0c62fd6
commit
02ee1818a3
9 changed files with 178 additions and 5 deletions
10
Makefile
10
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
16
include/xtc/xtc_midi_file.h
Normal file
16
include/xtc/xtc_midi_file.h
Normal 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
|
||||
|
|
@ -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;}
|
||||
|
|
|
|||
|
|
@ -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
60
src/midi/file.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
52
tests/unit/test_midi_file.c
Normal file
52
tests/unit/test_midi_file.c
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue