diff --git a/Makefile b/Makefile index 5a32b02..acfff1a 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -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 +CORE_SOURCES := 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) @@ -520,3 +520,10 @@ test-settings: $(BUILD_DIR)/test_settings test: test-settings -include $(BUILD_DIR)/tests/unit/test_settings.d + +$(BUILD_DIR)/test_builtin: tests/unit/test_builtin.c $(BUILD_DIR)/libxtc.a + $(CC) $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $< $(BUILD_DIR)/libxtc.a -lm -ldl +.PHONY: test-builtins +test: test-builtins +test-builtins: $(BUILD_DIR)/test_builtin + ./$(BUILD_DIR)/test_builtin diff --git a/docs/BUILTIN_DEVICES.md b/docs/BUILTIN_DEVICES.md new file mode 100644 index 0000000..80fd865 --- /dev/null +++ b/docs/BUILTIN_DEVICES.md @@ -0,0 +1,41 @@ +# Internal instruments and effects + +No plugin downloads, search paths or external libraries are needed. The original +built-in sine remains available for existing projects. + +Choose **Internal: Soft Keys** or **Internal: Sub Bass** in a MIDI track's +instrument dropdown. Soft Keys blends a sine with a third harmonic; Sub Bass +uses a pure sine. Both are fixed presets with 32 voices, velocity sensitivity, +short attack/release envelopes, sustain pedal, note-off and panic handling. +High harmonics are omitted near Nyquist. These basic instruments have no preset +editor, pitch bend, sample library or oscillator/filter controls. + +In **Modular Graph**, select a wire and choose **Edit → Insert internal delay on +wire** or **Insert internal reverb on wire**. This preserves the route and inserts +the effect into the signal path. **File → Add internal delay/reverb** creates an +unconnected device for manual wiring. Select its node and use **Edit → Selected +plugin control** to edit it, or **Selected plugin automation** to automate it. +Changes use the existing stopped-engine preparation and undo workflow. + +- **Delay:** time (1–2000 ms), feedback (0–0.95), dry/wet mix (0–1). +- **Reverb:** four parallel damped feedback combs, with decay (0–0.95), damping + (0–0.95) and dry/wet mix (0–1). Decay is a feedback coefficient, not seconds. + +These are basic mono processors; stereo plans use independent instances. Reverb +is deliberately simple and has no room model or stereo diffusion. Delay time +changes can click; there is no crossfade or tempo sync. Transport seeks retain +existing effect history, like LADSPA. Effect tails require continued rendering; +playback/export does not automatically extend the song for tails. + +Project format 13 stores internal devices as format 4 with reserved module path +`/xtc/internal`, stable numeric identity and index (0 keys, 1 bass, 2 delay, +3 reverb). This is a virtual identifier, never an external binary. Effect controls +and automation are saved using the existing plugin records; instruments are fixed +presets. Readers still accept formats 1–12. Older xtc builds reject format 13. +All buffers are allocated during preparation; processing performs no allocation, +file access, library loading, locking or GUI calls. + +Validation: `make BUILD_DIR=build/phase38 all gui test test-gui-instruments + test-gui-modern-plugins test-gui-plugins analyze` (join onto one line). +`test-builtins` checks impulse tails, dry mix, feedback bounds, block partition +consistency, note release/panic and instrument/effect project round trips. diff --git a/docs/PREFERENCES.md b/docs/PREFERENCES.md index 7a6fa65..74854cd 100644 --- a/docs/PREFERENCES.md +++ b/docs/PREFERENCES.md @@ -21,7 +21,7 @@ may be quoted, with `\\` and `\"` escapes. Apply rewrites comments and formattin | 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` | +| transport | `loop_start=0`, `loop_end=3840`, `loop_enabled=0`, `follow_playhead=0` | Audio client name, block capacity, output channels and fades take effect on **Reconnect**. JACK chooses the actual device, rate and hardware buffer size; diff --git a/docs/PROJECT_FORMAT.md b/docs/PROJECT_FORMAT.md index b70976e..c8078c7 100644 --- a/docs/PROJECT_FORMAT.md +++ b/docs/PROJECT_FORMAT.md @@ -1,4 +1,4 @@ -# Project format v12 (reads v1–v11) +# Project format v13 (reads v1–v12) Phases 5–31 use a bounded text format implemented in C99 without a parser library. The extension is `.xtc`. File operations run exclusively on the control thread. @@ -389,3 +389,10 @@ migrate to no bindings and built-in live sine. Parsing/inspection never executes modules; preparing playback verifies binary identity and configuration. Deleting tracks removes bindings; reordering preserves stable track identities. Undo snapshots include bindings. Public clients must rebuild for the new spec field. + +## Version 13 internal devices + +Format value 4 identifies dependency-free internal devices at `/xtc/internal`. +Indices 0/1 bind fixed Soft Keys/Sub Bass instruments; 2/3 are Delay/Reverb +effects with three controls and existing automation records. Identity and control +ranges are validated without loading libraries. See [BUILTIN_DEVICES.md](BUILTIN_DEVICES.md). diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index d59bc69..f5ddd7d 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -15,7 +15,7 @@ undo/redo and a generic LADSPA editor. JACK is the only live audio/MIDI backend; core validation and offline rendering work without it. Native/LADSPA processors are trusted in-process code. No proprietary energyXT resources are included. -Project format v12 reads versions 1–12 transactionally. Syntax diagnostics identify +Project format v13 reads versions 1–13 transactionally. Syntax diagnostics identify records and lines. `xtc-check --inspect` lists dependency paths without executing plugins. Phase 29 adds timing/CPU/RSS reports, repeatable stress runs and virtual endurance. Phase 30 adds configurable install directories, shared install/remove @@ -85,3 +85,13 @@ vertical dB faders, flat pan knobs, mute/solo, routing and stereo meters on ever track and bus, plus a pinned master monitor. Selected-track automation remains below the strips. No project format or engine changes. Screenshots are refreshed; see MIXER.md and screenshots/README.md. + + +Phase 37 adds searchable Commands (Ctrl+P), editable project BPM, a live track +inspector, session statistics, Fit song and an optional persistent Follow +playhead control. Numeric input remains intact during display refreshes. +Screenshots are refreshed, including the command palette. See WORKSPACE.md. + +Phase 38 adds dependency-free Soft Keys/Sub Bass instruments and Delay/Reverb +effects with saved controls and automation. Choose instruments in track headers; +insert effects on selected graph wires from Edit. See [BUILTIN_DEVICES.md](BUILTIN_DEVICES.md). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5f33fac..d5b0301 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1038,6 +1038,52 @@ and the updated [screenshots](../screenshots/README.md). --- +## Phase 37 — Workspace Navigation and Direct Editing + +### Goal +Reduce menu hunting and make the workspace useful for everyday project editing +with a direct transport, track inspector and searchable commands. + +### Tasks and acceptance criteria +- [x] Add Ctrl+P command search with filtering, keyboard navigation, Enter and Escape. +- [x] Expose active menu actions through existing callbacks, without duplicating command implementations. +- [x] Make project BPM editable from the transport with validation, undo and failed-preparation rollback. +- [x] Replace the passive sidebar with a context-aware gain/pan/mute/solo inspector. +- [x] Preserve unfinished numeric input across timer refreshes and avoid no-op history edits. +- [x] Add a session summary, Fit song and optional Follow playhead navigation. +- [x] Persist Follow playhead through the existing Preferences / Apply workflow. +- [x] Test command selection/cancellation, inspector edits, tempo rollback/undo and navigation. +- [x] Refresh all screenshots, including a command-palette view, and document the workflow. + +### Implementation status +Implemented over the existing C model, history and backend APIs, with no new +dependencies or project format change. Tempo edits stop and rewind transport; +track/clip and piano selections survive. Fit song uses the supported 24..384 +pixels-per-beat zoom levels; very long projects still require scrolling. +The inspector targets the selected mixer channel in Mixer and the arrangement +track in other views. Command search lists active menu actions; it is not a +plugin scanner or a key-binding editor. See WORKSPACE.md. + +### Future usability work +Dockable editor panels, arrangement multiselection, independent track inserts, +resizable pinned track headers and customizable shortcuts remain future work. +They require separate model/layout designs and acceptance tests. + +--- + +## Phase 38 — Internal instruments and effects + +- [x] Add always-available Soft Keys and Sub Bass MIDI instrument presets. +- [x] Add Delay and Reverb with bounded controls and effect automation. +- [x] Integrate track selection, graph wire insertion, undo and saved projects. +- [x] Preserve older projects; reserve format 13 internal device identities. +- [x] Verify DSP tails, block partition consistency, note release/panic and persistence. +- [x] Refresh application screenshots and add internal device editor views. + +See [BUILTIN_DEVICES.md](BUILTIN_DEVICES.md) for controls and explicit 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/docs/WORKSPACE.md b/docs/WORKSPACE.md index 139c5f0..f369b21 100644 --- a/docs/WORKSPACE.md +++ b/docs/WORKSPACE.md @@ -108,3 +108,44 @@ horizontally for larger projects; the master output monitor stays visible. Select a channel name to edit its gain/pan automation below the bank. Existing undo, keyboard controls and project persistence remain available. See [MIXER.md](MIXER.md) for fader units, metering, automation and limits. + + +## Phase 37 direct editing and navigation + +**Commands** (Ctrl+P) searches active menu actions by case-insensitive substring. +Type part of an action path such as `mixer`, `import` or `preferences`, use +Up/Down to select, then Enter or **Run command**. Double-click also runs an +entry. Escape or Cancel leaves the project unchanged. The palette closes before +executing the existing menu callback; context and recording guards still apply. + +The transport **BPM** field edits project tempo from 20 to 300 BPM, stored in +milli-BPM. Enter or release commits one undoable change. It stops and rewinds +transport and prepares the project, including converting loop ticks to samples +at the new tempo. Failed validation/preparation retains the previous project; +the field returns to its actual value. Existing track/clip and piano-roll +selection survives. This is project tempo, separate from Preferences' new-project +default. Tempo automation is not added. + +The **Track inspector** now edits the selected track's linear Gain (0..4), Pan +(-1..1), Mute and Solo. In Mixer it follows the selected channel; in Sequencer +and Graph it follows arrangement selection. Controls synchronize with model +changes and undo, while typing is protected from the display refresh timer. +Only the edited field is committed. Unchanged values create no history entry. +These are static values; automation still overrides gain and pan. Recording +prevents edits. Track instruments and routing retain their existing selectors. + +The session row shows track/bus count, clip count and the arrangement's bar span. +**Fit song** (Ctrl+0) returns to the start and selects the largest supported zoom +that fits the arrangement plus a beat of padding. Supported zoom remains +24, 48, 96, 192 or 384 pixels per beat; a sufficiently long arrangement requires +scrolling even at the minimum. Empty projects fit one bar. + +**Follow playhead** pages the arrangement horizontally during playback, retaining +vertical scroll. It operates only in Sequencer and waits while a mouse drag is +active. It is off by default; Preferences / Apply persists it in +`[transport] follow_playhead=0` or `1`. Manual scrolling remains available and +Fit song resets the viewport without editing the project. + +Verification covers real FLTK palette filtering, empty searches and cancellation, +tempo callbacks/undo/failed adoption, inspector edits and in-progress numeric +input, fit and follow navigation, and existing core/GUI/live JACK workflows. diff --git a/include/xtc/xtc_plugin.h b/include/xtc/xtc_plugin.h index 6f55f32..ab47d8b 100644 --- a/include/xtc/xtc_plugin.h +++ b/include/xtc/xtc_plugin.h @@ -4,7 +4,9 @@ #ifdef __cplusplus extern "C" { #endif -typedef enum { XTC_PLUGIN_LADSPA = 0, XTC_PLUGIN_CLAP, XTC_PLUGIN_VST2, XTC_PLUGIN_VST3 } xtc_plugin_format; +typedef enum { XTC_PLUGIN_LADSPA = 0, XTC_PLUGIN_CLAP, XTC_PLUGIN_VST2, XTC_PLUGIN_VST3, XTC_PLUGIN_BUILTIN } xtc_plugin_format; +/* Internal device indices: 0 Soft Keys, 1 Sub Bass, 2 Delay, 3 Reverb. */ +#define XTC_BUILTIN_PATH "/xtc/internal" const char *xtc_plugin_format_name(xtc_plugin_format format); #define XTC_PLUGIN_CONTROLS 16U #define XTC_PLUGIN_POINTS 32U @@ -13,7 +15,7 @@ const char *xtc_plugin_format_name(xtc_plugin_format format); * sample rounding collides. Integer/toggle controls require mode 1. */ typedef struct { uint64_t tick; unsigned int port, step; float value; } xtc_plugin_point; /* Plain copied model: no library handles or callback pointers. Absolute trusted - * binary path, format/index/integer or string identity/label verified on compile. + * binary path (or reserved XTC_BUILTIN_PATH), identity/label verified on compile. * Controls are LADSPA port indices; modern effects use default configuration * with zero controls/points. Initialize the complete struct to zero. */ typedef struct { @@ -21,7 +23,7 @@ typedef struct { xtc_plugin_format format; char identifier[128]; /* CLAP stable ID / VST3 class hex; empty for LADSPA/VST2. */ unsigned int index, unique_id, inputs, control_count; - unsigned int instrument; /* Modern zero-input note instrument; track binding only. */ + unsigned int instrument; /* Zero-input note instrument; track binding only. */ unsigned int ports[XTC_PLUGIN_CONTROLS]; float values[XTC_PLUGIN_CONTROLS]; unsigned int tempo, point_count; /* milli-BPM, 20000..300000 */ diff --git a/include/xtc/xtc_project.h b/include/xtc/xtc_project.h index 3c95771..43617bc 100644 --- a/include/xtc/xtc_project.h +++ b/include/xtc/xtc_project.h @@ -7,7 +7,7 @@ extern "C" { #endif -#define XTC_PROJECT_FORMAT_VERSION 12U +#define XTC_PROJECT_FORMAT_VERSION 13U #define XTC_PROJECT_MAX_TRACKS 64U #define XTC_PROJECT_MAX_CLIPS 1024U #define XTC_PROJECT_NAME_BYTES 64U diff --git a/include/xtc/xtc_settings.h b/include/xtc/xtc_settings.h index 4d90a81..431f372 100644 --- a/include/xtc/xtc_settings.h +++ b/include/xtc/xtc_settings.h @@ -14,7 +14,7 @@ typedef struct { 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; + int64_t loop_start, loop_end, loop_enabled, follow_playhead; } xtc_settings; typedef struct { const char *section, *key, *label; diff --git a/screenshots/README.md b/screenshots/README.md index d5f21cb..5e380e2 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -1,10 +1,13 @@ # xtc screenshots -Captured from the Phase 36 FLTK application on 2026-10-28, using a virtual X11 +Captured from the Phase 38 FLTK application on 2026-10-28, using a virtual X11 screen. Images show actual rendered widgets, without mockups or image edits. ![Overview of xtc screenshots](overview.png) +Internal instrument dropdowns show Soft Keys and Sub Bass; internal delay and +reverb captures show the actual editable effect controls. + The arrangement uses `examples/projects/modern-session.xtc`. Mixer captures add a bus, routing, pan/level settings and automation. The playing view shows actual JACK callback meters; additional captures show the minimum 1220 × 720 layout and @@ -12,7 +15,9 @@ a larger session scrolled to its final channels, with the master monitor pinned. The audio view adds a temporary generated waveform. Plugin views use the repository's LADSPA, CLAP, VST2 and VST3 test fixtures; these are demonstration plugins, not commercial instruments. Preferences show application defaults and -the staged loop. No user configuration or example project was overwritten. +the staged loop. The command palette shows the available application actions. +The workspace now includes direct BPM editing, a live track inspector and +arrangement navigation. No user configuration or example project was overwritten. - [Sequencer loop](sequencer-loop.png) - [Mixer automation](mixer-automation.png) @@ -38,3 +43,9 @@ the staged loop. No user configuration or example project was overwritten. - [Mixer playing](mixer-playing.png) - [Mixer minimum size](mixer-minimum-size.png) - [Mixer scrolled session](mixer-scrolled-session.png) + +- [Commands](commands.png) + +- [Internal instruments](internal-instruments.png) +- [Internal delay](internal-delay.png) +- [Internal reverb](internal-reverb.png) diff --git a/screenshots/clap-instrument-descriptors.png b/screenshots/clap-instrument-descriptors.png index 54ab718..7f23216 100644 Binary files a/screenshots/clap-instrument-descriptors.png and b/screenshots/clap-instrument-descriptors.png differ diff --git a/screenshots/commands.png b/screenshots/commands.png new file mode 100644 index 0000000..0e02c1f Binary files /dev/null and b/screenshots/commands.png differ diff --git a/screenshots/internal-delay.png b/screenshots/internal-delay.png new file mode 100644 index 0000000..ec8f707 Binary files /dev/null and b/screenshots/internal-delay.png differ diff --git a/screenshots/internal-instruments.png b/screenshots/internal-instruments.png new file mode 100644 index 0000000..1391085 Binary files /dev/null and b/screenshots/internal-instruments.png differ diff --git a/screenshots/internal-reverb.png b/screenshots/internal-reverb.png new file mode 100644 index 0000000..3975a85 Binary files /dev/null and b/screenshots/internal-reverb.png differ diff --git a/screenshots/mixer-automation.png b/screenshots/mixer-automation.png index 0956a3d..47a9d5a 100644 Binary files a/screenshots/mixer-automation.png and b/screenshots/mixer-automation.png differ diff --git a/screenshots/mixer-minimum-size.png b/screenshots/mixer-minimum-size.png index f547e4d..438c130 100644 Binary files a/screenshots/mixer-minimum-size.png and b/screenshots/mixer-minimum-size.png differ diff --git a/screenshots/mixer-playing.png b/screenshots/mixer-playing.png index b0a3f35..c078e2e 100644 Binary files a/screenshots/mixer-playing.png and b/screenshots/mixer-playing.png differ diff --git a/screenshots/mixer-scrolled-session.png b/screenshots/mixer-scrolled-session.png index a155a5e..2f42d00 100644 Binary files a/screenshots/mixer-scrolled-session.png and b/screenshots/mixer-scrolled-session.png differ diff --git a/screenshots/modular-graph.png b/screenshots/modular-graph.png index 5ec5a61..5add830 100644 Binary files a/screenshots/modular-graph.png and b/screenshots/modular-graph.png differ diff --git a/screenshots/overview.png b/screenshots/overview.png index 6f1753d..c7c7574 100644 Binary files a/screenshots/overview.png and b/screenshots/overview.png differ diff --git a/screenshots/plugin-controls.png b/screenshots/plugin-controls.png index 4e1aad3..bef7e9e 100644 Binary files a/screenshots/plugin-controls.png and b/screenshots/plugin-controls.png differ diff --git a/screenshots/plugin-descriptors.png b/screenshots/plugin-descriptors.png index bfa617d..9a5b959 100644 Binary files a/screenshots/plugin-descriptors.png and b/screenshots/plugin-descriptors.png differ diff --git a/screenshots/plugin-library.png b/screenshots/plugin-library.png index 96ea645..8fc02b4 100644 Binary files a/screenshots/plugin-library.png and b/screenshots/plugin-library.png differ diff --git a/screenshots/preferences-transport.png b/screenshots/preferences-transport.png index 435e057..2e75085 100644 Binary files a/screenshots/preferences-transport.png and b/screenshots/preferences-transport.png differ diff --git a/screenshots/sequencer-audio-waveform.png b/screenshots/sequencer-audio-waveform.png index 16dbee0..cdc8a79 100644 Binary files a/screenshots/sequencer-audio-waveform.png and b/screenshots/sequencer-audio-waveform.png differ diff --git a/screenshots/sequencer-loop.png b/screenshots/sequencer-loop.png index 6697279..dc60599 100644 Binary files a/screenshots/sequencer-loop.png and b/screenshots/sequencer-loop.png differ diff --git a/screenshots/vst2-instrument-descriptors.png b/screenshots/vst2-instrument-descriptors.png index e52bc59..9924196 100644 Binary files a/screenshots/vst2-instrument-descriptors.png and b/screenshots/vst2-instrument-descriptors.png differ diff --git a/screenshots/vst3-instrument-descriptors.png b/screenshots/vst3-instrument-descriptors.png index 59590b2..7403537 100644 Binary files a/screenshots/vst3-instrument-descriptors.png and b/screenshots/vst3-instrument-descriptors.png differ diff --git a/src/gui/command_palette.hpp b/src/gui/command_palette.hpp new file mode 100644 index 0000000..2b1b457 --- /dev/null +++ b/src/gui/command_palette.hpp @@ -0,0 +1,102 @@ +#ifndef XTC_COMMAND_PALETTE_HPP +#define XTC_COMMAND_PALETTE_HPP +#include "theme.hpp" +#include +#include +#include +#include +#include +#include +#include + +/* UI command search only; execute the chosen action after this modal is gone. */ +class CommandPalette : public Fl_Double_Window +{ + std::vector commands_, visible_; + Fl_Input *search_; + Fl_Hold_Browser *list_; + std::string selected_; + static std::string lower(std::string value) + { + for (auto &c : value) + c = static_cast(std::tolower(static_cast(c))); + return value; + } + void filter() + { + list_->clear(); + visible_.clear(); + const auto query = lower(search_->value()); + for (const auto &command : commands_) + if (lower(command).find(query) != std::string::npos) { + visible_.push_back(command); + list_->add(command.c_str()); + } + if (!visible_.empty()) + list_->select(1); + } + void accept() + { + int i = list_->value(); + if (i > 0 && static_cast(i) <= visible_.size()) { + selected_ = visible_[static_cast(i - 1)]; + hide(); + } + } + + public: + explicit CommandPalette(std::vector commands) + : Fl_Double_Window(700, 440, "xtc — Commands"), commands_(std::move(commands)) + { + set_modal(); + begin(); + search_ = new Fl_Input(136, 18, 544, 32, "Search commands"); + search_->when(FL_WHEN_CHANGED); + search_->callback([](Fl_Widget *, void *p) { static_cast(p)->filter(); }, this); + list_ = new Fl_Hold_Browser(20, 64, 660, 302); + list_->format_char(0); + list_->callback( + [](Fl_Widget *, void *p) { + if (Fl::event_clicks()) + static_cast(p)->accept(); + }, + this); + auto *cancel = new Fl_Button(450, 390, 100, 30, "Cancel"); + cancel->callback([](Fl_Widget *, void *p) { static_cast(p)->hide(); }, this); + auto *run = new Fl_Button(564, 390, 116, 30, "Run command"); + run->callback([](Fl_Widget *, void *p) { static_cast(p)->accept(); }, this); + callback([](Fl_Widget *, void *p) { static_cast(p)->hide(); }, this); + end(); + xtc_ui::style(this); + filter(); + } + int handle(int event) override + { + if (event == FL_KEYDOWN) { + int key = Fl::event_key(); + if (key == FL_Enter || key == FL_KP_Enter) { + accept(); + return 1; + } + if (key == FL_Escape) { + hide(); + return 1; + } + if ((key == FL_Up || key == FL_Down) && !visible_.empty()) { + list_->select( + std::clamp(list_->value() + (key == FL_Up ? -1 : 1), 1, static_cast(visible_.size()))); + return 1; + } + } + return Fl_Double_Window::handle(event); + } + std::string run() + { + show(); + search_->take_focus(); + while (shown()) + Fl::wait(); + return selected_; + } +}; +#endif diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 8e29943..b6d9cb4 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -8,6 +8,8 @@ #include "snap.hpp" #include "velocity_lane.hpp" #include "preferences_dialog.hpp" +#include "command_palette.hpp" +#include #include "plugin_library.hpp" #include #include @@ -32,6 +34,11 @@ #include namespace { +bool editing_number(Fl_Value_Input *input) +{ + return Fl::focus() == input || Fl::focus() == &input->input || Fl::pushed() == input || + Fl::pushed() == &input->input; +} /** * @brief Updates a box label only when the text differs. * @@ -103,12 +110,18 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) menu->add("Edit/Redo alternate", FL_CTRL + 'y', history_callback, this); menu->add("Audio/MIDI controller mapping", 0, control_map_callback, this); menu->add("Audio/Panic", FL_F + 9, panic_callback, this); + menu->add("View/Commands",FL_CTRL+'p',[](Fl_Widget *,void *c){static_cast(c)->show_commands();},this); + menu->add("View/Fit song",FL_CTRL+'0',[](Fl_Widget *,void *c){static_cast(c)->fit_song();},this); menu->add("View/Piano roll", FL_CTRL + '4', piano_callback, this); menu->add("Audio/Record WAV", FL_SHIFT + FL_F + 5, audio_file_callback, this); menu->add("Track/Load instrument plugin", 0, instrument_callback, this); menu->add("Track/Use built-in instrument", 0, instrument_callback, this); menu->add("Track/Live MIDI to selected instrument", 0, instrument_callback, this); menu->add("Track/Live MIDI to built-in sine", 0, instrument_callback, this); + menu->add("Edit/Insert internal delay on wire", 0, plugin_callback, this); + menu->add("Edit/Insert internal reverb on wire", 0, plugin_callback, this); + menu->add("File/Add internal delay", 0, plugin_callback, this); + menu->add("File/Add internal reverb", 0, plugin_callback, this); menu->add("File/Load plugin", 0, plugin_callback, this); menu->add("Edit/Replace selected plugin", 0, plugin_callback, this); menu->add("Edit/Insert plugin on selected wire", 0, plugin_callback, this); @@ -159,10 +172,43 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) indicator_->labelsize(12); transport_position_ = new Fl_Box(680,40,190,30,"001 : 1 : 000"); 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->resize(12,82,970,28); - loop_button_=new Fl_Check_Button(1000,82,100,28,"Loop"); + tempo_display_ = new Fl_Value_Input(880, 40, 78, 30, "BPM"); + tempo_display_->align(FL_ALIGN_RIGHT); + tempo_display_->bounds(20, 300); + tempo_display_->step(.1); + tempo_display_->when(FL_WHEN_RELEASE | FL_WHEN_ENTER_KEY); + tempo_display_->tooltip("Project tempo in BPM. Enter or release to apply; undoable. Stops playback."); + tempo_display_->callback( + [](Fl_Widget *, void *c) { + auto *w = static_cast(c); + double bpm = w->tempo_display_->value(); + xtc_result r = (!std::isfinite(bpm) || bpm < 20 || bpm > 300) + ? XTC_ERR_INVALID_ARGUMENT + : w->set_tempo(static_cast(std::lround(bpm * 1000))); + if (r != XTC_OK) + w->report_error("Tempo unchanged", r); + w->tempo_display_->value(xtc_project_tempo(w->project_.get()) / 1000.0); + w->refresh_status(); + }, + this); + auto *hint = new Fl_Box(12, 82, 160, 28, "xtc / SESSION"); + hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); + session_info_ = new Fl_Box(180, 82, 280, 28); + session_info_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); + auto *commands = new Fl_Button(480, 82, 120, 28, "Commands"); + commands->tooltip("Search actions (Ctrl+P)"); + commands->callback([](Fl_Widget *, void *c) { static_cast(c)->show_commands(); }, this); + follow_ = new Fl_Check_Button(620, 82, 154, 28, "Follow playhead"); + follow_->tooltip("Keep playback visible in the arrangement; pauses while dragging."); + follow_->callback( + [](Fl_Widget *widget, void *c) { + static_cast(c)->settings_.follow_playhead = static_cast(widget)->value(); + }, + this); + auto *fit = new Fl_Button(790, 82, 100, 28, "Fit song"); + fit->tooltip("Choose the largest supported zoom that fits the song (Ctrl+0)"); + fit->callback([](Fl_Widget *, void *c) { static_cast(c)->fit_song(); }, this); + 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); @@ -194,6 +240,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) timeline_hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); timeline_hint->labelsize(12); auto *timeline_scroll = new Fl_Scroll(228, 206, 1032, 452); + timeline_scroll_=timeline_scroll; sequencer_ = new SequencerWidget(228, 206, project_.get(), seek, this); timeline_scroll->end(); sequencer_->before_edit = before_arrangement_edit; @@ -361,11 +408,27 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) load->callback(instrument_callback,this); auto *editor = new Fl_Button(24,344,168,34,"Edit MIDI notes"); editor->callback(piano_callback,this); - auto *selection_title = new Fl_Box(24,404,168,24,"SELECTED TRACK"); + auto *selection_title = new Fl_Box(24,404,168,24,"TRACK INSPECTOR"); selection_title->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); - selection_info_ = new Fl_Box(24,438,168,170,"Select a track to inspect it."); + selection_info_ = new Fl_Box(24,438,168,82,"Select a track to inspect it."); selection_info_->align(FL_ALIGN_TOP_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP); - auto *tip = new Fl_Box(24,620,168,64,"Ctrl+1 Arrange\nCtrl+2 Mixer\nCtrl+3 Routing"); + inspector_gain_ = new Fl_Value_Input(78, 528, 114, 26, "Gain"); + inspector_gain_->bounds(0, 4); + inspector_gain_->step(.01); + inspector_pan_ = new Fl_Value_Input(78, 562, 114, 26, "Pan"); + inspector_pan_->bounds(-1, 1); + inspector_pan_->step(.01); + inspector_mute_ = new Fl_Check_Button(24, 596, 78, 24, "Mute"); + inspector_solo_ = new Fl_Check_Button(108, 596, 84, 24, "Solo"); + for (auto *control : {static_cast(inspector_gain_), static_cast(inspector_pan_), + static_cast(inspector_mute_), static_cast(inspector_solo_)}) { + control->when(FL_WHEN_RELEASE); + control->callback(inspector_callback, this); + } + inspector_gain_->tooltip( + "Selected track linear gain: 0 silence, 1 unity, 4 maximum. Automation overrides static gain."); + inspector_pan_->tooltip("Selected track pan: -1 left, 0 center, +1 right."); + auto *tip = new Fl_Box(24, 632, 168, 52, "Ctrl+P Commands\nCtrl+1/2/3 Views\nCtrl+0 Fit song"); tip->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); sidebar->end(); status_ = new Fl_Box(12,714,1256,26,"JACK: not connected"); @@ -870,6 +933,7 @@ void MainWindow::timer_callback(void *context) window->stop_engine(); } window->refresh_status(); + window->follow_playhead(); if (!window->closing_) Fl::repeat_timeout(static_cast(window->settings_.refresh_ms)/1000,timer_callback,context); } @@ -1564,41 +1628,97 @@ 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 (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())); - size_t count = 0; bool selected = false; xtc_project_note_count(project_.get(),&count); + size_t count = 0; + bool selected = false; + xtc_project_note_count(project_.get(), &count); for (size_t i = 0; i < count; ++i) { - xtc_note_info note{}; xtc_project_get_note(project_.get(),i,¬e); + xtc_note_info note{}; + xtc_project_get_note(project_.get(), i, ¬e); if (note.id == piano_->selected_note() && piano_->note_visible(note)) { - selected = true; if (Fl::pushed() != note_velocity_) note_velocity_->value(note.velocity); break; + selected = true; + if (Fl::pushed() != note_velocity_) + note_velocity_->value(note.velocity); + break; } } - if (selected) note_velocity_->activate(); else note_velocity_->deactivate(); + if (selected) + note_velocity_->activate(); + else + note_velocity_->deactivate(); velocity_lane_->redraw(); } - char position[64], tempo[40], info[512]; + char position[64], info[512]; uint64_t tick = sequencer_->position(); - std::snprintf(position,sizeof(position),"%03llu : %u : %03u", - static_cast(tick/3840+1),static_cast((tick/960)%4+1), - static_cast(tick%960)); - set_label(transport_position_,position); - std::snprintf(tempo,sizeof(tempo),"%.1f BPM",xtc_project_tempo(project_.get())/1000.0); - set_label(tempo_display_,tempo); - size_t tracks = 0, clips = 0; xtc_project_get_counts(project_.get(),&tracks,&clips); - for (size_t i = 0; i < tracks; ++i) { - xtc_track_info track{}; xtc_project_get_track(project_.get(),i,&track); - if (track.id != sequencer_->selected_track()) continue; - xtc_plugin_spec spec{}; - bool plugin = xtc_project_get_instrument(project_.get(),track.id,&spec) == XTC_OK; - std::snprintf(info,sizeof(info),"%s\n\n%s\nGain %.2f | Pan %.2f\n%s%s\n\n%s", - track.name,track.is_bus ? "BUS" : "TRACK",static_cast(track.gain),static_cast(track.pan), - track.mute ? "Muted " : "",track.solo ? "Solo" : "",plugin ? spec.label : "Built-in MIDI sine"); - set_label(selection_info_,info); return; + std::snprintf(position, sizeof(position), "%03llu : %u : %03u", static_cast(tick / 3840 + 1), + static_cast((tick / 960) % 4 + 1), static_cast(tick % 960)); + set_label(transport_position_, position); + if (!editing_number(tempo_display_)) + tempo_display_->value(xtc_project_tempo(project_.get()) / 1000.0); + if (recorder_) + tempo_display_->deactivate(); + else + tempo_display_->activate(); + size_t tracks = 0, clips = 0; + xtc_project_get_counts(project_.get(), &tracks, &clips); + uint64_t end = 0; + for (size_t i = 0; i < clips; ++i) { + xtc_clip_info clip{}; + xtc_project_get_clip(project_.get(), i, &clip); + end = std::max(end, clip.start_tick + clip.length_tick); } - std::snprintf(info,sizeof(info),"No track selected\n\n%zu tracks / buses\n%zu clips\n\nCreate a MIDI track to start drawing notes, or import a WAV file.",tracks,clips); - set_label(selection_info_,info); + std::snprintf(info, sizeof(info), "%zu tracks / %zu clips / %llu bars", tracks, clips, + static_cast((end + 3839) / 3840)); + set_label(session_info_, info); + uint64_t target = tabs_->value() == tabs_->child(1) ? mixer_->selected_track() : sequencer_->selected_track(); + bool selection_changed = target != inspected_track_; + inspected_track_ = UINT64_MAX; + for (size_t i = 0; i < tracks; ++i) { + xtc_track_info track{}; + xtc_project_get_track(project_.get(), i, &track); + if (track.id != target) + continue; + inspected_track_ = track.id; + for (auto *control : {static_cast(inspector_gain_), static_cast(inspector_pan_), + static_cast(inspector_mute_), static_cast(inspector_solo_)}) { + if (recorder_) + control->deactivate(); + else + control->activate(); + } + if (selection_changed || !editing_number(inspector_gain_)) + inspector_gain_->value(track.gain); + if (selection_changed || !editing_number(inspector_pan_)) + inspector_pan_->value(track.pan); + if (Fl::pushed() != inspector_mute_) + inspector_mute_->value(track.mute); + if (Fl::pushed() != inspector_solo_) + inspector_solo_->value(track.solo); + xtc_plugin_spec spec{}; + bool plugin = xtc_project_get_instrument(project_.get(), track.id, &spec) == XTC_OK; + std::snprintf(info, sizeof(info), "%s\n%s %s%s\n%s", track.name, track.is_bus ? "BUS" : "TRACK", + track.mute ? "Muted " : "", track.solo ? "Solo" : "", + track.is_bus ? "Bus output" + : plugin ? spec.label + : "Built-in MIDI sine"); + set_label(selection_info_, info); + return; + } + for (auto *control : {static_cast(inspector_gain_), static_cast(inspector_pan_), + static_cast(inspector_mute_), static_cast(inspector_solo_)}) + control->deactivate(); + std::snprintf(info, sizeof(info), "No track selected\nCreate a MIDI track or import a WAV to begin."); + set_label(selection_info_, info); } xtc_result MainWindow::assign_instrument(uint64_t track, const char *path, unsigned int index) @@ -1801,7 +1921,12 @@ void MainWindow::plugin_callback(Fl_Widget *widget, void *context) auto *menu = static_cast(widget); const char *label = menu->mvalue()->label(); xtc_result r; - if (std::strcmp(label,"Reset selected plugin") == 0) { + if (std::strcmp(label,"Insert internal delay on wire")==0 || std::strcmp(label,"Insert internal reverb on wire")==0) { + xtc_connection_info wire{}; + r=w->graph_widget_->selected_connection(wire) ? w->insert_plugin(wire,XTC_BUILTIN_PATH,std::strcmp(label,"Insert internal delay on wire")==0 ? 2U : 3U) : XTC_ERR_INVALID_STATE; + } else if (std::strcmp(label,"Add internal delay") == 0 || std::strcmp(label,"Add internal reverb") == 0) { + size_t id=0; r=w->add_plugin(XTC_BUILTIN_PATH, std::strcmp(label,"Add internal delay")==0 ? 2U : 3U, &id); + } else if (std::strcmp(label,"Reset selected plugin") == 0) { r = w->reset_plugin(w->graph_widget_->selected_node()); } else if (std::strcmp(label,"Load plugin") == 0 || std::strcmp(label,"Replace selected plugin") == 0 || @@ -1914,6 +2039,7 @@ void MainWindow::collect_preferences() } void MainWindow::apply_ui_preferences() { + follow_->value(static_cast(settings_.follow_playhead)); 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); @@ -1940,3 +2066,119 @@ 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(); } + +xtc_result MainWindow::set_tempo(unsigned int milli_bpm) +{ + if (recorder_) + return XTC_ERR_INVALID_STATE; + if (milli_bpm < 20000 || milli_bpm > 300000) + return XTC_ERR_INVALID_ARGUMENT; + if (milli_bpm == xtc_project_tempo(project_.get())) + return XTC_OK; + uint64_t track = sequencer_->selected_track(), clip = sequencer_->selected_clip(), mix = mixer_->selected_track(); + uint64_t piano_clip = piano_ ? piano_->clip() : UINT64_MAX, note = piano_ ? piano_->selected_note() : UINT64_MAX; + bool piano_visible = piano_window_ && piano_window_->shown(); + xtc_project *candidate = nullptr; + xtc_result r = xtc_project_clone(project_.get(), &candidate); + if (r == XTC_OK) + r = xtc_project_set_tempo(candidate, milli_bpm); + 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); + else { + sequencer_->select(track, clip); + mixer_->select_track(mix); + xtc_clip_info info{}; + if (piano_ && xtc_project_find_clip(project_.get(), piano_clip, &info) == XTC_OK) { + piano_->set_project(project_.get(), piano_clip); + piano_->select_note(note); + if (piano_visible) + piano_window_->show(); + } + mark_dirty(); + } + return r; +} +void MainWindow::inspector_callback(Fl_Widget *widget, void *context) +{ + auto *w = static_cast(context); + const uint64_t id = w->inspected_track_; + float gain = static_cast(w->inspector_gain_->value()), pan = static_cast(w->inspector_pan_->value()); + int mute = w->inspector_mute_->value(), solo = w->inspector_solo_->value(); + // Editing a single field must not commit another field's unfinished input. + size_t count = 0, clips = 0; + xtc_project_get_counts(w->project_.get(), &count, &clips); + for (size_t i = 0; i < count; ++i) { + xtc_track_info track{}; + xtc_project_get_track(w->project_.get(), i, &track); + if (track.id != id) + continue; + if (widget != w->inspector_gain_) + gain = track.gain; + if (widget != w->inspector_pan_) + pan = track.pan; + if (widget != w->inspector_mute_) + mute = track.mute; + if (widget != w->inspector_solo_) + solo = track.solo; + if (gain != track.gain || pan != track.pan || mute != track.mute || solo != track.solo) + if (w->mixer_->select_track(id)) + w->mixer_->mix(gain, pan, mute, solo); + break; + } + w->inspected_track_ = UINT64_MAX; + w->refresh_status(); +} +void MainWindow::show_commands() +{ + std::vector commands; + for (int i = 0; i < menu_->size(); ++i) { + const auto &item = menu_->menu()[i]; + if (!item.label() || item.submenu() || !item.active()) + continue; + char path[512]; + if (menu_->item_pathname(path, sizeof(path), &item) == 0) + commands.emplace_back(path); + } + std::string selected; + { + CommandPalette palette(std::move(commands)); + selected = palette.run(); + } + if (!selected.empty()) { + auto *item = menu_->find_item(selected.c_str()); + if (item && item->active()) + menu_->picked(item); + } +} +void MainWindow::fit_song() +{ + size_t tracks = 0, clips = 0; + uint64_t end = 3840; + xtc_project_get_counts(project_.get(), &tracks, &clips); + for (size_t i = 0; i < clips; ++i) { + xtc_clip_info clip{}; + xtc_project_get_clip(project_.get(), i, &clip); + end = std::max(end, clip.start_tick + clip.length_tick + 960); + } + int scale = 24; + for (int candidate : {24, 48, 96, 192, 384}) + if (end * static_cast(candidate) / 960 <= + static_cast(std::max(1, timeline_scroll_->w() - 204))) + scale = candidate; + sequencer_->set_zoom(scale); + timeline_scroll_->scroll_to(0, 0); + timeline_scroll_->redraw(); +} +void MainWindow::follow_playhead() +{ + if (!running_ || !follow_->value() || tabs_->value() != tabs_->child(0) || Fl::pushed()) + return; + int pixel = static_cast(sequencer_->position() * static_cast(sequencer_->zoom_level()) / 960); + int screen = sequencer_->x() + 180 + pixel; + if (screen > timeline_scroll_->x() + timeline_scroll_->w() - 48 || screen < timeline_scroll_->x() + 180) + timeline_scroll_->scroll_to(std::max(0, pixel - 160), timeline_scroll_->yposition()); +} diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 4f9be5d..a0c988e 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -21,6 +21,8 @@ class Fl_Tabs; class Fl_Menu_Bar; class Fl_Choice; class Fl_Value_Slider; +class Fl_Value_Input; +class Fl_Scroll; class Fl_Check_Button; class MainWindow : public Fl_Double_Window { @@ -29,6 +31,9 @@ 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); + xtc_result set_tempo(unsigned int milli_bpm); + void show_commands(); + void fit_song(); const xtc_settings &settings() const { return settings_; } xtc_result apply_preferences(const xtc_settings &settings); void show_preferences(); @@ -198,7 +203,14 @@ class MainWindow : public Fl_Double_Window { Fl_Value_Slider *note_velocity_ = nullptr; Fl_Widget *velocity_lane_ = nullptr; Fl_Box *transport_position_ = nullptr; - Fl_Box *tempo_display_ = nullptr; + Fl_Value_Input *tempo_display_ = nullptr; + Fl_Value_Input *inspector_gain_=nullptr,*inspector_pan_=nullptr; + Fl_Check_Button *inspector_mute_=nullptr,*inspector_solo_=nullptr,*follow_=nullptr; + Fl_Scroll *timeline_scroll_=nullptr; + Fl_Box *session_info_=nullptr; + uint64_t inspected_track_=UINT64_MAX; + static void inspector_callback(Fl_Widget *,void *); + void follow_playhead(); Fl_Box *selection_info_ = nullptr; MixerWidget *mixer_ = nullptr; Fl_Menu_Bar *menu_ = nullptr; diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index a5a48c9..88a1ab3 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -616,6 +616,7 @@ void SequencerWidget::refresh_instruments() try size_t tracks = 0, clips = 0; xtc_project_get_counts(project_,&tracks,&clips); catalog_.clear(); + for (unsigned int i=0;i<2;++i) { xtc_plugin_spec s{}; if (xtc_plugin_probe(XTC_BUILTIN_PATH,i,48000,&s)==XTC_OK) catalog_.push_back(s); } for (size_t i = 0; i < tracks; ++i) { xtc_track_info t{}; xtc_plugin_spec spec{}; xtc_project_get_track(project_,i,&t); diff --git a/src/main.c b/src/main.c index 932b62e..1493326 100644 --- a/src/main.c +++ b/src/main.c @@ -54,7 +54,7 @@ static int inspect(const xtc_project *project, uint64_t version) xtc_node_info node; xtc_plugin_spec spec; xtc_graph_get_node(graph,i,&node); if (xtc_graph_get_plugin(graph,node.id,&spec) == XTC_OK) { - int available = readable_file(spec.path); + int available = spec.format == XTC_PLUGIN_BUILTIN || readable_file(spec.path); printf("%s node %zu ID %u descriptor %u ",xtc_plugin_format_name(spec.format),node.id,spec.unique_id,spec.index); if (spec.identifier[0]) { quoted(spec.identifier); putchar(' '); } quoted(spec.label); printf(": %s ",available ? "available" : "missing/unreadable"); @@ -66,7 +66,7 @@ static int inspect(const xtc_project *project, uint64_t version) xtc_track_info track; xtc_plugin_spec spec; xtc_project_get_track(project,i,&track); if (xtc_project_get_instrument(project,track.id,&spec) == XTC_OK) { - int available = readable_file(spec.path); + int available = spec.format == XTC_PLUGIN_BUILTIN || readable_file(spec.path); printf("%s instrument track %" PRIu64 " descriptor %u ",xtc_plugin_format_name(spec.format),track.id,spec.index); quoted(spec.identifier); putchar(' '); quoted(spec.label); printf(": %s ",available ? "available" : "missing/unreadable"); quoted(spec.path); putchar('\n'); diff --git a/src/persistence/project_file.c b/src/persistence/project_file.c index 4c85d87..5f516ac 100644 --- a/src/persistence/project_file.c +++ b/src/persistence/project_file.c @@ -261,7 +261,7 @@ static xtc_result read_project(reader *input, xtc_project *project, const char * if (!decode_bytes(input->tokens[1],p->path,sizeof(p->path))) return XTC_ERR_FORMAT; if (format_version >= 11U) { READ("FORMAT",3U); NUM(1U,value); - if (value > XTC_PLUGIN_VST3) return XTC_ERR_UNSUPPORTED; + if (value > (format_version >= 13U ? XTC_PLUGIN_BUILTIN : XTC_PLUGIN_VST3)) return XTC_ERR_UNSUPPORTED; p->format = (xtc_plugin_format)value; if (strcmp(input->tokens[2],"-") && !decode_bytes(input->tokens[2],p->identifier,sizeof(p->identifier))) return XTC_ERR_FORMAT; } @@ -454,7 +454,7 @@ static xtc_result read_project(reader *input, xtc_project *project, const char * for (slot = 0; slot < project->track_count && project->tracks[slot].id != track; ++slot) {} if (slot == project->track_count || project->tracks[slot].is_bus || project->instruments[slot].instrument) return XTC_ERR_FORMAT; s = project->instruments+slot; s->instrument = 1; s->tempo = project->tempo; - NUM(2U,value); if (value < XTC_PLUGIN_CLAP || value > XTC_PLUGIN_VST3) return XTC_ERR_UNSUPPORTED; + NUM(2U,value); if (value < XTC_PLUGIN_CLAP || value > (format_version >= 13U ? XTC_PLUGIN_BUILTIN : XTC_PLUGIN_VST3)) return XTC_ERR_UNSUPPORTED; s->format = (xtc_plugin_format)value; NUM(3U,value); if (value >= 1024) return XTC_ERR_FORMAT; s->index = (unsigned int)value; NUM(4U,value); if (value > UINT32_MAX) return XTC_ERR_FORMAT; s->unique_id = (unsigned int)value; diff --git a/src/plugin/builtin.c b/src/plugin/builtin.c new file mode 100644 index 0000000..a7c9853 --- /dev/null +++ b/src/plugin/builtin.c @@ -0,0 +1,149 @@ +/* Original, dependency-free mono devices. All storage is prepared off-thread. */ +#include "builtin.h" +#include "xtc/xtc_midi.h" +#include +#include +#include +#include +#define TAU 6.2831853071795864769 +static const char *names[] = {"Soft Keys", "Sub Bass", "Delay", "Reverb"}; +static const char *controls[2][3] = {{"Time", "Feedback", "Mix"}, {"Decay", "Damping", "Mix"}}; +static const float defaults[2][3] = {{350, .35f, .25f}, {.7f, .45f, .25f}}; +static const float lows[2][3] = {{1,0,0},{0,0,0}}; +static const float highs[2][3] = {{2000,.95f,1},{.95f,.95f,1}}; +typedef struct { double phase, step; float level, velocity; unsigned int note, channel; int active, released, held; } voice; +struct xtc_builtin { + xtc_plugin_spec spec; + double rate; + float attack, release; + size_t max_frames, length, cursor; + float *line, damp[4]; + size_t comb[4]; + voice voices[32]; + double frequencies[128]; + int sustain[16]; +}; +xtc_result xtc_builtin_probe(unsigned int index, xtc_plugin_spec *out) +{ + unsigned int i; xtc_plugin_spec s = {0}; + if (!out) return XTC_ERR_INVALID_ARGUMENT; + if (index >= 4) return XTC_ERR_NOT_FOUND; + strcpy(s.path,XTC_BUILTIN_PATH); strcpy(s.label,names[index]); + s.format = XTC_PLUGIN_BUILTIN; s.index = index; s.unique_id = 0x58544300U+index; + s.instrument = index < 2; s.inputs = index < 2 ? 0U : 1U; s.tempo = 120000; + s.control_count = index < 2 ? 0U : 3U; + for (i=0;iindex,&a)!=XTC_OK || strcmp(s->path,a.path) || strcmp(s->label,a.label) || + s->identifier[0] || s->unique_id!=a.unique_id || s->instrument!=a.instrument || s->inputs!=a.inputs || + s->control_count!=a.control_count) return XTC_ERR_INVALID_ARGUMENT; + for(i=0;icontrol_count;++i) if(s->ports[i]!=i || !isfinite(s->values[i]) || + s->values[i]index-2][i] || s->values[i]>highs[s->index-2][i]) return XTC_ERR_INVALID_ARGUMENT; + for(i=0;ipoint_count;++i) if(s->index<2 || s->points[i].port>=3 || !isfinite(s->points[i].value) || + s->points[i].valueindex-2][s->points[i].port] || s->points[i].value>highs[s->index-2][s->points[i].port]) return XTC_ERR_INVALID_ARGUMENT; + return XTC_OK; +} +xtc_result xtc_builtin_describe(const xtc_plugin_spec *s, xtc_plugin_details *out) +{ + unsigned int i; + if (!out || xtc_plugin_spec_validate(s)!=XTC_OK) return XTC_ERR_INVALID_ARGUMENT; + memset(out,0,sizeof(*out)); snprintf(out->name,sizeof(out->name),"%s",s->label); + strcpy(out->maker,"xtc"); strcpy(out->copyright,"See xtc LICENSE"); out->hard_realtime=1; + for(i=0;icontrol_count;++i) { + xtc_parameter_info *p=out->parameters+i; p->id=i; + strcpy(p->name,controls[s->index-2][i]); if(s->index==2 && i==0) strcpy(p->unit,"ms"); + p->minimum=lows[s->index-2][i]; p->maximum=highs[s->index-2][i]; p->default_value=defaults[s->index-2][i]; + out->flags[i]=XTC_PLUGIN_BOUNDED_BELOW|XTC_PLUGIN_BOUNDED_ABOVE; + } + return XTC_OK; +} +xtc_result xtc_builtin_create(const xtc_plugin_spec *s, double rate, size_t frames, xtc_builtin **out) +{ + xtc_builtin *p; unsigned int i; + if (!out || xtc_plugin_spec_validate(s)!=XTC_OK || !isfinite(rate) || rate<1 || rate>768000 || !frames || frames>65536) return XTC_ERR_INVALID_ARGUMENT; + *out=NULL; p=calloc(1,sizeof(*p)); if(!p) return XTC_ERR_OUT_OF_MEMORY; + p->spec=*s; p->rate=rate; p->attack=(float)(1-exp(-1/(.008*rate))); p->release=(float)exp(-1/(.08*rate)); p->max_frames=frames; p->length=(size_t)ceil(rate*2)+2; + if(!s->instrument) { p->line=calloc(p->length*4,sizeof(float)); if(!p->line) {free(p);return XTC_ERR_OUT_OF_MEMORY;} } + for(i=0;i<128;++i) p->frequencies[i]=440*pow(2,((double)i-69)/12)/rate; + for(i=0;i<4;++i) { static const double times[]={.0297,.0371,.0411,.0437}; p->comb[i]=(size_t)(rate*times[i])+1; } + *out=p; return XTC_OK; +} +void xtc_builtin_destroy(xtc_builtin *p) { if(p) {free(p->line);free(p);} } +static float parameter(const xtc_builtin *p, unsigned int port, uint64_t position) +{ + const xtc_plugin_point *a=NULL,*b=NULL; unsigned int i; + double tick=(double)position*p->spec.tempo*XTC_TICKS_PER_QUARTER/(p->rate*60000); + for(i=0;ispec.point_count;++i) { const xtc_plugin_point *q=p->spec.points+i; + if(q->port!=port) continue; + if((double)q->tick<=tick) { if(!a || q->tick>a->tick) a=q; } + else if(!b || q->ticktick) b=q; + } + if(!a) return b ? b->value : p->spec.values[port]; + if(!b || a->step) return a->value; + return (float)(a->value+(b->value-a->value)*(tick-(double)a->tick)/(double)(b->tick-a->tick)); +} +xtc_result xtc_builtin_process(xtc_builtin *p, const float *in, float *out, size_t frames, uint64_t position) +{ + size_t i; unsigned int j; + if(!p || !out || frames>p->max_frames || p->spec.instrument || position>UINT64_MAX-frames) return XTC_ERR_INVALID_ARGUMENT; + for(i=0;ispec.index==2) { + size_t delay=(size_t)(a*p->rate/1000); if(!delay) delay=1; + wet=p->line[(p->cursor+p->length-delay)%p->length]; + p->line[p->cursor]=x+wet*b; + } else { + for(j=0;j<4;++j) { + float *line=p->line+j*p->length; + float y=line[(p->cursor+p->length-p->comb[j])%p->length]; + p->damp[j]=(1-b)*y+b*p->damp[j]; + line[p->cursor]=x+a*p->damp[j]; wet+=y*.25f; + } + } + out[i]=(1-mix)*x+mix*wet; p->cursor=(p->cursor+1)%p->length; + } + return XTC_OK; +} +static void midi_event(xtc_builtin *p, const xtc_midi_event *e) +{ + unsigned int j,slot=0; + if(e->type==XTC_MIDI_ALL_OFF) { memset(p->voices,0,sizeof(p->voices));memset(p->sustain,0,sizeof(p->sustain));return; } + if(e->type==XTC_MIDI_CONTROL) { + if(e->pitch==64) {p->sustain[e->channel]=e->velocity>=64; if(e->velocity<64) for(j=0;j<32;++j) if(p->voices[j].channel==e->channel && p->voices[j].held) p->voices[j].released=1;} + if(e->pitch==120 || e->pitch==123) for(j=0;j<32;++j) if(p->voices[j].channel==e->channel) p->voices[j].active=0; + return; + } + if(e->type==XTC_MIDI_NOTE_OFF || !e->velocity) { + for(j=0;j<32;++j) if(p->voices[j].channel==e->channel && p->voices[j].note==e->pitch) { + p->voices[j].held=1; p->voices[j].released=!p->sustain[e->channel]; + } + return; + } + for(j=0;j<32;++j) { if(!p->voices[j].active) {slot=j;break;} if(p->voices[j].levelvoices[slot].level) slot=j; } + {voice *v=p->voices+slot; memset(v,0,sizeof(*v));v->active=1;v->note=e->pitch;v->channel=e->channel;v->velocity=(float)e->velocity/127;v->step=p->frequencies[e->pitch];} +} +xtc_result xtc_builtin_midi(xtc_builtin *p, const xtc_midi_event *events, size_t count, float *out, size_t frames) +{ + size_t i,k=0; unsigned int j; + if(!p || !p->spec.instrument || !out || frames>p->max_frames || count>256 || (count && !events)) return XTC_ERR_INVALID_ARGUMENT; + for(i=0;i=frames || (i && events[i].offset15 || events[i].pitch>127 || events[i].velocity>127 || events[i].typeXTC_MIDI_CONTROL) return XTC_ERR_INVALID_ARGUMENT; + for(i=0;ivoices+j; double wave; + if(!v->active) continue; + if(v->released) v->level *= p->release; + else v->level += (1-v->level)*p->attack; + if(v->released && v->level<.00001f) {v->active=0;continue;} + wave=v->step<.49 ? sin(TAU*v->phase) : 0; + if(p->spec.index==0 && v->step<.16) wave+=.3*sin(3*TAU*v->phase); + sum+=(float)wave*v->level*v->velocity*.12f; + v->phase+=v->step; v->phase-=floor(v->phase); + } out[i]=sum; + } return XTC_OK; +} diff --git a/src/plugin/builtin.h b/src/plugin/builtin.h new file mode 100644 index 0000000..b2744a9 --- /dev/null +++ b/src/plugin/builtin.h @@ -0,0 +1,12 @@ +#ifndef XTC_BUILTIN_H +#define XTC_BUILTIN_H +#include "xtc/xtc_plugin.h" +typedef struct xtc_builtin xtc_builtin; +xtc_result xtc_builtin_probe(unsigned int index, xtc_plugin_spec *out); +xtc_result xtc_builtin_validate(const xtc_plugin_spec *s); +xtc_result xtc_builtin_describe(const xtc_plugin_spec *s, xtc_plugin_details *out); +xtc_result xtc_builtin_create(const xtc_plugin_spec *s, double rate, size_t frames, xtc_builtin **out); +void xtc_builtin_destroy(xtc_builtin *p); +xtc_result xtc_builtin_process(xtc_builtin *p, const float *in, float *out, size_t frames, uint64_t position); +xtc_result xtc_builtin_midi(xtc_builtin *p, const xtc_midi_event *events, size_t count, float *out, size_t frames); +#endif diff --git a/src/plugin/host.c b/src/plugin/host.c index 51ab4af..18bba3b 100644 --- a/src/plugin/host.c +++ b/src/plugin/host.c @@ -1,4 +1,5 @@ #include "modern.h" +#include "builtin.h" #include #include #include @@ -10,14 +11,15 @@ xtc_result xtc_ladspa_describe(const xtc_plugin_spec *, double, xtc_plugin_detai xtc_result xtc_ladspa_create(const xtc_plugin_spec *, double, size_t, xtc_ladspa_plugin **); void xtc_ladspa_destroy(xtc_ladspa_plugin *); xtc_result xtc_ladspa_process_at(xtc_ladspa_plugin *, const float *, float *, size_t, uint64_t); -struct xtc_plugin { xtc_ladspa_plugin *ladspa; xtc_modern *modern; uint64_t next; }; +struct xtc_plugin { xtc_ladspa_plugin *ladspa; xtc_modern *modern; xtc_builtin *builtin; uint64_t next; }; const char *xtc_plugin_format_name(xtc_plugin_format f) -{ static const char *names[] = {"LADSPA", "CLAP", "VST2", "VST3"}; return f >= 0 && f <= XTC_PLUGIN_VST3 ? names[f] : "Unknown"; } +{ static const char *names[] = {"LADSPA", "CLAP", "VST2", "VST3", "Internal"}; return f >= 0 && f <= XTC_PLUGIN_BUILTIN ? names[f] : "Unknown"; } xtc_result xtc_plugin_probe_format(xtc_plugin_format f, const char *path, unsigned int index, double rate, xtc_plugin_spec *out) { xtc_modern *p = NULL; xtc_result r; if (!out || !path || path[0] != '/' || strlen(path) >= 512 || index >= 1024 || !isfinite(rate) || rate < 1 || rate > 768000) return XTC_ERR_INVALID_ARGUMENT; + if (f == XTC_PLUGIN_BUILTIN) return !strcmp(path,XTC_BUILTIN_PATH) ? xtc_builtin_probe(index,out) : XTC_ERR_INVALID_ARGUMENT; if (f == XTC_PLUGIN_LADSPA) return xtc_ladspa_probe(path,index,rate,out); if (f < XTC_PLUGIN_CLAP || f > XTC_PLUGIN_VST3) return XTC_ERR_INVALID_ARGUMENT; r = xtc_modern_open(f,path,index,rate,256,&p,out); @@ -27,6 +29,7 @@ xtc_result xtc_plugin_probe(const char *path, unsigned int index, double rate, x { void *module; xtc_plugin_format f = XTC_PLUGIN_LADSPA; if (!path || path[0] != '/' || strlen(path) >= 512) return XTC_ERR_INVALID_ARGUMENT; + if (!strcmp(path,XTC_BUILTIN_PATH)) return xtc_plugin_probe_format(XTC_PLUGIN_BUILTIN,path,index,rate,out); module = dlopen(path,RTLD_NOW | RTLD_LOCAL); if (!module) return XTC_ERR_NOT_FOUND; if (dlsym(module,"clap_entry")) f = XTC_PLUGIN_CLAP; @@ -39,6 +42,8 @@ xtc_result xtc_plugin_describe(const xtc_plugin_spec *s, double rate, xtc_plugin { xtc_plugin_spec actual; xtc_result r; if (!out || xtc_plugin_spec_validate(s) != XTC_OK) return XTC_ERR_INVALID_ARGUMENT; + if (!isfinite(rate) || rate < 1 || rate > 768000) return XTC_ERR_INVALID_ARGUMENT; + if (s->format == XTC_PLUGIN_BUILTIN) return xtc_builtin_describe(s,out); if (s->format == XTC_PLUGIN_LADSPA) return xtc_ladspa_describe(s,rate,out); r = xtc_plugin_probe_format(s->format,s->path,s->index,rate,&actual); if (r != XTC_OK) return r; @@ -49,7 +54,7 @@ xtc_result xtc_plugin_describe(const xtc_plugin_spec *s, double rate, xtc_plugin return XTC_OK; } void xtc_plugin_destroy(xtc_plugin *p) -{ if (p) { xtc_ladspa_destroy(p->ladspa); xtc_modern_destroy(p->modern); free(p); } } +{ if (p) { xtc_builtin_destroy(p->builtin); xtc_ladspa_destroy(p->ladspa); xtc_modern_destroy(p->modern); free(p); } } xtc_result xtc_plugin_create(const xtc_plugin_spec *s, double rate, size_t frames, xtc_plugin **out) { xtc_plugin *p; xtc_result r; xtc_plugin_spec actual; @@ -58,7 +63,8 @@ xtc_result xtc_plugin_create(const xtc_plugin_spec *s, double rate, size_t frame r = xtc_plugin_spec_validate(s); if (r != XTC_OK) return r; if (!frames || frames > 65536 || !isfinite(rate) || rate < 1 || rate > 768000) return XTC_ERR_INVALID_ARGUMENT; p = calloc(1,sizeof(*p)); if (!p) return XTC_ERR_OUT_OF_MEMORY; - if (s->format == XTC_PLUGIN_LADSPA) r = xtc_ladspa_create(s,rate,frames,&p->ladspa); + if (s->format == XTC_PLUGIN_BUILTIN) r = xtc_builtin_create(s,rate,frames,&p->builtin); + else if (s->format == XTC_PLUGIN_LADSPA) r = xtc_ladspa_create(s,rate,frames,&p->ladspa); else { r = xtc_modern_open(s->format,s->path,s->index,rate,frames,&p->modern,&actual); if (r == XTC_OK && (actual.instrument != s->instrument || actual.unique_id != s->unique_id || actual.inputs != s->inputs || @@ -71,12 +77,12 @@ xtc_result xtc_plugin_process_at(xtc_plugin *p, const float *in, float *out, siz { xtc_result r; if (!p) return XTC_ERR_INVALID_ARGUMENT; - r = p->modern ? xtc_modern_process(p->modern,in,out,frames) : xtc_ladspa_process_at(p->ladspa,in,out,frames,position); + r = p->builtin ? xtc_builtin_process(p->builtin,in,out,frames,position) : p->modern ? xtc_modern_process(p->modern,in,out,frames) : xtc_ladspa_process_at(p->ladspa,in,out,frames,position); if (r == XTC_OK) p->next = position <= UINT64_MAX-frames ? position+frames : 0; return r; } xtc_result xtc_plugin_process_midi(xtc_plugin *p, const xtc_midi_event *events, size_t count, float *out, size_t frames) -{ return p && p->modern ? xtc_modern_process_midi(p->modern,events,count,out,frames) : XTC_ERR_UNSUPPORTED; } +{ return p && p->builtin ? xtc_builtin_midi(p->builtin,events,count,out,frames) : p && p->modern ? xtc_modern_process_midi(p->modern,events,count,out,frames) : XTC_ERR_UNSUPPORTED; } xtc_result xtc_plugin_process(xtc_plugin *p, const float *in, float *out, size_t frames) { return xtc_plugin_process_at(p,in,out,frames,p ? p->next : 0); } xtc_result xtc_plugin_parameters(const xtc_plugin_spec *s, double rate, xtc_parameter_info out[XTC_PLUGIN_CONTROLS]) diff --git a/src/plugin/ladspa.c b/src/plugin/ladspa.c index 432e597..1905df1 100644 --- a/src/plugin/ladspa.c +++ b/src/plugin/ladspa.c @@ -16,6 +16,7 @@ /* Header-only optional dependency; absent development header gives explicit * unsupported stubs, preserving metadata-only load/save of plugin documents. */ #include "config.h" +#include "builtin.h" static int terminated(const char *s, size_t n) { return memchr(s, 0, n) != NULL; } xtc_result xtc_plugin_spec_validate(const xtc_plugin_spec *s) { @@ -24,9 +25,11 @@ xtc_result xtc_plugin_spec_validate(const xtc_plugin_spec *s) !terminated(s->label,sizeof(s->label)) || !s->label[0] || s->index >= 1024 || s->inputs > 1 || s->control_count > XTC_PLUGIN_CONTROLS || s->tempo < 20000 || s->tempo > 300000 || s->point_count > XTC_PLUGIN_POINTS) return XTC_ERR_INVALID_ARGUMENT; - if (s->format < XTC_PLUGIN_LADSPA || s->format > XTC_PLUGIN_VST3 || + if (s->format < XTC_PLUGIN_LADSPA || s->format > XTC_PLUGIN_BUILTIN || !terminated(s->identifier,sizeof(s->identifier))) return XTC_ERR_INVALID_ARGUMENT; - if (s->format != XTC_PLUGIN_LADSPA) { + if (s->format == XTC_PLUGIN_BUILTIN) { + if (xtc_builtin_validate(s) != XTC_OK) return XTC_ERR_INVALID_ARGUMENT; + } else if (s->format != XTC_PLUGIN_LADSPA) { if (s->instrument > 1 || s->inputs != (s->instrument ? 0U : 1U) || s->control_count || s->point_count || ((s->format == XTC_PLUGIN_CLAP || s->format == XTC_PLUGIN_VST3) && !s->identifier[0])) return XTC_ERR_UNSUPPORTED; diff --git a/src/settings/settings.c b/src/settings/settings.c index 33bb399..a8b5a54 100644 --- a/src/settings/settings.c +++ b/src/settings/settings.c @@ -37,7 +37,8 @@ static const xtc_setting_info schema[] = { 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) + NUM("transport",loop_enabled,"Loop enabled: 0 or 1",0,1), + NUM("transport",follow_playhead,"Follow playhead: 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; } diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 0090c35..8df8466 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -5,6 +5,8 @@ #include "../../src/gui/mixer_widget.hpp" #include "../../src/gui/plugin_dialog.hpp" #include "../../src/gui/preferences_dialog.hpp" +#include "../../src/gui/command_palette.hpp" +#include #include "../../src/gui/plugin_library.hpp" #include #include @@ -856,7 +858,7 @@ int main(int argc, char **argv) CHECK(window.create_track(true) == XTC_OK); uint64_t second = sequence->selected_track(); auto *selector = sequence->instrument_choice(second); CHECK(selector && selector->value() == 0); - CHECK(selector->size() == 4); // Sine, current project instrument, load, terminator. + CHECK(selector->size() == 6); // Sine, two internal presets, project instrument, load, terminator. selector->value(1); selector->do_callback(); CHECK(xtc_project_get_instrument(sequence->project(),second,&spec) == XTC_OK && spec.instrument); CHECK(sequence->selected_track() == second); @@ -1080,6 +1082,58 @@ int main(int argc, char **argv) } xtc_project_destroy(project); } + { + reset();MainWindow window(false,"");window.show();CHECK(window.create_track(true)==XTC_OK); + auto *seq=timeline(&window);auto clip=seq->selected_clip(),track=seq->selected_track(); + CHECK(window.set_tempo(135500)==XTC_OK && xtc_project_tempo(seq->project())==135500); + CHECK(seq->selected_clip()==clip && seq->selected_track()==track); + CHECK(window.undo()==XTC_OK && xtc_project_tempo(seq->project())==120000); + CHECK(window.undo(true)==XTC_OK && xtc_project_tempo(seq->project())==135500); + const auto *before=seq->project();reserve_result=XTC_ERR_OUT_OF_MEMORY; + CHECK(window.set_tempo(100000)==XTC_ERR_OUT_OF_MEMORY && seq->project()==before); + reserve_result=XTC_OK;CHECK(window.set_tempo(0)==XTC_ERR_INVALID_ARGUMENT); + submit_result=XTC_ERR_CAPACITY;CHECK(window.set_tempo(100000)==XTC_ERR_CAPACITY && seq->project()==before);submit_result=XTC_OK; + CHECK(window.set_tempo(135500)==XTC_OK); + auto *bpm=dynamic_cast(find(&window,"BPM"));CHECK(bpm); + if(bpm){bpm->value(140);bpm->do_callback();CHECK(xtc_project_tempo(seq->project())==140000); + bpm->value(900);bpm->do_callback();CHECK(xtc_project_tempo(seq->project())==140000 && bpm->value()==140);} + + auto *gain=dynamic_cast(find(&window,"Gain"));CHECK(gain);pump(); + if(gain){gain->value(.6);gain->do_callback();}xtc_track_info info{};xtc_project_get_track(seq->project(),0,&info);CHECK(info.gain==.6f); + if(gain){Fl::focus(&gain->input);gain->input.value("0.123");pump();CHECK(std::strcmp(gain->input.value(),"0.123")==0);Fl::focus(nullptr);} + CHECK(window.undo()==XTC_OK);xtc_project_get_track(seq->project(),0,&info);CHECK(info.gain==1); + CHECK(seq->set_zoom(384)==XTC_OK);window.fit_song();CHECK(seq->zoom_level()<384); + CHECK(contains(&window,"1 tracks / 1 clips")); + Fl::add_timeout(.05,[](void *){auto *dialog=Fl::first_window();auto *search=dynamic_cast(find(dialog,"Search commands"));CHECK(search); + if(search){search->value("view/mixer");search->do_callback();}press(find(dialog,"Run command"));if(dialog->shown()){CHECK(false);dialog->hide();}}); + window.show_commands();CHECK(window.mixer()->visible_r()); + auto *bar=dynamic_cast(window.child(0));CHECK(bar);if(bar)bar->picked(bar->find_item("View/Sequencer")); + auto *follow=dynamic_cast(find(&window,"Follow playhead"));CHECK(follow); + if(follow){follow->value(1);follow->do_callback();CHECK(window.settings().follow_playhead==1);} + press(find(&window,"Play"));engine_status.sample_position=960000;pump(); + auto *scroll=dynamic_cast(seq->parent());CHECK(scroll && scroll->xposition()>0); + press(find(&window,"Stop"));window.fit_song();CHECK(scroll && scroll->xposition()==0); + + Fl::add_timeout(.05,[](void *){auto *dialog=Fl::first_window();auto *search=dynamic_cast(find(dialog,"Search commands")); + CHECK(search);if(search){search->value("does not exist");search->do_callback();}press(find(dialog,"Run command"));CHECK(dialog->shown());press(find(dialog,"Cancel"));}); + window.show_commands(); + CHECK(window.create_track(true)==XTC_OK);uint64_t second=seq->selected_track(); + window.mixer()->select_track(track);if(bar)bar->picked(bar->find_item("View/Mixer"));pump(); + if(gain){gain->value(.4);gain->do_callback();}xtc_project_get_track(seq->project(),0,&info);CHECK(info.id==track && info.gain==.4f); + xtc_project_get_track(seq->project(),1,&info);CHECK(info.id==second && info.gain==1); + window.do_callback(); + } + { + CommandPalette palette({"View/Mixer","File/Open"}); + Fl::add_timeout(.05,[](void *context){auto *p=static_cast(context);auto *search=dynamic_cast(find(p,"Search commands"));CHECK(search); + if(search){search->value("FILE");search->do_callback();}int old=Fl::e_keysym;Fl::e_keysym=FL_Enter;CHECK(p->handle(FL_KEYDOWN)==1);Fl::e_keysym=old;},&palette); + CHECK(palette.run()=="File/Open"); + } + { + CommandPalette palette({"View/Mixer"}); + Fl::add_timeout(.05,[](void *context){int old=Fl::e_keysym;Fl::e_keysym=FL_Escape;static_cast(context)->handle(FL_KEYDOWN);Fl::e_keysym=old;},&palette); + CHECK(palette.run().empty()); + } 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_install.py b/tests/integration/test_install.py index 9bd55d2..0b0c5e0 100644 --- a/tests/integration/test_install.py +++ b/tests/integration/test_install.py @@ -56,7 +56,7 @@ def main(): run([str(binaries / 'xtc-check'), '--inspect', str(resources / 'xtc/examples/modern-session.xtc')]) assert (docs / 'WORKSPACE.md').is_file() diagnose = subprocess.check_output([str(binaries / 'xtc-check'), '--diagnose'], text=True) - assert 'Project formats: 1..12' in diagnose and 'JACK compiled:' in diagnose + assert 'Project formats: 1..13' in diagnose and 'JACK compiled:' in diagnose # Replacing a file symlink must not overwrite an unrelated target. protected = stage / 'unrelated-target' protected.write_text('keep unrelated target\n') diff --git a/tests/integration/test_project_inspection.py b/tests/integration/test_project_inspection.py index ab403fc..17efa39 100644 --- a/tests/integration/test_project_inspection.py +++ b/tests/integration/test_project_inspection.py @@ -84,7 +84,7 @@ with tempfile.TemporaryDirectory(prefix="xtc-inspect-") as directory: project.write_text(malformed) check("--validate", project, 1) for text, messages in [ - (base.replace("XTC_PROJECT 10", "XTC_PROJECT 999"), ("line 1", "999", "1..12")), + (base.replace("XTC_PROJECT 10", "XTC_PROJECT 999"), ("line 1", "999", "1..13")), ("XTC_PROJECT 10\n", ("line 2", "APP")), (base.replace("TEMPO 120000", "TEMPO 0"), ("line 3", "TEMPO")), (base + "OPTIONAL_FUTURE 1\n", ("Unknown fields",)), diff --git a/tests/unit/test_builtin.c b/tests/unit/test_builtin.c new file mode 100644 index 0000000..778ea8f --- /dev/null +++ b/tests/unit/test_builtin.c @@ -0,0 +1,73 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_plugin.h" +#include +#include +#include "xtc/xtc_midi.h" +#include +#include +#include +#include +static void effects(unsigned int id) +{ + xtc_plugin_spec s; xtc_plugin *a=NULL,*b=NULL; float in[256]={0},x[256],y[256]; size_t k,i; double energy=0; + assert(xtc_plugin_probe(XTC_BUILTIN_PATH,id,48000,&s)==XTC_OK); + assert(xtc_plugin_create(&s,48000,256,&a)==XTC_OK); + assert(xtc_plugin_create(&s,48000,256,&b)==XTC_OK); + for(k=0;k<600;++k) { + in[0]=k==0 ? 1 : 0; + assert(xtc_plugin_process(a,in,x,256)==XTC_OK); + assert(xtc_plugin_process(b,in,y,128)==XTC_OK); + assert(xtc_plugin_process(b,in+128,y+128,128)==XTC_OK); + for(i=0;i<256;++i) {assert(isfinite(x[i]));assert(fabsf(x[i]-y[i])<1e-6f);if(k>0)energy+=fabs(x[i]);} + } + assert(energy>.01);xtc_plugin_destroy(a);xtc_plugin_destroy(b); + s.values[2]=0; assert(xtc_plugin_create(&s,48000,256,&a)==XTC_OK); + in[0]=1;assert(xtc_plugin_process(a,in,x,256)==XTC_OK);assert(!memcmp(in,x,sizeof(in)));xtc_plugin_destroy(a); + s.values[1]=2;assert(xtc_plugin_spec_validate(&s)!=XTC_OK); +} +static void synth(unsigned int id) +{ + xtc_plugin_spec s; xtc_plugin *p=NULL; float out[256]; size_t i,k; double energy=0; + xtc_midi_event e={0,XTC_MIDI_NOTE_ON,0,60,100}; + assert(xtc_plugin_probe(XTC_BUILTIN_PATH,id,48000,&s)==XTC_OK); + assert(xtc_plugin_create(&s,48000,256,&p)==XTC_OK); + assert(xtc_plugin_process_midi(p,&e,1,out,256)==XTC_OK); + for(i=0;i<256;++i)energy+=fabs(out[i]); + assert(energy>0); + e.type=XTC_MIDI_NOTE_OFF;assert(xtc_plugin_process_midi(p,&e,1,out,256)==XTC_OK); + for(k=0;k<200;++k)assert(xtc_plugin_process_midi(p,NULL,0,out,256)==XTC_OK); + for(i=0;i<256;++i)assert(fabsf(out[i])<1e-5f); + e.type=XTC_MIDI_ALL_OFF;assert(xtc_plugin_process_midi(p,&e,1,out,256)==XTC_OK); + for(i=0;i<256;++i)assert(out[i]==0); + xtc_plugin_destroy(p); +} +static void automation(void) +{ + xtc_plugin_spec s; xtc_plugin *p=NULL; float in[128],out[128]; size_t i; + assert(xtc_plugin_probe(XTC_BUILTIN_PATH,2,960,&s)==XTC_OK); + s.tempo=60000; s.values[0]=2000; s.point_count=2; + s.points[0]=(xtc_plugin_point){0,2,0,0}; s.points[1]=(xtc_plugin_point){100,2,0,1}; + assert(xtc_plugin_create(&s,960,128,&p)==XTC_OK); + for(i=0;i<128;++i)in[i]=1; + assert(xtc_plugin_process_at(p,in,out,128,0)==XTC_OK); + for(i=0;i<128;++i) assert(fabsf(out[i]-(i<100 ? 1-(float)i/100 : 0))<1e-6f); + xtc_plugin_destroy(p); +} +static void roundtrip(void) +{ + xtc_project *p=NULL,*q=NULL; xtc_plugin_spec s,t; uint64_t track; size_t id; xtc_graph *g=NULL; + char path[]="/tmp/xtc-builtin-XXXXXX"; int fd=mkstemp(path); assert(fd>=0); close(fd); + assert(xtc_project_create(&p)==XTC_OK); assert(xtc_project_create(&q)==XTC_OK); + assert(xtc_project_add_track(p,"Keys",&track)==XTC_OK); + assert(xtc_plugin_probe(XTC_BUILTIN_PATH,0,48000,&s)==XTC_OK); + assert(xtc_project_set_instrument(p,track,&s)==XTC_OK); + assert(xtc_plugin_probe(XTC_BUILTIN_PATH,2,48000,&s)==XTC_OK); s.values[0]=250; + s.point_count=2; s.points[0]=(xtc_plugin_point){0,2,0,.1f}; s.points[1]=(xtc_plugin_point){960,2,0,.6f}; + assert(xtc_graph_clone(xtc_project_graph(p),&g)==XTC_OK); + assert(xtc_graph_add_plugin(g,&s,&id)==XTC_OK); assert(xtc_project_set_graph(p,g)==XTC_OK);xtc_graph_destroy(g); + assert(xtc_project_save(p,path)==XTC_OK); assert(xtc_project_load(q,path)==XTC_OK); + assert(xtc_graph_get_plugin(xtc_project_graph(q),id,&t)==XTC_OK); assert(t.values[0]==250 && t.point_count==2); + assert(xtc_project_get_instrument(q,track,&t)==XTC_OK && t.index==0 && t.format==XTC_PLUGIN_BUILTIN); + xtc_project_destroy(p);xtc_project_destroy(q);unlink(path); +} +int main(void) {automation();roundtrip();effects(2);effects(3);synth(0);synth(1);puts("Built-in devices passed.");return 0;} diff --git a/tests/unit/test_project_diagnostics.c b/tests/unit/test_project_diagnostics.c index 5bcc52a..94e8496 100644 --- a/tests/unit/test_project_diagnostics.c +++ b/tests/unit/test_project_diagnostics.c @@ -68,7 +68,7 @@ int main(void) assert(xtc_project_add_track(old,"Old",&id) == XTC_OK && xtc_project_add_track(next,"New",&id) == XTC_OK); assert(xtc_project_save(old,song) == XTC_OK); size = bytes(song,before); assert(xtc_project_load_diagnostic(loaded,song,&diagnostic) == XTC_OK && diagnostic.result == XTC_OK && - diagnostic.format_version == 12 && !diagnostic.line); + diagnostic.format_version == 13 && !diagnostic.line); for (mode = 1; mode <= 5; ++mode) { fault = mode; assert(xtc_project_save(next,song) == XTC_ERR_IO); @@ -81,7 +81,7 @@ int main(void) } write_text(bad,"XTC_PROJECT 999\n"); assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_UNSUPPORTED && diagnostic.line == 1 && - diagnostic.format_version == 999 && !strcmp(diagnostic.record,"XTC_PROJECT") && strstr(diagnostic.message,"1..12")); + diagnostic.format_version == 999 && !strcmp(diagnostic.record,"XTC_PROJECT") && strstr(diagnostic.message,"1..13")); write_text(bad,"XTC_PROJECT 10\nAPP 0 1 0\nTEMPO nope\n"); assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_FORMAT && diagnostic.line == 3 && !strcmp(diagnostic.record,"TEMPO")); diff --git a/tests/unit/test_settings.c b/tests/unit/test_settings.c index b827ad4..8a25f44 100644 --- a/tests/unit/test_settings.c +++ b/tests/unit/test_settings.c @@ -13,7 +13,7 @@ int main(void) 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; + strcpy(a.clap_paths,"~/space folder:/tmp/quoted\"name\\path");a.loop_enabled=1;a.follow_playhead=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);