From 47b7d38a6653941d95180c59db5c590760d64792 Mon Sep 17 00:00:00 2001 From: hanez Date: Wed, 28 Oct 2026 10:37:01 +0100 Subject: [PATCH] Phase 35: add persistent preferences and sequencer loop playback --- Makefile | 11 +- docs/PREFERENCES.md | 68 +++++++++++ docs/RELEASE_NOTES.md | 5 + docs/ROADMAP.md | 24 ++++ include/xtc/xtc_backend.h | 4 + include/xtc/xtc_settings.h | 37 ++++++ src/backend/jack_backend.c | 110 +++++++++-------- src/gui/main_window.cpp | 175 +++++++++++++++++++++------- src/gui/main_window.hpp | 16 ++- src/gui/piano_roll.cpp | 4 +- src/gui/piano_roll.hpp | 5 + src/gui/plugin_library.hpp | 74 ++++++++++++ src/gui/preferences_dialog.hpp | 69 +++++++++++ src/gui/sequencer_widget.cpp | 32 ++++- src/gui/sequencer_widget.hpp | 8 ++ src/settings/settings.c | 173 +++++++++++++++++++++++++++ tests/integration/test_gui.cpp | 70 ++++++++--- tests/integration/test_gui_jack.cpp | 2 +- tests/integration/test_workflow.cpp | 4 +- tests/unit/test_backend.c | 40 +++++++ tests/unit/test_settings.c | 29 +++++ 21 files changed, 852 insertions(+), 108 deletions(-) create mode 100644 docs/PREFERENCES.md create mode 100644 include/xtc/xtc_settings.h create mode 100644 src/gui/plugin_library.hpp create mode 100644 src/gui/preferences_dialog.hpp create mode 100644 src/settings/settings.c create mode 100644 tests/unit/test_settings.c diff --git a/Makefile b/Makefile index 74ca728..5a32b02 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -CORE_SOURCES := 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/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) @@ -511,3 +511,12 @@ test: test-instruments .PHONY: test-gui-instruments test-gui-instruments: $(BUILD_DIR)/test_gui $(BUILD_DIR)/clap-synth-fixture.clap $(BUILD_DIR)/vst2-synth-fixture.so $(BUILD_DIR)/vst3-synth-fixture.so XTC_TEST_SYNTH_CLAP="$(abspath $(BUILD_DIR))/clap-synth-fixture.clap" XTC_TEST_SYNTH_VST2="$(abspath $(BUILD_DIR))/vst2-synth-fixture.so" XTC_TEST_SYNTH_VST3="$(abspath $(BUILD_DIR))/vst3-synth-fixture.so" xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_gui + +$(BUILD_DIR)/test_settings: $(BUILD_DIR)/tests/unit/test_settings.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm -ldl +.PHONY: test-settings +test-settings: $(BUILD_DIR)/test_settings + ./$(BUILD_DIR)/test_settings +test: test-settings + +-include $(BUILD_DIR)/tests/unit/test_settings.d diff --git a/docs/PREFERENCES.md b/docs/PREFERENCES.md new file mode 100644 index 0000000..7a6fa65 --- /dev/null +++ b/docs/PREFERENCES.md @@ -0,0 +1,68 @@ +# Application preferences and looping + +Open **Edit / Preferences** (Ctrl+,). Each tab edits application settings. +**Apply** validates every tab, saves `~/.xtc.conf`, and applies the values. +**Close** discards edits made since the last Apply. Apply stops playback; stop +recording before opening Preferences. Missing configuration uses defaults. +Malformed configuration reports an error and uses defaults without overwriting +it. Invalid input or failed writes preserve the previous saved configuration. + +The file is INI with `[audio]`, `[plugins]`, `[interface]`, `[editing]`, +`[project_defaults]` and `[transport]` sections. Save uses a mode-0600 temporary +file, flushes and fsyncs it, then atomically renames it. Unknown keys/sections, +duplicate keys, invalid ranges, embedded NULs and files larger than 64 KiB are +rejected. Blank lines and full-line `;` / `#` comments are supported. Strings +may be quoted, with `\\` and `\"` escapes. Apply rewrites comments and formatting. + +| Section | Settings and defaults | +| --- | --- | +| audio | `client_name="xtc"`, `max_frames=65536`, `channels=2`, `transition_ms=5`, `offline_rate=48000`, `recording_seconds=2` | +| plugins | `clap_paths`, `vst2_paths`, `vst3_paths`, `ladspa_paths`: colon-separated `~/.clap`, `~/.vst`, `~/.vst3`, `~/.ladspa`, followed by corresponding `/usr/local/lib/` and `/usr/lib/` directories | +| interface | `width=1280`, `height=800`, `piano_width=900`, `piano_height=620`, `refresh_ms=100` | +| editing | `sequencer_snap=240`, `piano_snap=240`, `sequencer_zoom=48`, `clip_ticks=3840`, `note_ticks=960`, `note_velocity=100`, `note_channel=0`, `filter_channel=-1`, `filter_low=0`, `filter_high=127` | +| project_defaults | `tempo=120000` (milli-BPM), `volume_cc=7`, `pan_cc=10`, `sustain_cc=64` (128 disables a mapping) | +| transport | `loop_start=0`, `loop_end=3840`, `loop_enabled=0` | + +Audio client name, block capacity, output channels and fades take effect on +**Reconnect**. JACK chooses the actual device, rate and hardware buffer size; +these remain JACK server settings. Offline rate is used when disconnected. +Recording capacity applies to the next take. Tempo and CC maps apply to **New** +projects. Track mix, routing, instruments, plugin parameters, automation and +musical content remain project data, saved through Save Project. Internal +safety limits and plugin ABI requirements are not runtime preferences. + +Snap accepts 1 (Off), 120, 160, 240, 320, 480, 960, 1920 or 3840 ticks. +Zoom accepts 24, 48, 96, 192 or 384 pixels per beat. MIDI channels are zero-based; +filter channel -1 shows all channels. Window sizes, grids and filters currently +in use populate Preferences when it opens; press Apply to persist them. + +Plugin selection now opens a library of `.clap` and `.so` files from the search +paths. `~/` expands to the home directory; empty/nonabsolute roots are skipped. +Traversal does not follow directory symlinks and stops at depth six, 4096 entries +or 512 files. Linux VST3 bundle binaries are found inside bundle directories. +Listing reads filenames only. **Inspect plugin** explicitly loads the selected +trusted binary through the existing descriptor chooser; **Browse files** allows +other locations. It is not a plugin certification or isolation service. + +## Loop playback + +**Shift-drag the sequencer ruler** to select a region using the selected Snap. +Dragging in either direction works. A band shows the region; **Loop** enables +or disables it. **Loop range...** also accepts start and end in ticks. Selection +and toggling take effect immediately; Apply in Preferences persists the region. +The region is global application configuration and is not stored in `.xtc` +projects. Changing a region during playback stops, reconfigures and restarts the +backend. Selecting a region does not itself enable looping. + +Playback wraps at the exclusive end sample, including inside an audio block, +and starts at the region beginning when the transport lies outside the region. +The region follows the current project's tempo. Enabled regions must span at +least 64 samples at the active rate. Scheduled MIDI uses the existing seek/reset +and note-chase behavior at a wrap. Effects retain tails/state. No loop-boundary +crossfade is added, so discontinuous audio can click. Recording requires looping +to be disabled. Export retains its explicit linear range. + +Validation covers settings roundtrips, malformed/duplicate/overflow/NUL input, +private permissions, save failure, GUI Apply/Close/reload and plugin listing, +plus JACK callback wrap positions and existing GUI/core regressions. Tests use +temporary configuration paths and do not write the user's configuration. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 48734e6..e7ea962 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -74,3 +74,8 @@ and trims, MIDI double-click editing, a piano velocity lane/slider and per-track instrument dropdowns. Snap preferences are UI state; velocity and instrument bindings use the existing v12 format. No new dependency or engine feature is required. See WORKSPACE.md for supported divisions, controller semantics and limits. + +Phase 35 adds Edit / Preferences with an Apply button and `~/.xtc.conf` INI +persistence, configurable plugin search paths and a file library, plus sequencer +ruler loop selection and sample-boundary playback wrapping. See PREFERENCES.md +for settings, reconnect behavior and looping limits. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4240bb8..4a4bf00 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -989,6 +989,30 @@ patches control its audible response. See WORKSPACE.md and MIDI.md. --- +## Phase 35 — Application Preferences and Sequencer Looping + +### Goal +Persist application preferences in `~/.xtc.conf` and provide selectable sequencer loops. + +### Tasks and acceptance criteria +- [x] Add a flat, tabbed Edit / Preferences dialog with Apply and Close. +- [x] Validate and atomically save all preference tabs as a private INI file; load at startup. +- [x] Expose audio, interface, editing, new-project, plugin-path and transport settings. +- [x] List plugin files from configurable CLAP, VST2, VST3 and LADSPA search paths without executing modules. +- [x] Select a snapped loop range by Shift-dragging the ruler or entering tick bounds. +- [x] Toggle looping and split audio processing at exact sample boundaries. +- [x] Preserve real-time constraints and reject recording with looping enabled. +- [x] Verify persistence, invalid input, GUI Apply/reload, backend wraps and existing regressions. + +### Implementation status +Implemented without dependencies or a project format change. Application settings +are saved only with Apply; audio connection options take effect on Reconnect and +project defaults on New. Existing projects retain their musical data. Loop ranges +are global preferences, not project data. Enabled loops require at least 64 samples. +See [PREFERENCES.md](PREFERENCES.md) for configuration, workflow and limitations. + +--- + ## Recommended Execution Order Reorder the phases when the audit reveals a blocking dependency. Do not mark a phase complete merely because code exists: its acceptance criteria must be demonstrated by builds, tests, or documented manual verification. diff --git a/include/xtc/xtc_backend.h b/include/xtc/xtc_backend.h index a0a2a4f..1a8f9ba 100644 --- a/include/xtc/xtc_backend.h +++ b/include/xtc/xtc_backend.h @@ -106,6 +106,10 @@ xtc_result xtc_backend_request_stop(xtc_backend *backend); * @return XTC_OK on success, or an error code describing the failure. */ xtc_result xtc_backend_seek(xtc_backend *backend, uint64_t sample); +/* Stopped-only loop [start,end) in samples. Enabled loops require >=64 samples + * and cannot be combined with recording. Callback splits at exact boundaries; + * live MIDI offsets remain relative to the JACK block. */ +xtc_result xtc_backend_set_loop(xtc_backend *backend, uint64_t start, uint64_t end, int enabled); /* Copies graph. While running, capacity means pending slot busy. * While stopped, transactionally replaces all retained plans (counters/revisions * restart); failed compilation leaves the previous engine intact. */ diff --git a/include/xtc/xtc_settings.h b/include/xtc/xtc_settings.h new file mode 100644 index 0000000..4d90a81 --- /dev/null +++ b/include/xtc/xtc_settings.h @@ -0,0 +1,37 @@ +#ifndef XTC_SETTINGS_H +#define XTC_SETTINGS_H +#include "xtc.h" +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +/* Control-thread application preferences; not project data or audio state. */ +typedef struct { + char client_name[64], clap_paths[2048], vst2_paths[2048], vst3_paths[2048], ladspa_paths[2048]; + int64_t max_frames, channels, transition_ms, offline_rate, recording_seconds; + int64_t width, height, piano_width, piano_height, refresh_ms; + int64_t sequencer_snap, piano_snap, sequencer_zoom; + int64_t clip_ticks, note_ticks, note_velocity, note_channel, filter_channel, filter_low, filter_high; + int64_t tempo, volume_cc, pan_cc, sustain_cc; + int64_t loop_start, loop_end, loop_enabled; +} xtc_settings; +typedef struct { + const char *section, *key, *label; + size_t offset, capacity; /* capacity > 0 means string; otherwise int64_t. */ + int64_t minimum, maximum; +} xtc_setting_info; +const xtc_setting_info *xtc_settings_schema(size_t *count); +void xtc_settings_defaults(xtc_settings *settings); +xtc_result xtc_settings_validate(const xtc_settings *settings); +xtc_result xtc_settings_get(const xtc_settings *settings, size_t index, char *out, size_t size); +xtc_result xtc_settings_set(xtc_settings *settings, size_t index, const char *value); +xtc_result xtc_settings_path(char *out, size_t size); +/* Strict bounded INI. Missing file returns NOT_FOUND; failure retains destination. + * Save validates then writes a 0600 temporary, fsyncs and atomically renames it. */ +xtc_result xtc_settings_load(xtc_settings *settings, const char *path); +xtc_result xtc_settings_save(const xtc_settings *settings, const char *path); +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c index eea8cec..d155f1c 100644 --- a/src/backend/jack_backend.c +++ b/src/backend/jack_backend.c @@ -29,6 +29,8 @@ struct xtc_backend { unsigned int stop_requested, stop_done; int stop_seen; /* Audio owner. */ uint64_t sample_position; + uint64_t loop_start, loop_end; + int loop_enabled; unsigned int expected_rate; unsigned int rate, frames, lost, rate_changed, xruns; unsigned int process_result; @@ -45,22 +47,22 @@ static void publish_peak(unsigned int *slot, float peak) } /** - * @brief Decodes queued JACK MIDI and renders the instrument for the current audio block. + * @brief Decodes queued JACK MIDI once for the current audio block. * * @param backend Backend handle. * @param position Absolute sample position at the start of the block. * @param frames Number of audio frames. - * @return XTC_OK on success, or an error code describing the failure. + * @param events Caller-owned bounded event storage. + * @return Number of decoded events; malformed input requests a MIDI panic. */ -static xtc_result render_midi(xtc_backend *backend, uint64_t position, jack_nframes_t frames) +static size_t decode_midi(xtc_backend *backend, uint64_t position, jack_nframes_t frames, xtc_midi_event events[XTC_MIDI_MAX_EVENTS]) { - xtc_midi_event events[XTC_MIDI_MAX_EVENTS]; size_t count = 0; uint32_t i, total; unsigned int requested = __atomic_load_n(&backend->panic_requested, __ATOMIC_ACQUIRE); void *buffer; if (!backend->midi) - return XTC_OK; + return 0; if (requested != backend->panic_seen) { xtc_midi_plan_panic(backend->midi, position); backend->panic_seen = requested; @@ -91,9 +93,7 @@ static xtc_result render_midi(xtc_backend *backend, uint64_t position, jack_nfra } ++count; } - return xtc_midi_plan_render_stereo(backend->midi, position, events, count, backend->instrument, - backend->output_right ? backend->instrument + backend->max_frames : NULL, - (size_t)frames); + return count; } /** @@ -137,27 +137,50 @@ static int process(jack_nframes_t frames, void *context) } else { const float *input = jack_port_get_buffer(backend->input, frames); uint64_t position = __atomic_load_n(&backend->sample_position, __ATOMIC_RELAXED); - (void)xtc_realtime_position(backend->engine,position); - if (backend->recorder) - xtc_recorder_push(backend->recorder, input, (size_t)frames); - { - size_t channel; + xtc_midi_event events[XTC_MIDI_MAX_EVENTS], segment_events[XTC_MIDI_MAX_EVENTS]; + size_t count = decode_midi(backend,position,frames,events), offset = 0; + if (backend->recorder) xtc_recorder_push(backend->recorder,input,(size_t)frames); + while (offset < frames && result == XTC_OK) { + size_t n = (size_t)frames-offset, i, queued = 0, channel; + if (backend->loop_enabled) { + if (position < backend->loop_start || position >= backend->loop_end) position = backend->loop_start; + if (backend->loop_end-position < n) n = (size_t)(backend->loop_end-position); + } + if (position > UINT64_MAX-n) { result = XTC_ERR_CAPACITY; break; } for (channel = 0; channel < backend->meter_count*2; ++channel) - memset(backend->monitor + channel*backend->max_frames, 0, (size_t)frames*sizeof(float)); + memset(backend->monitor+channel*backend->max_frames,0,n*sizeof(float)); + (void)xtc_realtime_position(backend->engine,position); + if (backend->audio) + result = xtc_audio_plan_render_stereo(backend->audio,position,backend->timeline, + right ? backend->timeline+backend->max_frames : NULL,n); + for (i = 0; i < count; ++i) if (events[i].offset >= offset && events[i].offset < offset+n) { + segment_events[queued] = events[i]; segment_events[queued++].offset -= (unsigned int)offset; + } + if (result == XTC_OK && backend->midi) + result = xtc_midi_plan_render_stereo(backend->midi,position,segment_events,queued,backend->instrument, + right ? backend->instrument+backend->max_frames : NULL,n); + if (result == XTC_OK) + result = xtc_realtime_process_stereo(backend->engine,input ? input+offset : NULL, + backend->audio ? backend->timeline : NULL, + backend->audio && right ? backend->timeline+backend->max_frames : NULL, + backend->midi ? backend->instrument : NULL, + backend->midi && right ? backend->instrument+backend->max_frames : NULL, + output+offset,right ? right+offset : NULL,n); + if (result == XTC_OK) { + size_t track, side, frame; + for (track = 0; track < backend->meter_count; ++track) for (side = 0; side < 2; ++side) { + float peak = 0; + for (frame = 0; frame < n; ++frame) { + float value = fabsf(backend->monitor[(track*2+side)*backend->max_frames+frame]); + if (isfinite(value) && value > peak) peak = value; + } + publish_peak(&backend->meter_peaks[track][side],peak); + } + } + offset += n; position += n; + if (backend->loop_enabled && position == backend->loop_end) position = backend->loop_start; } - if (backend->audio) - result = - xtc_audio_plan_render_stereo(backend->audio, position, backend->timeline, - right ? backend->timeline + backend->max_frames : NULL, (size_t)frames); - if (result == XTC_OK) - result = render_midi(backend, position, frames); - if (result == XTC_OK) - result = - xtc_realtime_process_stereo(backend->engine, input, backend->audio ? backend->timeline : NULL, - backend->audio && right ? backend->timeline + backend->max_frames : NULL, - backend->midi ? backend->instrument : NULL, - backend->midi && right ? backend->instrument + backend->max_frames : NULL, - output, right, (size_t)frames); + if (result == XTC_OK) __atomic_store_n(&backend->sample_position,position,__ATOMIC_RELAXED); } if (result == XTC_OK && backend->transition_frames) { if (__atomic_load_n(&backend->stop_requested, __ATOMIC_ACQUIRE) && !backend->stop_seen) { @@ -168,13 +191,6 @@ static int process(jack_nframes_t frames, void *context) if (backend->stop_seen && !backend->ramp.remaining) __atomic_store_n(&backend->stop_done, 1U, __ATOMIC_RELEASE); } - if (result == XTC_OK) { - uint64_t position = __atomic_load_n(&backend->sample_position, __ATOMIC_RELAXED); - if (position > UINT64_MAX - frames) - result = XTC_ERR_CAPACITY; - else - __atomic_store_n(&backend->sample_position, position + frames, __ATOMIC_RELAXED); - } if (result != XTC_OK) { (void)__atomic_fetch_add(&backend->panic_requested, 1U, __ATOMIC_RELEASE); if (backend->recorder) @@ -195,18 +211,6 @@ static int process(jack_nframes_t frames, void *context) } publish_peak(backend->master_peaks, left_peak); publish_peak(backend->master_peaks+1, right_peak); - if (result == XTC_OK) { - size_t track, side, frame; - for (track = 0; track < backend->meter_count; ++track) - for (side = 0; side < 2; ++side) { - float peak = 0; - for (frame = 0; frame < frames; ++frame) { - float value = fabsf(backend->monitor[(track*2+side)*backend->max_frames+frame]); - if (isfinite(value) && value > peak) peak = value; - } - publish_peak(&backend->meter_peaks[track][side], peak); - } - } __atomic_store_n(&backend->peak_left, (unsigned int)(fminf(left_peak, 1000) * 1000000), __ATOMIC_RELAXED); __atomic_store_n(&backend->peak_right, (unsigned int)(fminf(right_peak, 1000) * 1000000), __ATOMIC_RELAXED); } @@ -592,6 +596,7 @@ const char *xtc_backend_asset_issue(const xtc_backend *backend) */ xtc_result xtc_backend_set_recorder(xtc_backend *backend, xtc_recorder *recorder) { + if (backend && recorder && backend->loop_enabled) return XTC_ERR_INVALID_STATE; if (!backend) return XTC_ERR_INVALID_ARGUMENT; if (!backend->ready || backend->running) @@ -639,6 +644,13 @@ xtc_result xtc_backend_midi_port_name(const xtc_backend *backend, const char **o } /* Control-only configuration; applied to source plans on next preparation. */ +xtc_result xtc_backend_set_loop(xtc_backend *backend, uint64_t start, uint64_t end, int enabled) +{ + if (!backend || (enabled != 0 && enabled != 1) || (enabled && (end <= start || end-start < 64))) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready || backend->running || (enabled && backend->recorder)) return XTC_ERR_INVALID_STATE; + backend->loop_start = start; backend->loop_end = end; backend->loop_enabled = enabled; return XTC_OK; +} + xtc_result xtc_backend_set_transition_frames(xtc_backend *backend, size_t frames) { if (!backend || frames > 65536) return XTC_ERR_INVALID_ARGUMENT; @@ -1099,6 +1111,12 @@ xtc_result xtc_backend_disconnect(xtc_backend *backend, xtc_port_direction direc (void)remote; return XTC_ERR_UNSUPPORTED; } +xtc_result xtc_backend_set_loop(xtc_backend *backend, uint64_t start, uint64_t end, int enabled) +{ + (void)start; (void)end; (void)enabled; + return backend ? XTC_ERR_UNSUPPORTED : XTC_ERR_INVALID_ARGUMENT; +} + xtc_result xtc_backend_set_transition_frames(xtc_backend *backend, size_t frames) { (void)backend; (void)frames; diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index b0a5fd6..e2d8895 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -7,6 +7,9 @@ #include "theme.hpp" #include "snap.hpp" #include "velocity_lane.hpp" +#include "preferences_dialog.hpp" +#include "plugin_library.hpp" +#include #include #include #include @@ -64,14 +67,21 @@ class BusyOperation { * @param prompt_unsaved Whether open, new, and close actions ask before * discarding unsaved changes. */ -MainWindow::MainWindow(bool prompt_unsaved) +MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) : Fl_Double_Window(1280, 800, XTC_PROJECT_NAME " — modular audio workstation"), prompt_unsaved_(prompt_unsaved) { xtc_ui::initialize(); + xtc_settings_defaults(&settings_); + char config_path[4096]; + if(configuration) configuration_=configuration; + else if(xtc_settings_path(config_path,sizeof(config_path))==XTC_OK) configuration_=config_path; + xtc_result config_result=configuration_.empty() ? XTC_ERR_NOT_FOUND : xtc_settings_load(&settings_,configuration_.c_str()); + xtc_project *initial = nullptr; xtc_result result = xtc_project_create(&initial); if (result != XTC_OK) throw std::runtime_error(xtc_result_string(result)); + project_defaults(initial); project_.reset(initial); xtc_history *history = nullptr; result = xtc_history_create(initial, &history); @@ -87,6 +97,7 @@ MainWindow::MainWindow(bool prompt_unsaved) 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); menu->add("File/Relink selected audio", 0, audio_file_callback, this); + menu->add("Edit/Preferences",FL_CTRL+',',[](Fl_Widget *,void *c){static_cast(c)->show_preferences();},this); menu->add("Edit/Undo", FL_CTRL + 'z', history_callback, this); menu->add("Edit/Redo", FL_CTRL + FL_SHIFT + 'z', history_callback, this); menu->add("Edit/Redo alternate", FL_CTRL + 'y', history_callback, this); @@ -150,7 +161,20 @@ MainWindow::MainWindow(bool prompt_unsaved) transport_position_->tooltip("Musical position: bar : beat : tick (4/4, 960 ticks per beat)"); tempo_display_ = new Fl_Box(880,40,110,30,"120.0 BPM"); auto *hint = new Fl_Box(12,82,1256,28,"xtc / ARRANGEMENT Create tracks, arrange clips and shape your sound."); - hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); + hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); hint->resize(12,82,970,28); + loop_button_=new Fl_Check_Button(1000,82,100,28,"Loop"); + loop_button_->callback([](Fl_Widget *widget,void *c){auto *w=static_cast(c); + xtc_result r=w->set_loop(static_cast(w->settings_.loop_start),static_cast(w->settings_.loop_end),static_cast(widget)->value()!=0); + if(r!=XTC_OK) w->report_error("Loop unchanged",r); + w->refresh_status();},this); + auto *range=new Fl_Button(1110,82,150,28,"Loop range..."); + range->tooltip("Shift-drag the sequencer ruler to select a loop, or enter start/end ticks here."); + range->callback([](Fl_Widget *,void *c){auto *w=static_cast(c);char initial[100]; + std::snprintf(initial,sizeof(initial),"%lld %lld",static_cast(w->settings_.loop_start),static_cast(w->settings_.loop_end)); + const char *text=fl_input("Loop start and end ticks (end excluded)",initial);if(!text)return; + unsigned long long start,end;char extra;xtc_result r=XTC_ERR_INVALID_ARGUMENT; + if(std::sscanf(text,"%llu %llu %c",&start,&end,&extra)==2)r=w->set_loop(start,end,w->settings_.loop_enabled!=0); + if(r!=XTC_OK)w->report_error("Loop unchanged",r);},this); tabs_ = new Fl_Tabs(216, 126, 1056, 578); tabs_->begin(); @@ -173,6 +197,7 @@ MainWindow::MainWindow(bool prompt_unsaved) sequencer_ = new SequencerWidget(228, 206, project_.get(), seek, this); timeline_scroll->end(); sequencer_->before_edit = before_arrangement_edit; + sequencer_->loop_select=[](void *c,uint64_t start,uint64_t end){auto *w=static_cast(c);return w->set_loop(start,end,w->settings_.loop_enabled!=0);}; sequencer_->open_clip = [](void *c) { piano_callback(nullptr,c); }; sequencer_->instrument_select = [](void *c, uint64_t track, const xtc_plugin_spec *spec, bool browse) { auto *w = static_cast(c); @@ -361,8 +386,10 @@ MainWindow::MainWindow(bool prompt_unsaved) resizable(tabs_); size_range(1220, 720); callback(close_callback, this); + apply_ui_preferences(); open_backend(); - Fl::add_timeout(0.1, timer_callback, this); + if(config_result!=XTC_OK && config_result!=XTC_ERR_NOT_FOUND) report_error("Preferences file invalid; using defaults",config_result); + Fl::add_timeout(static_cast(settings_.refresh_ms)/1000,timer_callback,this); } void MainWindow::draw() @@ -472,7 +499,7 @@ xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, siz */ void MainWindow::open_backend() { - xtc_backend_config config = {XTC_BACKEND_JACK, XTC_PROJECT_NAME, 65536U, 2U}; + xtc_backend_config config = {XTC_BACKEND_JACK, settings_.client_name, static_cast(settings_.max_frames), static_cast(settings_.channels)}; xtc_result result = xtc_backend_open(&config, &backend_); ready_ = result == XTC_OK; running_ = false; @@ -482,7 +509,7 @@ void MainWindow::open_backend() xtc_backend_status format{}; result = xtc_backend_get_status(backend_, &format); if (result == XTC_OK) - result = xtc_backend_set_transition_frames(backend_, format.sample_rate / 200U); + result = xtc_backend_set_transition_frames(backend_, static_cast(format.sample_rate)*static_cast(settings_.transition_ms)/1000U); if (result == XTC_OK) result = xtc_backend_submit(backend_, xtc_project_graph(project_.get())); if (result != XTC_OK) { @@ -509,7 +536,8 @@ void MainWindow::start_engine() } if (running_) return; - xtc_result result = xtc_backend_prepare_project(backend_, project_.get()); + xtc_result result = configure_loop(project_.get(),settings_); + if(result==XTC_OK) result = xtc_backend_prepare_project(backend_, project_.get()); if (result == XTC_OK) result = xtc_backend_start(backend_); if (result != XTC_OK) @@ -849,7 +877,7 @@ void MainWindow::timer_callback(void *context) } window->refresh_status(); if (!window->closing_) - Fl::repeat_timeout(0.1, timer_callback, context); + Fl::repeat_timeout(static_cast(window->settings_.refresh_ms)/1000,timer_callback,context); } /** @@ -965,19 +993,20 @@ xtc_result MainWindow::install_project(xtc_project *candidate, bool reset_histor r = xtc_backend_stop(backend_); if (r == XTC_OK) r = xtc_backend_seek(backend_, 0); + if(r==XTC_OK) r=configure_loop(candidate,settings_); if (r == XTC_OK) r = xtc_backend_prepare_project(backend_, candidate); } xtc_audio_plan *offline = nullptr; if (r == XTC_OK && !ready_) - r = xtc_audio_plan_create(candidate, 48000, &offline); + r = xtc_audio_plan_create(candidate, static_cast(settings_.offline_rate), &offline); xtc_midi_plan *offline_midi = nullptr; if (r == XTC_OK && !ready_) - r = xtc_midi_plan_create(candidate, 48000, &offline_midi); + r = xtc_midi_plan_create(candidate, static_cast(settings_.offline_rate), &offline_midi); xtc_midi_plan_destroy(offline_midi); if (r == XTC_OK && !ready_) { xtc_plan *graph = nullptr; - r = xtc_graph_compile(xtc_project_graph(candidate),48000,256,&graph); + r = xtc_graph_compile(xtc_project_graph(candidate),static_cast(settings_.offline_rate),256,&graph); xtc_plan_destroy(graph); } if (r != XTC_OK) { @@ -1054,7 +1083,7 @@ xtc_result MainWindow::open_project(const char *path) xtc_node_info node{}; xtc_plugin_spec spec{}; if (xtc_graph_get_node(graph,i,&node) != XTC_OK || node.type != XTC_NODE_PLUGIN || xtc_graph_get_plugin(graph,node.id,&spec) != XTC_OK) continue; - double rate = 48000; xtc_backend_status status{}; + double rate = sample_rate(); xtc_backend_status status{}; if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; xtc_plugin *check = nullptr; xtc_result plugin_result = xtc_plugin_create(&spec,rate,256,&check); @@ -1081,6 +1110,7 @@ xtc_result MainWindow::new_project() return XTC_ERR_INVALID_STATE; xtc_project *candidate = nullptr; xtc_result result = xtc_project_create(&candidate); + if(result==XTC_OK) project_defaults(candidate); if (result == XTC_OK) result = install_project(candidate); if (result != XTC_OK) @@ -1217,7 +1247,7 @@ xtc_result MainWindow::import_audio(const char *path) */ xtc_result MainWindow::record_audio(const char *path) { - if (!ready_ || recorder_) + if (!ready_ || recorder_ || settings_.loop_enabled) return XTC_ERR_INVALID_STATE; xtc_result r = xtc_backend_stop(backend_); xtc_backend_status status{}; @@ -1228,7 +1258,7 @@ xtc_result MainWindow::record_audio(const char *path) if (r == XTC_OK) r = xtc_backend_prepare_project(backend_, project_.get()); if (r == XTC_OK) - r = xtc_recorder_create(path, status.sample_rate, static_cast(status.sample_rate) * 2, &recorder_); + r = xtc_recorder_create(path, status.sample_rate, static_cast(status.sample_rate) * static_cast(settings_.recording_seconds), &recorder_); if (r != XTC_OK) return r; recording_path_ = path; @@ -1406,6 +1436,9 @@ xtc_result MainWindow::open_piano_roll() xtc_ui::style(piano_window_.get()); piano_window_->resizable(scroll); scroll->scroll_to(0, 60 * 16); + settings_.width=w();settings_.height=h(); + settings_.sequencer_snap=static_cast(sequencer_->grid());settings_.sequencer_zoom=sequencer_->zoom_level(); + apply_ui_preferences(); } else piano_->set_project(project_.get(), clip.id); refresh_workspace(); @@ -1525,7 +1558,7 @@ xtc_result MainWindow::create_track(bool midi) uint64_t track = UINT64_MAX, clip = UINT64_MAX; xtc_result r = xtc_project_clone(project_.get(),&candidate); if (r == XTC_OK) r = xtc_project_add_track(candidate,midi ? "MIDI track" : "Audio track",&track); - if (r == XTC_OK && midi) r = xtc_project_add_midi_clip(candidate,track,position,3840,&clip); + if (r == XTC_OK && midi) r = xtc_project_add_midi_clip(candidate,track,position,static_cast(settings_.clip_ticks),&clip); if (r == XTC_OK) r = xtc_history_reserve(history_.get()); if (r == XTC_OK) r = install_project(candidate,false); if (r != XTC_OK) xtc_project_destroy(candidate); @@ -1537,6 +1570,7 @@ xtc_result MainWindow::create_track(bool midi) } void MainWindow::refresh_workspace() { + if(loop_button_) {loop_button_->value(settings_.loop_enabled!=0);if(recorder_)loop_button_->deactivate();else loop_button_->activate();} if (snap_) snap_->value(xtc_ui::snap_index(sequencer_->grid())); if (piano_) { piano_snap_->value(xtc_ui::snap_index(piano_->grid())); @@ -1579,7 +1613,7 @@ xtc_result MainWindow::assign_instrument(uint64_t track, const char *path, unsig if (recorder_) return XTC_ERR_INVALID_STATE; xtc_project *candidate = nullptr; xtc_plugin_spec spec{}; - xtc_result r = path ? xtc_plugin_probe(path,index,48000,&spec) : XTC_OK; + xtc_result r = path ? xtc_plugin_probe(path,index,sample_rate(),&spec) : XTC_OK; if (r == XTC_OK && path && !spec.instrument) return XTC_ERR_UNSUPPORTED; if (r == XTC_OK) r = xtc_project_clone(project_.get(),&candidate); if (r == XTC_OK) r = xtc_project_set_instrument(candidate,track,path ? &spec : nullptr); @@ -1611,13 +1645,10 @@ void MainWindow::instrument_callback(Fl_Widget *widget, void *context) else if (std::strcmp(label,"Live MIDI to selected instrument") == 0) r = w->select_live_instrument(track); else if (std::strcmp(label,"Live MIDI to built-in sine") == 0) r = w->select_live_instrument(UINT64_MAX); else { - Fl_Native_File_Chooser chooser; - chooser.title("Choose trusted mono instrument binary for selected track"); - chooser.filter("Plugin binaries\t*.{so,clap}\nAll files\t*"); - if (chooser.show() != 0 || !chooser.filename()) return; - unsigned int index = 0; - if (!xtc_choose_plugin(chooser.filename(),48000,index,true)) return; - r = w->assign_instrument(track,chooser.filename(),index); + std::string module; if(!xtc_choose_plugin_file(w->settings_,module)) return; + unsigned int index=0; + if(!xtc_choose_plugin(module.c_str(),w->sample_rate(),index,true))return; + r=w->assign_instrument(track,module.c_str(),index); } if (r != XTC_OK) w->report_error("Instrument assignment failed; select a non-bus track and compatible mono instrument",r); } @@ -1668,7 +1699,7 @@ xtc_result MainWindow::add_plugin(const char *path, unsigned int index, size_t * { BusyOperation busy(*this,status_,"Loading plugin and preparing playback…"); if (recorder_) return XTC_ERR_INVALID_STATE; - double rate = 48000; + double rate = sample_rate(); xtc_backend_status status{}; if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; xtc_plugin_spec spec{}; @@ -1689,7 +1720,7 @@ xtc_result MainWindow::replace_plugin(size_t id, const char *path, unsigned int { BusyOperation busy(*this,status_,"Replacing plugin and preparing playback…"); if (recorder_) return XTC_ERR_INVALID_STATE; - double rate = 48000; xtc_backend_status status{}; + double rate = sample_rate(); xtc_backend_status status{}; if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; xtc_plugin_spec spec{}; xtc_result r = xtc_plugin_probe(path,index,rate,&spec); @@ -1700,7 +1731,7 @@ xtc_result MainWindow::replace_plugin(size_t id, const char *path, unsigned int xtc_result MainWindow::reset_plugin(size_t id, unsigned int port) { if (recorder_) return XTC_ERR_INVALID_STATE; - double rate = 48000; xtc_backend_status status{}; + double rate = sample_rate(); xtc_backend_status status{}; if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; xtc_plugin_spec spec{}; xtc_result r = xtc_graph_get_plugin(xtc_project_graph(project_.get()),id,&spec); @@ -1710,7 +1741,7 @@ xtc_result MainWindow::reset_plugin(size_t id, unsigned int port) xtc_result MainWindow::insert_plugin(const xtc_connection_info &wire, const char *path, unsigned int index, size_t *id) { if (recorder_) return XTC_ERR_INVALID_STATE; - double rate = 48000; xtc_backend_status status{}; + double rate = sample_rate(); xtc_backend_status status{}; if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; xtc_plugin_spec spec{}; xtc_result r = xtc_plugin_probe(path,index,rate,&spec); @@ -1744,7 +1775,7 @@ xtc_result MainWindow::set_plugin_control(size_t id, unsigned int port, float va xtc_result MainWindow::set_plugin_automation(size_t id, const xtc_plugin_point &point, bool remove) { if (recorder_) return XTC_ERR_INVALID_STATE; - double rate = 48000; xtc_backend_status status{}; + double rate = sample_rate(); xtc_backend_status status{}; if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; xtc_project *candidate = nullptr; xtc_result r = xtc_project_clone(project_.get(),&candidate); @@ -1762,7 +1793,7 @@ xtc_result MainWindow::apply_plugin_spec(size_t id, const xtc_plugin_spec &spec, if (r == XTC_OK) r = replace ? xtc_graph_replace_plugin(graph,id,&spec) : xtc_graph_set_plugin(graph,id,&spec); /* Compile before adoption even with a mocked/disconnected backend. */ xtc_plan *check = nullptr; - xtc_backend_status status{}; double rate = 48000; + xtc_backend_status status{}; double rate = sample_rate(); if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; if (r == XTC_OK) r = xtc_graph_compile(graph,rate,256,&check); xtc_plan_destroy(check); @@ -1793,28 +1824,26 @@ void MainWindow::plugin_callback(Fl_Widget *widget, void *context) r = xtc_graph_get_plugin(xtc_project_graph(w->project_.get()),selected,&old); if (r != XTC_OK) { w->report_error("Select a plugin node to replace",r); return; } } - Fl_Native_File_Chooser chooser; - chooser.title("Choose trusted plugin binary (LADSPA, CLAP, VST2, VST3)"); chooser.filter("Plugin binaries\t*.{so,clap}\nAll files\t*"); - if (chooser.show() != 0 || !chooser.filename()) return; - double rate = 48000; xtc_backend_status status{}; + std::string module; if(!xtc_choose_plugin_file(w->settings_,module))return; + double rate = w->sample_rate(); xtc_backend_status status{}; if (w->ready_ && xtc_backend_get_status(w->backend_,&status) == XTC_OK) rate = status.sample_rate; unsigned int index = 0; xtc_plugin_spec probe{}; - r = xtc_plugin_probe(chooser.filename(),0,rate,&probe); + r = xtc_plugin_probe(module.c_str(),0,rate,&probe); if (r != XTC_OK && r != XTC_ERR_UNSUPPORTED) { w->report_error("Cannot discover plugin; check binary path, format, mono layout and dependencies",r); return; } - if (!xtc_choose_plugin(chooser.filename(),rate,index)) return; - if (replace) r = w->replace_plugin(selected,chooser.filename(),index); - else if (insert) r = w->insert_plugin(wire,chooser.filename(),index); - else r = w->add_plugin(chooser.filename(),index); + if (!xtc_choose_plugin(module.c_str(),rate,index)) return; + if (replace) r = w->replace_plugin(selected,module.c_str(),index); + else if (insert) r = w->insert_plugin(wire,module.c_str(),index); + else r = w->add_plugin(module.c_str(),index); } else { size_t id = w->graph_widget_->selected_node(); xtc_plugin_spec spec{}; r = xtc_graph_get_plugin(xtc_project_graph(w->project_.get()),id,&spec); if (r == XTC_OK) { std::string prompt = std::string(spec.label) + " control ports and values:\n"; if (std::strcmp(label,"Selected plugin control") == 0) { - double rate = 48000; xtc_backend_status status{}; + double rate = w->sample_rate(); xtc_backend_status status{}; if (w->ready_ && xtc_backend_get_status(w->backend_,&status) == XTC_OK) rate = status.sample_rate; xtc_plugin_details details{}; xtc_plugin_spec edited{}; r = xtc_plugin_describe(&spec,rate,&details); @@ -1823,7 +1852,7 @@ void MainWindow::plugin_callback(Fl_Widget *widget, void *context) return; } xtc_parameter_info info[XTC_PLUGIN_CONTROLS]{}; - double rate = 48000; xtc_backend_status status{}; + double rate = w->sample_rate(); xtc_backend_status status{}; if (w->ready_ && xtc_backend_get_status(w->backend_,&status) == XTC_OK) rate = status.sample_rate; r = xtc_plugin_parameters(&spec,rate,info); if (r != XTC_OK) { w->report_error("Read plugin controls failed",r); return; } @@ -1849,3 +1878,71 @@ void MainWindow::plugin_callback(Fl_Widget *widget, void *context) } if (r != XTC_OK) w->report_error("Plugin change failed: check module, mono layout, control port/range and JACK format",r); } + +void MainWindow::project_defaults(xtc_project *project) +{ + (void)xtc_project_set_tempo(project,static_cast(settings_.tempo)); + unsigned int cc[3]={static_cast(settings_.volume_cc),static_cast(settings_.pan_cc),static_cast(settings_.sustain_cc)}; + for(unsigned int channel=0;channel<16;++channel)(void)xtc_project_set_control_map(project,channel,cc); +} +double MainWindow::sample_rate() const +{ + xtc_backend_status status{}; + return ready_ && xtc_backend_get_status(backend_,&status)==XTC_OK ? status.sample_rate : static_cast(settings_.offline_rate); +} +xtc_result MainWindow::configure_loop(const xtc_project *project,const xtc_settings &settings) +{ + if(!ready_)return XTC_OK; + uint64_t start,end;unsigned int rate=static_cast(sample_rate()); + xtc_result r=xtc_project_tick_to_sample(project,static_cast(settings.loop_start),rate,&start); + if(r==XTC_OK)r=xtc_project_tick_to_sample(project,static_cast(settings.loop_end),rate,&end); + if(r==XTC_OK)r=xtc_backend_set_loop(backend_,start,end,settings.loop_enabled!=0); + return r; +} +xtc_result MainWindow::set_loop(uint64_t start,uint64_t end,bool enabled) +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + if(start>=end || end>XTC_PROJECT_MAX_TICK)return XTC_ERR_INVALID_ARGUMENT; + xtc_settings candidate=settings_;candidate.loop_start=static_cast(start);candidate.loop_end=static_cast(end);candidate.loop_enabled=enabled; + bool resume=running_;xtc_result r=ready_ ? xtc_backend_stop(backend_) : XTC_OK; + if(r==XTC_OK)r=configure_loop(project_.get(),candidate); + if(r==XTC_OK) {settings_=candidate;sequencer_->loop_region(start,end,enabled);} + if(ready_ && resume && r==XTC_OK) {xtc_result restarted=xtc_backend_start(backend_);if(r==XTC_OK)r=restarted;} + refresh_status();return r; +} +void MainWindow::collect_preferences() +{ + settings_.width=w();settings_.height=h();settings_.sequencer_snap=static_cast(sequencer_->grid());settings_.sequencer_zoom=sequencer_->zoom_level(); + if(piano_) { + settings_.piano_width=piano_window_->w();settings_.piano_height=piano_window_->h();settings_.piano_snap=static_cast(piano_->grid()); + settings_.filter_channel=piano_->filter_channel();settings_.filter_low=piano_->filter_low();settings_.filter_high=piano_->filter_high(); + } +} +void MainWindow::apply_ui_preferences() +{ + resize(x(),y(),static_cast(settings_.width),static_cast(settings_.height)); + sequencer_->grid(static_cast(settings_.sequencer_snap));sequencer_->set_zoom(static_cast(settings_.sequencer_zoom)); + sequencer_->new_clip_ticks=static_cast(settings_.clip_ticks); + sequencer_->loop_region(static_cast(settings_.loop_start),static_cast(settings_.loop_end),settings_.loop_enabled!=0); + if(piano_) { + piano_window_->resize(piano_window_->x(),piano_window_->y(),static_cast(settings_.piano_width),static_cast(settings_.piano_height)); + piano_->set_grid(static_cast(settings_.piano_snap));piano_->set_filter(static_cast(settings_.filter_channel),static_cast(settings_.filter_low),static_cast(settings_.filter_high)); + piano_->new_note_ticks=static_cast(settings_.note_ticks);piano_->new_note_velocity=static_cast(settings_.note_velocity);piano_->new_note_channel=static_cast(settings_.note_channel); + } +} +xtc_result MainWindow::apply_preferences(const xtc_settings &candidate) +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + xtc_result r=xtc_settings_validate(&candidate);if(r!=XTC_OK)return r; + if(configuration_.empty())return XTC_ERR_INVALID_STATE; + if(ready_)r=xtc_backend_stop(backend_); + if(r==XTC_OK)r=configure_loop(project_.get(),candidate); + if(r==XTC_OK)r=xtc_settings_save(&candidate,configuration_.c_str()); + if(r!=XTC_OK) {if(ready_)(void)configure_loop(project_.get(),settings_);refresh_status();return r;} + settings_=candidate;apply_ui_preferences();refresh_status();return XTC_OK; +} +void MainWindow::show_preferences() +{ + if(recorder_) {report_error("Stop recording before preferences",XTC_ERR_INVALID_STATE);return;} + collect_preferences();PreferencesDialog dialog(settings_,configuration_,[this](const xtc_settings &s){return apply_preferences(s);});dialog.run(); +} diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 748175d..4f9be5d 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -9,6 +9,7 @@ #include "xtc/xtc_mixer.h" #include "xtc/xtc_plugin.h" #include "xtc/xtc_control.h" +#include "xtc/xtc_settings.h" class GraphWidget; class MixerWidget; @@ -20,6 +21,7 @@ class Fl_Tabs; class Fl_Menu_Bar; class Fl_Choice; class Fl_Value_Slider; +class Fl_Check_Button; class MainWindow : public Fl_Double_Window { public: @@ -27,6 +29,10 @@ class MainWindow : public Fl_Double_Window { * Preparation stops/resets transport; recording and failed candidates retain * the current document. No engine/model changes happen in audio callbacks. */ xtc_result create_track(bool midi); + const xtc_settings &settings() const { return settings_; } + xtc_result apply_preferences(const xtc_settings &settings); + void show_preferences(); + xtc_result set_loop(uint64_t start,uint64_t end,bool enabled); Fl_Widget *velocity_lane() const { return velocity_lane_; } /* Control-thread plugin commands, transactional model adoption. */ xtc_result assign_instrument(uint64_t track, const char *path, unsigned int index); @@ -49,7 +55,7 @@ class MainWindow : public Fl_Double_Window { * @param prompt_unsaved Whether open, new, and close actions ask before * discarding unsaved changes. */ - explicit MainWindow(bool prompt_unsaved = true); + explicit MainWindow(bool prompt_unsaved = true, const char *configuration = nullptr); /** * @brief Removes the status timer and releases backend and recording state. * @@ -178,6 +184,14 @@ class MainWindow : public Fl_Double_Window { xtc_result return_to_start(); private: + xtc_settings settings_{}; + std::string configuration_; + Fl_Check_Button *loop_button_=nullptr; + xtc_result configure_loop(const xtc_project *project,const xtc_settings &settings); + void apply_ui_preferences(); + void collect_preferences(); + void project_defaults(xtc_project *project); + double sample_rate() const; void draw() override; void refresh_workspace(); Fl_Choice *snap_ = nullptr, *piano_snap_ = nullptr; diff --git a/src/gui/piano_roll.cpp b/src/gui/piano_roll.cpp index 02f1a9c..13082ef 100644 --- a/src/gui/piano_roll.cpp +++ b/src/gui/piano_roll.cpp @@ -282,8 +282,8 @@ int PianoRollWidget::handle(int event) uint64_t start = tick(Fl::event_x()); if (pitch >= static_cast(low_) && pitch <= static_cast(high_) && xtc_project_find_clip(project_, clip_, &c) == XTC_OK && start < c.source_length_tick) - add_note(start, std::min(960, c.source_length_tick - start), - static_cast(pitch), 100, channel_filter_ < 0 ? 0U : static_cast(channel_filter_)); + add_note(start, std::min(new_note_ticks, c.source_length_tick - start), + static_cast(pitch), new_note_velocity, channel_filter_ < 0 ? new_note_channel : static_cast(channel_filter_)); } redraw(); return 1; diff --git a/src/gui/piano_roll.hpp b/src/gui/piano_roll.hpp index 55df253..c3f3f0c 100644 --- a/src/gui/piano_roll.hpp +++ b/src/gui/piano_roll.hpp @@ -72,6 +72,11 @@ class PianoRollWidget : public Fl_Widget { xtc_project *project() const { return project_; } bool note_visible(const xtc_note_info ¬e) const { return visible(note); } int note_x(uint64_t tick) const { return x()+64+static_cast(tick*96/960); } + uint64_t new_note_ticks=960; + unsigned int new_note_velocity=100,new_note_channel=0; + int filter_channel() const { return channel_filter_; } + unsigned int filter_low() const { return low_; } + unsigned int filter_high() const { return high_; } uint64_t grid() const { return grid_; } xtc_result set_velocity(uint64_t note, unsigned int velocity); uint64_t selected_note() const { return selected_; } diff --git a/src/gui/plugin_library.hpp b/src/gui/plugin_library.hpp new file mode 100644 index 0000000..9a15286 --- /dev/null +++ b/src/gui/plugin_library.hpp @@ -0,0 +1,74 @@ +#ifndef XTC_PLUGIN_LIBRARY_HPP +#define XTC_PLUGIN_LIBRARY_HPP +#include "xtc/xtc_settings.h" +#include "theme.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +inline std::vector xtc_plugin_files(const xtc_settings &settings) +{ + namespace fs=std::filesystem; + std::set files; + size_t visited=0; + for(const char *list : {settings.clap_paths,settings.vst2_paths,settings.vst3_paths,settings.ladspa_paths}) { + std::string paths=list; size_t start=0; + while(start<=paths.size()) { + size_t end=paths.find(':',start); std::string root=paths.substr(start,end==std::string::npos ? end : end-start); + if(root.rfind("~/",0)==0) { char config[4096]; if(xtc_settings_path(config,sizeof(config))==XTC_OK) { + std::string home=config; home.resize(home.size()-10); root=home+root.substr(1); + } } + std::error_code error; + if(!root.empty() && fs::path(root).is_absolute()) { + fs::recursive_directory_iterator it(root,fs::directory_options::skip_permission_denied,error), finish; + while(!error && it!=finish && visited<4096 && files.size()<512) { + ++visited; + if(it.depth()>=6) it.disable_recursion_pending(); + if(it->is_regular_file(error)) { + auto extension=it->path().extension().string(); + if(extension==".so" || extension==".clap") files.insert(it->path().lexically_normal().string()); + } + error.clear(); it.increment(error); + } + } + if(end==std::string::npos) break; + start=end+1; + } + } + return {files.begin(),files.end()}; +} +inline bool xtc_choose_plugin_file(const xtc_settings &settings,std::string &path) +{ + struct Library : Fl_Double_Window { + bool accepted=false,browse=false; + Library():Fl_Double_Window(900,520,"Plugin library — trusted binaries") {set_modal();} + } dialog; + auto files=xtc_plugin_files(settings); + auto *hint=new Fl_Box(16,10,868,44,"Files from Preferences search paths. Select a trusted binary to inspect its plugins.\nNo module code is executed while listing. Browse can select another binary."); + hint->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE); + auto *list=new Fl_Hold_Browser(16,62,868,370); list->format_char(0); + for(const auto &file:files) list->add(file.c_str()); + if(!files.empty()) list->select(1); + auto *browse=new Fl_Button(16,462,130,32,"Browse files..."); + browse->callback([](Fl_Widget *,void *c){auto *d=static_cast(c);d->browse=true;d->hide();},&dialog); + auto *cancel=new Fl_Button(650,462,100,32,"Cancel"); + cancel->callback([](Fl_Widget *,void *c){static_cast(c)->hide();},&dialog); + auto *choose=new Fl_Button(764,462,120,32,"Inspect plugin"); + choose->callback([](Fl_Widget *,void *c){auto *d=static_cast(c);d->accepted=true;d->hide();},&dialog); + if(files.empty()) choose->deactivate(); + dialog.callback([](Fl_Widget *,void *c){static_cast(c)->hide();},&dialog); + dialog.end(); xtc_ui::style(&dialog); dialog.show(); while(dialog.shown()) Fl::wait(); + if(dialog.browse) { + Fl_Native_File_Chooser chooser; chooser.title("Choose trusted plugin binary"); chooser.filter("Plugin binaries\t*.{so,clap}\nAll files\t*"); + if(chooser.show()!=0 || !chooser.filename()) return false; + path=chooser.filename(); return true; + } + if(!dialog.accepted || list->value()<1) return false; + path=files[static_cast(list->value()-1)]; return true; +} +#endif diff --git a/src/gui/preferences_dialog.hpp b/src/gui/preferences_dialog.hpp new file mode 100644 index 0000000..5ed757d --- /dev/null +++ b/src/gui/preferences_dialog.hpp @@ -0,0 +1,69 @@ +#ifndef XTC_PREFERENCES_DIALOG_HPP +#define XTC_PREFERENCES_DIALOG_HPP +#include "xtc/xtc_settings.h" +#include "theme.hpp" +#include +#include +#include +#include +#include +#include +#include + +class PreferencesDialog : public Fl_Double_Window { + xtc_settings original_; + std::function apply_; + std::vector inputs_; + Fl_Box *message_; + static void apply_callback(Fl_Widget *,void *context) { + auto *d=static_cast(context); + xtc_settings candidate=d->original_; size_t count=0; + const xtc_setting_info *schema=xtc_settings_schema(&count); + for(size_t i=0;iinputs_[i]->value())!=XTC_OK) { + std::string text=std::string("Invalid value: ")+schema[i].label; + d->message_->copy_label(text.c_str()); return; + } + xtc_result r=xtc_settings_validate(&candidate); + if(r==XTC_OK) r=d->apply_(candidate); + if(r==XTC_OK) { + d->original_=candidate; + d->message_->copy_label("Saved and applied. Audio options take effect on Reconnect; project defaults on New."); + } else { + std::string text=std::string("Preferences unchanged: ")+xtc_result_string(r)+". Check values, loop range and configuration file permissions."; + d->message_->copy_label(text.c_str()); + } + d->redraw(); + } + public: + PreferencesDialog(const xtc_settings &settings,const std::string &path, + std::function apply) + : Fl_Double_Window(1050,660,"xtc — Preferences"),original_(settings),apply_(std::move(apply)) { + set_modal(); begin(); + auto *location=new Fl_Box(16,12,1018,30); location->copy_label(path.c_str()); location->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE); + auto *tabs=new Fl_Tabs(12,50,1026,510); tabs->begin(); + size_t count=0; const auto *schema=xtc_settings_schema(&count); + inputs_.resize(count); std::string section; Fl_Group *group=nullptr; Fl_Scroll *scroll=nullptr; int row=0; + for(size_t i=0;iend(); group->end(); } + section=schema[i].section; tabs->begin(); + group=new Fl_Group(12,82,1026,478); group->copy_label(section.c_str()); group->begin(); + scroll=new Fl_Scroll(22,94,1000,450); scroll->begin(); row=0; + } + auto *input=new Fl_Input(510,108+row*48,480,30,schema[i].label); + input->align(FL_ALIGN_LEFT); input->maximum_size(schema[i].capacity ? static_cast(schema[i].capacity-1) : 22); + char value[4096]; xtc_settings_get(&settings,i,value,sizeof(value)); input->value(value); + inputs_[i]=input; ++row; + } + if(scroll) { scroll->end(); group->end(); } tabs->end(); + message_=new Fl_Box(16,568,1018,36,"Apply saves every tab. Existing project contents are not overwritten by defaults."); + message_->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE|FL_ALIGN_WRAP); + auto *apply_button=new Fl_Button(812,614,100,30,"Apply"); apply_button->callback(apply_callback,this); + auto *close=new Fl_Button(924,614,110,30,"Close"); + close->callback([](Fl_Widget *,void *c){static_cast(c)->hide();},this); + callback([](Fl_Widget *,void *c){static_cast(c)->hide();},this); + end(); xtc_ui::style(this); + } + void run() { show(); while(shown()) Fl::wait(); } +}; +#endif diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 0810813..a5a48c9 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -161,7 +161,7 @@ xtc_result SequencerWidget::add_region() track_ = t.id; } uint64_t id; - xtc_result r = xtc_project_add_clip(project_, track_, "Region (silent)", cursor_, 3840U, &id); + xtc_result r = xtc_project_add_clip(project_, track_, "Region (silent)", cursor_, new_clip_ticks, &id); if (r == XTC_OK) clip_ = id; changed(r); @@ -183,7 +183,7 @@ xtc_result SequencerWidget::add_midi_clip() track_ = r == XTC_OK ? t.id : UINT64_MAX; } if (r == XTC_OK) - r = xtc_project_add_midi_clip(project_, track_, cursor_, 3840, &id); + r = xtc_project_add_midi_clip(project_, track_, cursor_, new_clip_ticks, &id); if (r == XTC_OK) clip_ = id; } @@ -321,6 +321,14 @@ void SequencerWidget::draw() fl_draw("Create a MIDI track or import audio from the panel on the left.",x()+220,y()+135); fl_draw("Arrange clips here. Open the piano roll to draw and edit MIDI notes.",x()+220,y()+161); } + { + uint64_t begin=mode_==4 ? std::min(loop_anchor_,loop_preview_) : loop_begin_; + uint64_t end=mode_==4 ? std::max(loop_anchor_,loop_preview_) : loop_end_; + int left=x()+180+static_cast(begin*static_cast(scale_)/960); + int right=x()+180+static_cast(end*static_cast(scale_)/960); + fl_color(loop_enabled_ ? xtc_ui::accent() : xtc_ui::muted()); + fl_rectf(left,y()+23,std::max(0,right-left),4); + } int playhead = x()+180+static_cast(cursor_*static_cast(scale_)/960U); if (playhead >= cx && playhead <= cx+cw) { fl_color(xtc_ui::accent()); fl_line(playhead,y(),playhead,y()+h()); @@ -348,6 +356,10 @@ int SequencerWidget::handle(int event) if (event == FL_PUSH && (Fl::event_button() == FL_LEFT_MOUSE || Fl::event_button() == FL_RIGHT_MOUSE)) { take_focus(); mode_ = 0; + if (Fl::event_y() < y() + 28 && Fl::event_x() >= x()+180 && + Fl::event_button()==FL_LEFT_MOUSE && Fl::event_state(FL_SHIFT)) { + loop_anchor_=loop_preview_=tick(Fl::event_x()); mode_=4; redraw(); return 1; + } if (Fl::event_y() < y() + 28) { uint64_t target = tick(Fl::event_x()); xtc_result r = seek_(context_, target); @@ -400,6 +412,16 @@ int SequencerWidget::handle(int event) redraw(); return 1; } + if (event==FL_DRAG && mode_==4) {loop_preview_=tick(Fl::event_x());redraw();return 1;} + if (event==FL_RELEASE && mode_==4) { + loop_preview_=tick(Fl::event_x()); mode_=0; + uint64_t begin=std::min(loop_anchor_,loop_preview_),end=std::max(loop_anchor_,loop_preview_); + if(end>begin && loop_select) { + xtc_result r=loop_select(context_,begin,end); + if(r!=XTC_OK) fl_alert("Loop unchanged: %s",xtc_result_string(r)); + } + redraw();return 1; + } if (event == FL_DRAG) return mode_ != 0; if (event == FL_RELEASE && mode_) { @@ -653,3 +675,9 @@ void SequencerWidget::instrument_callback(Fl_Widget *widget, void *context) owner->refresh_instruments(); owner->redraw(); return; } } + +xtc_result SequencerWidget::set_zoom(int scale) +{ + if(scale!=24 && scale!=48 && scale!=96 && scale!=192 && scale!=384) return XTC_ERR_INVALID_ARGUMENT; + scale_=scale;extent();redraw();return XTC_OK; +} diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 194d504..3aefe69 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -16,6 +16,12 @@ class SequencerWidget : public Fl_Group { * * @return XTC_OK to allow the edit, or an error to cancel it. */ + using LoopSelect = xtc_result (*)(void *,uint64_t,uint64_t); + LoopSelect loop_select = nullptr; + uint64_t new_clip_ticks = 3840; + int zoom_level() const { return scale_; } + xtc_result set_zoom(int scale); + void loop_region(uint64_t start,uint64_t end,bool enabled) { loop_begin_=start; loop_end_=end; loop_enabled_=enabled; redraw(); } using OpenClip = void (*)(void *); using InstrumentSelect = void (*)(void *, uint64_t, const xtc_plugin_spec *, bool); OpenClip open_clip = nullptr; @@ -164,6 +170,8 @@ class SequencerWidget : public Fl_Group { Seek seek_; void *context_; uint64_t track_ = UINT64_MAX, clip_ = UINT64_MAX, cursor_ = 0, grid_ = 240; + uint64_t loop_begin_=0,loop_end_=3840,loop_anchor_=0,loop_preview_=0; + bool loop_enabled_=false; int scale_ = 48, drag_x_ = 0, mode_ = 0; xtc_clip_info original_{}; /** diff --git a/src/settings/settings.c b/src/settings/settings.c new file mode 100644 index 0000000..33bb399 --- /dev/null +++ b/src/settings/settings.c @@ -0,0 +1,173 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_settings.h" +#include +#include +#include +#include +#include +#include +#include +#include +#define NUM(section,field,label,min,max) {section,#field,label,offsetof(xtc_settings,field),0,min,max} +#define STR(section,field,label) {section,#field,label,offsetof(xtc_settings,field),sizeof(((xtc_settings *)0)->field),0,0} +static const xtc_setting_info schema[] = { + STR("audio",client_name,"JACK client name (Reconnect)"), + NUM("audio",max_frames,"Maximum block capacity (Reconnect)",32,65536), + NUM("audio",channels,"Output channels: 1 mono, 2 stereo (Reconnect)",1,2), + NUM("audio",transition_ms,"Start/stop and source fades, ms (Reconnect)",0,1000), + NUM("audio",offline_rate,"Offline preparation sample rate, Hz",8000,192000), + NUM("audio",recording_seconds,"Recording ring capacity, seconds (next take)",1,30), + STR("plugins",clap_paths,"CLAP search paths (colon-separated)"), + STR("plugins",vst2_paths,"VST2 search paths (colon-separated)"), + STR("plugins",vst3_paths,"VST3 search paths (colon-separated)"), + STR("plugins",ladspa_paths,"LADSPA search paths (colon-separated)"), + NUM("interface",width,"Main window width",1220,3840), NUM("interface",height,"Main window height",720,2160), + NUM("interface",piano_width,"Piano window width",800,3840), NUM("interface",piano_height,"Piano window height",520,2160), + NUM("interface",refresh_ms,"Display refresh interval, ms",20,1000), + NUM("editing",sequencer_snap,"Sequencer snap ticks (1 means Off)",1,3840), + NUM("editing",piano_snap,"Piano snap ticks (1 means Off)",1,3840), + NUM("editing",sequencer_zoom,"Sequencer pixels per beat: 24/48/96/192/384",24,384), + NUM("editing",clip_ticks,"New MIDI clip length, ticks",1,38400000), + NUM("editing",note_ticks,"New note length, ticks",1,38400000), + NUM("editing",note_velocity,"New note velocity",1,127), NUM("editing",note_channel,"New note channel",0,15), + NUM("editing",filter_channel,"Piano channel filter (-1 means all)",-1,15), + NUM("editing",filter_low,"Piano minimum pitch",0,127), NUM("editing",filter_high,"Piano maximum pitch",0,127), + NUM("project_defaults",tempo,"New project tempo, milli-BPM",20000,300000), + NUM("project_defaults",volume_cc,"New project volume CC (128 disables)",0,128), + NUM("project_defaults",pan_cc,"New project pan CC (128 disables)",0,128), + NUM("project_defaults",sustain_cc,"New project sustain CC (128 disables)",0,128), + NUM("transport",loop_start,"Loop start, ticks",0,38399999), NUM("transport",loop_end,"Loop end, ticks",1,38400000), + NUM("transport",loop_enabled,"Loop enabled: 0 or 1",0,1) +}; +#define COUNT (sizeof(schema)/sizeof(schema[0])) +const xtc_setting_info *xtc_settings_schema(size_t *count) { if (count) *count = COUNT; return schema; } +void xtc_settings_defaults(xtc_settings *s) +{ + if (!s) return; + memset(s,0,sizeof(*s)); strcpy(s->client_name,"xtc"); + strcpy(s->clap_paths,"~/.clap:/usr/local/lib/clap:/usr/lib/clap"); + strcpy(s->vst2_paths,"~/.vst:/usr/local/lib/vst:/usr/lib/vst"); + strcpy(s->vst3_paths,"~/.vst3:/usr/local/lib/vst3:/usr/lib/vst3"); + strcpy(s->ladspa_paths,"~/.ladspa:/usr/local/lib/ladspa:/usr/lib/ladspa"); + s->max_frames=65536; s->channels=2; s->transition_ms=5; s->offline_rate=48000; s->recording_seconds=2; + s->width=1280; s->height=800; s->piano_width=900; s->piano_height=620; s->refresh_ms=100; + s->sequencer_snap=s->piano_snap=240; s->sequencer_zoom=48; s->clip_ticks=3840; s->note_ticks=960; + s->note_velocity=100; s->filter_channel=-1; s->filter_high=127; s->tempo=120000; + s->volume_cc=7; s->pan_cc=10; s->sustain_cc=64; s->loop_end=3840; +} +static int snap(int64_t v) { return v==1 || v==120 || v==160 || v==240 || v==320 || v==480 || v==960 || v==1920 || v==3840; } +xtc_result xtc_settings_validate(const xtc_settings *s) +{ + size_t i; + if (!s) return XTC_ERR_INVALID_ARGUMENT; + for (i=0;ioffset; + if (p->capacity) { size_t j; if (!memchr(field,0,p->capacity)) return XTC_ERR_INVALID_ARGUMENT; + for (j=0;field[j];++j) if ((unsigned char)field[j]<32 || (unsigned char)field[j]==127) return XTC_ERR_INVALID_ARGUMENT; + } else { int64_t v; memcpy(&v,field,sizeof(v)); if (vminimum || v>p->maximum) return XTC_ERR_INVALID_ARGUMENT; } + } + if (!s->client_name[0] || !snap(s->sequencer_snap) || !snap(s->piano_snap) || + (s->sequencer_zoom!=24 && s->sequencer_zoom!=48 && s->sequencer_zoom!=96 && s->sequencer_zoom!=192 && s->sequencer_zoom!=384) || + s->filter_low>s->filter_high || s->loop_end<=s->loop_start) return XTC_ERR_INVALID_ARGUMENT; + { int64_t cc[3]={s->volume_cc,s->pan_cc,s->sustain_cc}; + for(i=0;i<3;++i) { size_t j; if(cc[i]==120 || cc[i]==123) return XTC_ERR_INVALID_ARGUMENT; + for(j=0;j=COUNT || !out || !size) return XTC_ERR_INVALID_ARGUMENT; + if(schema[i].capacity && !memchr((const char *)s+schema[i].offset,0,schema[i].capacity)) return XTC_ERR_INVALID_ARGUMENT; + if(schema[i].capacity) n=snprintf(out,size,"%s",(const char *)s+schema[i].offset); + else { int64_t v; memcpy(&v,(const char *)s+schema[i].offset,sizeof(v)); n=snprintf(out,size,"%" PRId64,v); } + return n<0 || (size_t)n>=size ? XTC_ERR_CAPACITY : XTC_OK; +} +xtc_result xtc_settings_set(xtc_settings *s,size_t i,const char *value) +{ + const xtc_setting_info *p; char *out; + if(!s || i>=COUNT || !value) return XTC_ERR_INVALID_ARGUMENT; + p=schema+i; out=(char *)s+p->offset; + if(p->capacity) { size_t j,n=strlen(value); if(n>=p->capacity) return XTC_ERR_CAPACITY; + for(j=0;jminimum || v>p->maximum) return XTC_ERR_INVALID_ARGUMENT; + memcpy(out,&v,sizeof(v)); } + return XTC_OK; +} +xtc_result xtc_settings_path(char *out,size_t size) +{ + const char *home=getenv("HOME"); int n; + if(!out || !size) return XTC_ERR_INVALID_ARGUMENT; + if(!home || !*home) { struct passwd *pw=getpwuid(getuid()); home=pw ? pw->pw_dir : NULL; } + if(!home || home[0]!='/') return XTC_ERR_NOT_FOUND; + n=snprintf(out,size,"%s/.xtc.conf",home); + return n<0 || (size_t)n>=size ? XTC_ERR_CAPACITY : XTC_OK; +} +static char *trim(char *p) { char *end; while(*p && isspace((unsigned char)*p)) ++p; end=p+strlen(p); while(end>p && isspace((unsigned char)end[-1])) --end; *end=0; return p; } +xtc_result xtc_settings_load(xtc_settings *s,const char *path) +{ + FILE *f; xtc_settings candidate; char line[8192],section[64]="",data[65537]; unsigned char seen[COUNT]; size_t bytes=0,offset=0; xtc_result r=XTC_OK; + if(!s || !path || !*path) return XTC_ERR_INVALID_ARGUMENT; + f=fopen(path,"rb"); if(!f) return errno==ENOENT ? XTC_ERR_NOT_FOUND : XTC_ERR_IO; + xtc_settings_defaults(&candidate); memset(seen,0,sizeof(seen)); + bytes=fread(data,1,sizeof(data),f); + if(ferror(f)) r=XTC_ERR_IO; + if(fclose(f)!=0) r=XTC_ERR_IO; + if(r!=XTC_OK)return r; + if(bytes==sizeof(data) || memchr(data,0,bytes))return XTC_ERR_FORMAT; + while (offset=sizeof(line)){r=XTC_ERR_FORMAT;break;} + line[n++]=data[offset++]; + } + if(r!=XTC_OK)break; + if(offset=sizeof(section)) {r=XTC_ERR_FORMAT;break;} + *end=0; + for(i=0;i=sizeof(temporary)) return XTC_ERR_CAPACITY; + fd=mkstemp(temporary); if(fd<0) return XTC_ERR_IO; + f=fdopen(fd,"wb"); if(!f) {close(fd);unlink(temporary);return XTC_ERR_IO;} + r=XTC_OK; if(fputs("; xtc application preferences. Saved by Preferences / Apply.\n",f)<0) r=XTC_ERR_IO; + for(i=0;i #include #include @@ -126,6 +128,8 @@ xtc_result xtc_backend_close(xtc_backend *backend) } xtc_result xtc_backend_start(xtc_backend *backend) { CHECK(backend == &handle); ++starts; if (start_result == XTC_OK) { handle.running = 1; engine_status.active_revision = engine_status.submitted_revision; } return start_result; } +xtc_result xtc_backend_set_loop(xtc_backend *,uint64_t start,uint64_t end,int enabled) +{ return enabled && (end<=start || end-start<64) ? XTC_ERR_INVALID_ARGUMENT : XTC_OK; } xtc_result xtc_backend_set_transition_frames(xtc_backend *, size_t frames) { CHECK(frames == 240); return XTC_OK; } xtc_result xtc_backend_request_stop(xtc_backend *backend) @@ -183,7 +187,7 @@ int main(int argc, char **argv) { reset(); { - MainWindow window(false); + MainWindow window(false,""); window.show(argc, argv); auto *play = find(&window, "Play"); auto *stop = find(&window, "Stop"); @@ -535,7 +539,7 @@ int main(int argc, char **argv) for (xtc_result failure : {XTC_ERR_IO, XTC_ERR_UNSUPPORTED}) { reset(); open_result = failure; - MainWindow window(false); + MainWindow window(false,""); window.show(argc, argv); CHECK(contains(&window, "Open JACK failed") && !find(&window, "Play")->active()); open_result = XTC_OK; @@ -549,21 +553,21 @@ int main(int argc, char **argv) partial_open = true; open_result = XTC_ERR_IO; { - MainWindow window(false); + MainWindow window(false,""); CHECK(alive == 1U && !find(&window, "Play")->active()); window.do_callback(); // Partially initialized handles are closed only. CHECK(alive == 0U); } reset(); for (int i = 0; i < 10; ++i) { - MainWindow window(false); + MainWindow window(false,""); press(find(&window, "Play")); window.do_callback(); } CHECK(alive == 0U); reset(); { - MainWindow window(false); auto *s = timeline(&window); + MainWindow window(false,""); auto *s = timeline(&window); CHECK(s->add_track() == XTC_OK && s->rename_selection("Keys") == XTC_OK); CHECK(s->add_region() == XTC_OK && s->rename_selection("Verse") == XTC_OK); CHECK(s->grid(120) == XTC_OK && s->grid() == 120 && s->grid(0) == XTC_ERR_INVALID_ARGUMENT); @@ -585,7 +589,7 @@ int main(int argc, char **argv) } { reset(); - MainWindow window(false); + MainWindow window(false,""); auto *canvas = graph_canvas(&window); CHECK(canvas != nullptr); CHECK(canvas->add_node(XTC_NODE_OSCILLATOR) == XTC_OK); @@ -628,7 +632,7 @@ int main(int argc, char **argv) window.do_callback(); } { - reset(); MainWindow window(false); + reset(); MainWindow window(false,""); auto *sequence = timeline(&window); auto *mixer = window.mixer(); CHECK(sequence->add_track() == XTC_OK); CHECK(mixer->add_bus() == XTC_OK); @@ -649,7 +653,7 @@ int main(int argc, char **argv) window.do_callback(); } { - reset(); MainWindow window(false); + reset(); MainWindow window(false,""); auto *sequence = timeline(&window); unsigned int bindings[3] = {1,74,11}, actual[3]; CHECK(window.set_control_map(2,bindings) == XTC_OK); @@ -674,7 +678,7 @@ int main(int argc, char **argv) unlink("/tmp/xtc-gui-controls-test.xtc"); window.do_callback(); } { - reset(); MainWindow window(false); + reset(); MainWindow window(false,""); auto *sequence = timeline(&window); auto *canvas = graph_canvas(&window); CHECK(sequence->add_track() == XTC_OK && sequence->add_midi_clip() == XTC_OK); const uint64_t track = sequence->selected_track(), clip = sequence->selected_clip(); @@ -705,7 +709,7 @@ int main(int argc, char **argv) } if (const char *module = std::getenv("XTC_TEST_PLUGIN")) { reset(); - MainWindow window(false); + MainWindow window(false,""); auto *sequence = timeline(&window); size_t id = SIZE_MAX; CHECK(window.add_plugin(module,0,&id) == XTC_OK); @@ -807,7 +811,7 @@ int main(int argc, char **argv) } for (const char *variable : {"XTC_TEST_CLAP","XTC_TEST_VST2","XTC_TEST_VST3"}) { if (const char *module = std::getenv(variable)) { - reset(); MainWindow window(false); auto *sequence = timeline(&window); + reset(); MainWindow window(false,""); auto *sequence = timeline(&window); size_t id = SIZE_MAX; xtc_plugin_spec spec{}; CHECK(window.add_plugin(module,0,&id) == XTC_OK); CHECK(xtc_graph_get_plugin(xtc_project_graph(sequence->project()),id,&spec) == XTC_OK); @@ -831,7 +835,7 @@ int main(int argc, char **argv) } for (const char *variable : {"XTC_TEST_SYNTH_CLAP","XTC_TEST_SYNTH_VST2","XTC_TEST_SYNTH_VST3"}) { if (const char *module = std::getenv(variable)) { - reset(); MainWindow window(false); auto *sequence = timeline(&window); + reset(); MainWindow window(false,""); auto *sequence = timeline(&window); CHECK(sequence->add_track() == XTC_OK && sequence->add_midi_clip() == XTC_OK); uint64_t track = sequence->selected_track(); xtc_plugin_spec spec{}; CHECK(window.assign_instrument(track,module,0) == XTC_OK); @@ -867,7 +871,7 @@ int main(int argc, char **argv) // the current model. Exercise real sidebar buttons rather than mock actions. reset(); { - MainWindow window(false); window.show(); + MainWindow window(false,""); window.show(); auto *sequence = timeline(&window); CHECK(sequence != nullptr); auto *midi = find(&window,"New MIDI track"); @@ -908,7 +912,7 @@ int main(int argc, char **argv) } reset(); { - MainWindow window(false); window.show(); auto *sequence = timeline(&window); + MainWindow window(false,""); window.show(); auto *sequence = timeline(&window); CHECK(window.create_track(true) == XTC_OK); uint64_t clip = sequence->selected_clip(), track = sequence->selected_track(); auto *snap = dynamic_cast(find(&window,"Snap")); CHECK(snap && snap->value() == 3); @@ -964,6 +968,44 @@ int main(int argc, char **argv) xtc_project_get_note(sequence->project(),0,¬e); CHECK(note.velocity > 29 && note.pitch == 60 && note.channel == 2); unlink("/tmp/xtc-grid-velocity.xtc"); window.do_callback(); } + { + char dir[]="/tmp/xtc-preferences-XXXXXX"; CHECK(mkdtemp(dir)); + std::string path=std::string(dir)+"/.xtc.conf"; + reset(); + { + MainWindow window(false,path.c_str());window.show(); + xtc_settings settings=window.settings();settings.note_velocity=73;settings.sequencer_snap=480; + CHECK(window.apply_preferences(settings)==XTC_OK); + CHECK(timeline(&window)->grid()==480); + int old_state=Fl::e_state;Fl::e_state=FL_SHIFT; + gesture(timeline(&window),228,12,96,0);Fl::e_state=old_state; + CHECK(window.settings().loop_start==960 && window.settings().loop_end==2880); + CHECK(window.set_loop(960,3840,true)==XTC_OK); + CHECK(window.settings().loop_enabled==1 && window.settings().loop_start==960); + CHECK(window.set_loop(4000,2000,true)==XTC_ERR_INVALID_ARGUMENT); + settings=window.settings();CHECK(window.apply_preferences(settings)==XTC_OK); + settings.note_velocity=128;CHECK(window.apply_preferences(settings)==XTC_ERR_INVALID_ARGUMENT); + PreferencesDialog dialog(window.settings(),path,[&](const xtc_settings &v){return window.apply_preferences(v);}); + auto *input=dynamic_cast(find(&dialog,"New note velocity"));CHECK(input); + if(input)input->value("81"); + press(find(&dialog,"Apply"));CHECK(window.settings().note_velocity==81); + if(input)input->value("82"); + press(find(&dialog,"Close"));CHECK(window.settings().note_velocity==81); + window.do_callback(); + } + reset(); + { + MainWindow window(false,path.c_str());CHECK(window.settings().note_velocity==81); + CHECK(window.settings().loop_start==960 && window.settings().loop_enabled==1); + xtc_settings paths=window.settings(); + std::string binary=std::string(dir)+"/test.clap";FILE *f=fopen(binary.c_str(),"wb");CHECK(f);if(f)fclose(f); + std::snprintf(paths.clap_paths,sizeof(paths.clap_paths),"%s:%s",dir,dir); + paths.vst2_paths[0]=paths.vst3_paths[0]=paths.ladspa_paths[0]=0; + auto files=xtc_plugin_files(paths);CHECK(files.size()==1 && files[0]==binary); + unlink(binary.c_str());window.do_callback(); + } + unlink(path.c_str());rmdir(dir); + } if (failures != 0) return 1; std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes); return 0; diff --git a/tests/integration/test_gui_jack.cpp b/tests/integration/test_gui_jack.cpp index 5861067..b4b05ca 100644 --- a/tests/integration/test_gui_jack.cpp +++ b/tests/integration/test_gui_jack.cpp @@ -46,7 +46,7 @@ int main(int argc, char **argv) { // Real JACK backend and real FLTK event loop. No routes, no playback data. for (int iteration = 0; iteration < 3; ++iteration) { - MainWindow window(false); + MainWindow window(false,""); window.show(argc, argv); CHECK(find(window, "ready / stopped") != nullptr); auto *play = find(window, "Play"); diff --git a/tests/integration/test_workflow.cpp b/tests/integration/test_workflow.cpp index 396f749..12abf69 100644 --- a/tests/integration/test_workflow.cpp +++ b/tests/integration/test_workflow.cpp @@ -98,7 +98,7 @@ int main(int argc, char **argv) xtc_clip_info arranged{}; size_t nodes, edges; { - MainWindow window(false); + MainWindow window(false,""); window.show(argc, argv); auto *timeline = widget(window); auto *tabs = widget(window); @@ -123,7 +123,7 @@ int main(int argc, char **argv) window.do_callback(); CHECK(!window.shown()); } // Destroy all widgets/engine state before opening a fresh window. { - MainWindow window(false); + MainWindow window(false,""); window.show(argc, argv); auto *timeline = widget(window); widget(window)->value(widget(window)->child(2)); diff --git a/tests/unit/test_backend.c b/tests/unit/test_backend.c index 8e97035..f78f0ad 100644 --- a/tests/unit/test_backend.c +++ b/tests/unit/test_backend.c @@ -361,6 +361,46 @@ int main(void) CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && !status.running); CHECK(xtc_backend_close(backend) == XTC_OK); } + { + config.channel_count = 1;config.max_frames = 128; + CHECK(xtc_backend_open(&config, &backend) == XTC_OK); + CHECK(xtc_backend_submit(backend, graph) == XTC_OK); + CHECK(xtc_backend_set_loop(backend, 100, 163, 1) == XTC_ERR_INVALID_ARGUMENT); + CHECK(xtc_backend_set_loop(backend, 100, 164, 1) == XTC_OK); + CHECK(xtc_backend_start(backend) == XTC_OK); + CHECK(xtc_backend_set_loop(backend, 0, 64, 0) == XTC_ERR_INVALID_STATE); + CHECK(process_cb(32, context) == 0); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_position == 132); + CHECK(process_cb(32, context) == 0); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_position == 100); + CHECK(process_cb(17, context) == 0 && process_cb(32, context) == 0 && process_cb(32, context) == 0); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_position == 117); + for(i=0;i<128;++i)input[i]=(float)i; + CHECK(process_cb(128,context)==0); + for(i=0;i<128;++i)CHECK(output[i]==input[i]*0.5f); + CHECK(xtc_backend_get_status(backend,&status)==XTC_OK && status.sample_position==117); + CHECK(xtc_backend_stop(backend) == XTC_OK); + CHECK(xtc_backend_set_loop(backend, 100, 164, 0) == XTC_OK); + CHECK(xtc_backend_start(backend) == XTC_OK && process_cb(32, context) == 0); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_position == 149); + CHECK(xtc_backend_stop(backend) == XTC_OK && xtc_backend_close(backend) == XTC_OK); + } + { + xtc_project *p = NULL;uint64_t track,clip,note; + config.max_frames=128;config.channel_count=1; + CHECK(xtc_backend_open(&config,&backend)==XTC_OK); + CHECK(xtc_project_create(&p)==XTC_OK); + CHECK(xtc_project_add_track(p,"Loop instrument",&track)==XTC_OK); + CHECK(xtc_project_add_midi_clip(p,track,0,3840,&clip)==XTC_OK); + CHECK(xtc_project_add_note(p,clip,0,960,69,100,0,¬e)==XTC_OK); + CHECK(xtc_backend_prepare_project(backend,p)==XTC_OK); + CHECK(xtc_backend_set_loop(backend,0,64,1)==XTC_OK); + CHECK(xtc_backend_start(backend)==XTC_OK && process_cb(128,context)==0); + CHECK(output[1]!=0); + for(i=0;i<64;++i)CHECK(output[i]==output[i+64]); + CHECK(xtc_backend_stop(backend)==XTC_OK && xtc_backend_close(backend)==XTC_OK); + xtc_project_destroy(p); + } CHECK(closed >= 104); CHECK(xtc_backend_close(NULL) == XTC_OK); xtc_graph_destroy(invalid_graph); diff --git a/tests/unit/test_settings.c b/tests/unit/test_settings.c new file mode 100644 index 0000000..b827ad4 --- /dev/null +++ b/tests/unit/test_settings.c @@ -0,0 +1,29 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_settings.h" +#include +#include +#include +#include +#include +#define CHECK(x) do {if(!(x)){fprintf(stderr,"%d: %s\n",__LINE__,#x);return 1;}}while(0) +int main(void) +{ + char path[]="/tmp/xtc-settings-XXXXXX",value[4096]; int fd=mkstemp(path); size_t i,n; + xtc_settings a,b,before; struct stat st; FILE *f; + CHECK(fd>=0);close(fd);unlink(path); + xtc_settings_defaults(&a);CHECK(xtc_settings_validate(&a)==XTC_OK); + b=a;CHECK(xtc_settings_load(&b,path)==XTC_ERR_NOT_FOUND); + strcpy(a.clap_paths,"~/space folder:/tmp/quoted\"name\\path");a.loop_enabled=1;a.note_velocity=87; + CHECK(xtc_settings_save(&a,path)==XTC_OK);CHECK(stat(path,&st)==0 && (st.st_mode&0777)==0600); + CHECK(xtc_settings_load(&b,path)==XTC_OK);CHECK(memcmp(&a,&b,sizeof(a))==0); + xtc_settings_schema(&n); + for(i=0;i=0);CHECK(fclose(f)==0);before=b;CHECK(xtc_settings_load(&b,path)==XTC_ERR_FORMAT);CHECK(memcmp(&before,&b,sizeof(b))==0);}} + f=fopen(path,"wb");CHECK(f);CHECK(fwrite(";x\0hidden",1,9,f)==9);fclose(f);CHECK(xtc_settings_load(&b,path)==XTC_ERR_FORMAT); + CHECK(xtc_settings_save(&a,path)==XTC_OK);b=a;b.loop_end=b.loop_start;CHECK(xtc_settings_save(&b,path)==XTC_ERR_INVALID_ARGUMENT);CHECK(xtc_settings_load(&b,path)==XTC_OK && b.loop_end==a.loop_end); + CHECK(xtc_settings_save(&a,"/no-such-xtc-directory/settings")==XTC_ERR_IO); + CHECK(xtc_settings_path(value,sizeof(value))==XTC_OK && strstr(value,"/.xtc.conf")); + unlink(path);puts("Preferences INI validation, roundtrip, permissions and failure tests passed.");return 0; +}