diff --git a/Makefile b/Makefile index 0d6b601..2d2c80e 100644 --- a/Makefile +++ b/Makefile @@ -608,3 +608,7 @@ test-midi-file: $(BUILD_DIR)/test_midi_file ./$(BUILD_DIR)/test_midi_file test: test-midi-file -include $(BUILD_DIR)/tests/unit/test_midi_file.d + +.PHONY: test-configuration-startup +test-configuration-startup: gui + xvfb-run -a -s "-screen 0 1440x960x24" $(PYTHON) tests/integration/test_configuration_startup.py ./$(BUILD_DIR)/xtc diff --git a/README.md b/README.md index 7cd82c8..7adbfbf 100644 --- a/README.md +++ b/README.md @@ -184,3 +184,10 @@ Phases 90–99 add direct selected-track inserts, effect duplication, chain copy paste, A/B snapshots, three internal effect templates, bulk rack actions, insert peak meters, tap tempo, audio crossfades and MIDI chord strumming. See [creative workflows and limits](docs/CREATIVE_WORKFLOWS.md). + +MIDI devices and defaults are configurable in **Preferences → midi**, including +JACK/PipeWire input/output choices, channel filtering and optional thru. Use +**File → Import MIDI** or **Export selected clip as MIDI** to exchange note clips. +Application preferences load from **`~/.xtc.conf`** at startup and save on Apply +and clean exit. See [configuration and MIDI workflows](docs/MIDI_WORKFLOWS.md) +for file support, routing and persistence details. diff --git a/docs/MIDI_WORKFLOWS.md b/docs/MIDI_WORKFLOWS.md index 347946d..00a2c23 100644 --- a/docs/MIDI_WORKFLOWS.md +++ b/docs/MIDI_WORKFLOWS.md @@ -82,3 +82,8 @@ Unterminated notes, zero source duration and malformed events reject rather than silently dropping notes. Unmatched note-offs are harmless and ignored. Overlapping same-pitch notes pair FIFO within each file track/channel. Project save/reload uses the existing format; no format migration or extra dependency is required. + +Configured startup holds the transport clock and emits silence until selected +MIDI ports are connected, so notes at the song start are not consumed before +routing. MIDI routing retries publication for at most 100 ms on the control +thread; the callback never sleeps or calls the JACK graph connection APIs. diff --git a/docs/PREFERENCES.md b/docs/PREFERENCES.md index 54dd689..c5c6c00 100644 --- a/docs/PREFERENCES.md +++ b/docs/PREFERENCES.md @@ -3,11 +3,12 @@ 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. +recording before opening Preferences. Clean exit also saves collected application settings. +Startup reads `~/.xtc.conf` before project/backend creation; missing keys use 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]`, +The file is INI with `[audio]`, `[plugins]`, `[midi]`, `[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 @@ -18,7 +19,8 @@ may be quoted, with `\\` and `\"` escapes. Apply rewrites comments and formattin | --- | --- | | 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` | +| midi | `midi_input_port=""`, `midi_output_port=""`, `midi_input_channel=-1`, `midi_output_channel=-1`, `midi_thru=0`; ports connect on Play/Record | +| interface | `main_x=0`, `main_y=0`, `inspector_visible=0`, `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`, `follow_playhead=0` | @@ -34,7 +36,7 @@ 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. +in use populate Preferences when it opens; Apply or a clean application exit persists 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. @@ -49,7 +51,7 @@ other locations. It is not a plugin certification or isolation service. **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. +and toggling take effect immediately; Apply or clean exit 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. @@ -73,12 +75,19 @@ playback stopping uses the GUI timer and is disabled during looping/recording. See BUILTIN_DEVICES.md for endpoint, cancellation and reset semantics. Phase 45 adds `[interface] transport_detached`, `mixer_detached`, and each -panel's `_x`, `_y`, `_width`, `_height` rectangle. Use Preferences / Apply after +panel's `_x`, `_y`, `_width`, `_height` rectangle. Use Preferences / Apply or close the application cleanly after moving panels to save the captured layout. Restoration clamps rectangles to the current display; the floating transport wraps at narrow widths. Phase 46 extends `[interface]` with `sequencer_detached`, `graph_detached`, `piano_detached`, editor window geometry and `track_header_width` (180..360). Piano retains `piano_width`/`piano_height` and adds `piano_x`/`piano_y`. -Reset workspace layout changes the session; Preferences / Apply persists it. +Reset workspace layout changes the session; Preferences / Apply or clean exit persists it. Only original-tab docking and floating windows are supported. + +Phases 100–105 extend configuration with complete workspace collection and MIDI +ports/channels/thru. Invalid startup files are preserved on exit; explicit Apply +can replace them after validation. If saving fails, the window stays open for +retry. See [MIDI_WORKFLOWS.md](MIDI_WORKFLOWS.md) for device setup and notes-only +Standard MIDI File import/export. JACK server hardware settings and musical +project contents remain owned by the server and project respectively. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e6d3a74..77ab658 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1006,7 +1006,8 @@ Persist application preferences in `~/.xtc.conf` and provide selectable sequence ### 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 +were initially saved only with Apply; Phase 100 also saves on clean exit. +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. @@ -2301,15 +2302,38 @@ Implemented: bounded C99 type 0/1 PPQN notes import with running status, channel Capture current MIDI preferences, file workflows and imported notes, refresh the gallery, and qualify configuration, MIDI I/O and file workflows together. ### Tasks and acceptance criteria -- [ ] Implement and document the workflow with bounded validation and safe ownership. -- [ ] Pass meaningful core and GUI checks, including errors and persistence or undo. -- [ ] Commit this phase separately after validation. +- [x] Implement and document the workflow with bounded validation and safe ownership. +- [x] Pass meaningful core and GUI checks, including errors and persistence or undo. +- [x] Commit this phase separately after validation. ### Implementation status -Planned. +Implemented: all 66 gallery PNGs were refreshed, including five new MIDI preferences/help/file-menu/import captures through real C/GUI workflows. A real-process isolated-home test proves ~/.xtc.conf creation, startup reload and malformed-file preservation. Final routing qualification adds bounded PipeWire publication retry and holds transport until chosen routes exist, preserving notes at position zero; deterministic callback and real receiver checks pass. Full core/reliability, GUI/plugin/JACK, optional-backend builds, ASan/UBSan, static analysis and staged installation checks passed. --- +## Integrated Validation for Phases 100–105 + +Completed on 2026-11-06. Normal builds use `BUILD_DIR=build/audio-editor`: +`all gui test-reliability`, `test-gui-midi-workflows`, `test-gui-creative`, +`test-gui-track-fx`, full `test-gui`, modern-plugin/instrument/LADSPA GUI fixtures, +`test-gui-jack`, `test-jack-midi`, and `test-configuration-startup` passed. +The final routed-start change was rechecked with core/reliability, full/focused +GUI, live MIDI first-note receipt and real-process configuration startup tests. + +`BUILD_DIR=build/midi-minimal PKG_CONFIG=false FLTK_CONFIG=false all test` passed. +Clang ASan/UBSan full core/reliability and focused/full GUI checks passed using +only the existing Fontconfig leak suppression. The existing Clang/FLTK redundant +instrumentation warning remains; normal warning-enabled builds are clean. +Clang static analysis (including a final backend recheck), default/custom staged +install layouts and C/C++ pkg-config consumers passed. + +The full gallery and host VST3/creative/MIDI capture modes regenerated 66 PNGs. +File import is notes-only type 0/1 PPQN and retains project tempo. MIDI devices +are JACK/PipeWire ports; physical hardware/bridge compatibility is not claimed +from software-port tests. No dependencies or project-format change were added. +See [MIDI_WORKFLOWS.md](MIDI_WORKFLOWS.md), [PREFERENCES.md](PREFERENCES.md) and +[the gallery](../screenshots/README.md) for reproduction and exact limits. + ## 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/WORKFLOW.md b/docs/WORKFLOW.md index d86e622..0cdfa71 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -114,7 +114,8 @@ Duplicate, Rename and Delete. Invalid/capacity edits retain the existing model. View/Snap selects 1, 120, 240 (default), 480 or 960 ticks. It affects ruler seeks, clip moves and trims; 1 tick is the finest grid. Existing +/- zoom and scrollbars retain their behavior. Piano roll has its own configurable grid (see MIDI.md); mixer lanes retain their 240-tick -grid. Grid/zoom/selection are session UI preferences, not undoable music edits. +grid. Grid and zoom are application preferences saved on Apply/clean exit; +selection is session state. These controls are not undoable music edits. Escape cancels a drag; Delete affects the focused editor; text fields keep their own editing keys. Commands are named in menus/tooltips and timeline focus accepts keyboard editing; screen-reader and broad accessibility qualification are pending. diff --git a/docs/WORKSPACE.md b/docs/WORKSPACE.md index c88fa90..02cd5fa 100644 --- a/docs/WORKSPACE.md +++ b/docs/WORKSPACE.md @@ -164,7 +164,7 @@ and meters; the main Mixer tab offers Show and Dock actions while it is floating The transport owns the playback buttons, position, **BPM**, **Loop** toggle and **Loop range...** settings in both layouts. Shift-drag on the sequencer ruler still updates the same loop region. BPM remains an undoable project edit; -loop preferences retain their existing Preferences / Apply persistence behavior. +loop preferences persist through Preferences / Apply or clean application exit. Space and menu playback shortcuts work from either floating panel, with normal numeric/text editing protection. Closing the main window closes all panels only after the existing unsaved/recording/backend shutdown checks succeed. @@ -182,8 +182,8 @@ playback shortcuts, failed shutdown and cleanup. ## Phase 45 saved panel layouts Preferences / Apply now saves transport/mixer dock state and window rectangles. -Open Preferences after arranging the windows to capture their current positions. -Closing the application alone does not save preferences. Startup restores the +Preferences / Apply captures their current positions; Phase 100 also saves on +clean application exit. Startup restores the saved layout; off-screen rectangles are clamped to an available display's work area. Invalid dimensions are rejected transactionally by the settings reader. diff --git a/include/xtc/xtc_backend.h b/include/xtc/xtc_backend.h index 97761c5..7374345 100644 --- a/include/xtc/xtc_backend.h +++ b/include/xtc/xtc_backend.h @@ -81,6 +81,10 @@ xtc_result xtc_backend_close(xtc_backend *backend); * @return XTC_OK on success, or an error code describing the failure. */ xtc_result xtc_backend_start(xtc_backend *backend); +/* Activate with transport held until chosen MIDI routes are established. + * NULL/empty routes mean none. Control-thread only; failed routes stop playback. + * Avoids losing notes at position zero while JACK publishes/connects ports. */ +xtc_result xtc_backend_start_routed(xtc_backend *,const char *input_source,const char *output_destination); /** * @brief Deactivates the backend and establishes callback quiescence. * diff --git a/screenshots/README.md b/screenshots/README.md index c580eba..86227f6 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -1,7 +1,8 @@ # xtc screenshots -The complete gallery was refreshed after Phase 89 on 2026-11-06, including -the sample editor, compact workspace and real installed VST3 insert chains. +The complete gallery was refreshed after Phases 100–105 on 2026-11-06, including +MIDI preferences/import, the sample editor, creative workflows and real installed +VST3 insert chains. These are actual rendered application windows and menus on a 1440 × 960 virtual X11 screen, without mockups, compositing or image edits. @@ -14,10 +15,27 @@ APIs and GUI history/preparation paths. Original projects, media and user configuration are not overwritten. Black margins are the virtual desktop; floating views show the actual detached windows together. +## MIDI devices, defaults and file exchange (Phases 100–105) + +- [MIDI source/destination preferences and channel defaults](preferences-midi.png) +- [Device setup and MIDI defaults help](preferences-midi-help.png) +- [File menu with MIDI import and selected-clip export](midi-file-menu.png) +- [An exported clip reimported into a new arrangement](midi-import-arrangement.png) +- [The imported notes and velocities in the piano roll](midi-import-piano-roll.png) + +These captures use real file export/import and the real preferences schema. No +hardware device names or connected devices are fabricated; blank port defaults +mean no automatic connection. Actual device choices depend on host JACK/PipeWire +exposure. Configuration paths are disabled in captures to preserve user settings. +See [MIDI workflows and limits](../docs/MIDI_WORKFLOWS.md). + +Reproduce these five captures with +`XTC_CAPTURE_MIDI_ONLY=1 make BUILD_DIR=build/audio-editor screenshots`. + ## Creative workflows (Phases 90–99) -Four additional real captures show the current transport Tap button, insert -meters and new creative controls. Earlier gallery images retain Phase 89 UI. +Four real captures show the current transport Tap button, insert meters and +creative controls; the rest of the gallery also reflects the current UI. - [Dub Echo template in the insert graph](creative-dub-rack.png) - [Rack actions: bulk edits, comparison and chain clipboard](creative-rack-actions.png) diff --git a/screenshots/about.png b/screenshots/about.png index 2c0a6cb..cc920f1 100644 Binary files a/screenshots/about.png and b/screenshots/about.png differ diff --git a/screenshots/arrangement-markers.png b/screenshots/arrangement-markers.png index 553710e..3ea5ba2 100644 Binary files a/screenshots/arrangement-markers.png and b/screenshots/arrangement-markers.png differ diff --git a/screenshots/audio-clip-properties.png b/screenshots/audio-clip-properties.png index d172373..8251942 100644 Binary files a/screenshots/audio-clip-properties.png and b/screenshots/audio-clip-properties.png differ diff --git a/screenshots/audio-normalization-result.png b/screenshots/audio-normalization-result.png index cbfac94..e123d27 100644 Binary files a/screenshots/audio-normalization-result.png and b/screenshots/audio-normalization-result.png differ diff --git a/screenshots/audio-normalization.png b/screenshots/audio-normalization.png index de63931..b0c625a 100644 Binary files a/screenshots/audio-normalization.png and b/screenshots/audio-normalization.png differ diff --git a/screenshots/clap-instrument-descriptors.png b/screenshots/clap-instrument-descriptors.png index cb5c7f6..e510ac7 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 index 96e1ce5..dce2eec 100644 Binary files a/screenshots/commands.png and b/screenshots/commands.png differ diff --git a/screenshots/compact-workspace.png b/screenshots/compact-workspace.png index 553710e..3ea5ba2 100644 Binary files a/screenshots/compact-workspace.png and b/screenshots/compact-workspace.png differ diff --git a/screenshots/create-track-menu.png b/screenshots/create-track-menu.png index 902e86b..894518c 100644 Binary files a/screenshots/create-track-menu.png and b/screenshots/create-track-menu.png differ diff --git a/screenshots/internal-delay.png b/screenshots/internal-delay.png index 88652da..dfafd51 100644 Binary files a/screenshots/internal-delay.png and b/screenshots/internal-delay.png differ diff --git a/screenshots/internal-instruments.png b/screenshots/internal-instruments.png index 09b7022..3414860 100644 Binary files a/screenshots/internal-instruments.png and b/screenshots/internal-instruments.png differ diff --git a/screenshots/internal-reverb.png b/screenshots/internal-reverb.png index f945eb2..fc2d655 100644 Binary files a/screenshots/internal-reverb.png and b/screenshots/internal-reverb.png differ diff --git a/screenshots/loop-range.png b/screenshots/loop-range.png index b9b0399..4659980 100644 Binary files a/screenshots/loop-range.png and b/screenshots/loop-range.png differ diff --git a/screenshots/marker-edit.png b/screenshots/marker-edit.png index d0a0ae3..5168dfa 100644 Binary files a/screenshots/marker-edit.png and b/screenshots/marker-edit.png differ diff --git a/screenshots/midi-arpeggio-result.png b/screenshots/midi-arpeggio-result.png index 6527405..c62c4f1 100644 Binary files a/screenshots/midi-arpeggio-result.png and b/screenshots/midi-arpeggio-result.png differ diff --git a/screenshots/midi-arpeggio.png b/screenshots/midi-arpeggio.png index 435fdcc..54dfc70 100644 Binary files a/screenshots/midi-arpeggio.png and b/screenshots/midi-arpeggio.png differ diff --git a/screenshots/midi-chord-builder.png b/screenshots/midi-chord-builder.png index 4b17e84..624908e 100644 Binary files a/screenshots/midi-chord-builder.png and b/screenshots/midi-chord-builder.png differ diff --git a/screenshots/midi-chords-result.png b/screenshots/midi-chords-result.png index 81d9dbe..83e4493 100644 Binary files a/screenshots/midi-chords-result.png and b/screenshots/midi-chords-result.png differ diff --git a/screenshots/midi-consolidation.png b/screenshots/midi-consolidation.png index 79b4065..c69f822 100644 Binary files a/screenshots/midi-consolidation.png and b/screenshots/midi-consolidation.png differ diff --git a/screenshots/midi-controller-mapping.png b/screenshots/midi-controller-mapping.png index d224c7b..ed7964e 100644 Binary files a/screenshots/midi-controller-mapping.png and b/screenshots/midi-controller-mapping.png differ diff --git a/screenshots/midi-file-menu.png b/screenshots/midi-file-menu.png new file mode 100644 index 0000000..60c7cba Binary files /dev/null and b/screenshots/midi-file-menu.png differ diff --git a/screenshots/midi-humanize.png b/screenshots/midi-humanize.png index 56b1c48..08ae7c7 100644 Binary files a/screenshots/midi-humanize.png and b/screenshots/midi-humanize.png differ diff --git a/screenshots/midi-import-arrangement.png b/screenshots/midi-import-arrangement.png new file mode 100644 index 0000000..befb4c2 Binary files /dev/null and b/screenshots/midi-import-arrangement.png differ diff --git a/screenshots/midi-import-piano-roll.png b/screenshots/midi-import-piano-roll.png new file mode 100644 index 0000000..0964a3a Binary files /dev/null and b/screenshots/midi-import-piano-roll.png differ diff --git a/screenshots/midi-split.png b/screenshots/midi-split.png index f39be74..5118369 100644 Binary files a/screenshots/midi-split.png and b/screenshots/midi-split.png differ diff --git a/screenshots/mixer-automation.png b/screenshots/mixer-automation.png index 25a5540..339331d 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 4e3a94c..3dc54e0 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 4fee17e..9937496 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 4c04274..5e1a027 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 c542a9d..2f95443 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 553710e..3ea5ba2 100644 Binary files a/screenshots/overview.png and b/screenshots/overview.png differ diff --git a/screenshots/piano-roll-velocity.png b/screenshots/piano-roll-velocity.png index 9ec4509..64ad674 100644 Binary files a/screenshots/piano-roll-velocity.png and b/screenshots/piano-roll-velocity.png differ diff --git a/screenshots/plugin-controls.png b/screenshots/plugin-controls.png index d0e1465..4e26067 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 8639c92..01e1f5d 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 5bf129a..920911c 100644 Binary files a/screenshots/plugin-library.png and b/screenshots/plugin-library.png differ diff --git a/screenshots/preferences-audio.png b/screenshots/preferences-audio.png index b819158..d051582 100644 Binary files a/screenshots/preferences-audio.png and b/screenshots/preferences-audio.png differ diff --git a/screenshots/preferences-editing.png b/screenshots/preferences-editing.png index a30e801..5409ee8 100644 Binary files a/screenshots/preferences-editing.png and b/screenshots/preferences-editing.png differ diff --git a/screenshots/preferences-interface.png b/screenshots/preferences-interface.png index 33c7f9b..fdd5e22 100644 Binary files a/screenshots/preferences-interface.png and b/screenshots/preferences-interface.png differ diff --git a/screenshots/preferences-midi-help.png b/screenshots/preferences-midi-help.png new file mode 100644 index 0000000..30c62ee Binary files /dev/null and b/screenshots/preferences-midi-help.png differ diff --git a/screenshots/preferences-midi.png b/screenshots/preferences-midi.png new file mode 100644 index 0000000..b6f8a8d Binary files /dev/null and b/screenshots/preferences-midi.png differ diff --git a/screenshots/preferences-plugins.png b/screenshots/preferences-plugins.png index ed3ac46..cbb0503 100644 Binary files a/screenshots/preferences-plugins.png and b/screenshots/preferences-plugins.png differ diff --git a/screenshots/preferences-project_defaults.png b/screenshots/preferences-project_defaults.png index e23bbe4..00a6506 100644 Binary files a/screenshots/preferences-project_defaults.png and b/screenshots/preferences-project_defaults.png differ diff --git a/screenshots/preferences-transport.png b/screenshots/preferences-transport.png index f38ccd3..8ebc85f 100644 Binary files a/screenshots/preferences-transport.png and b/screenshots/preferences-transport.png differ diff --git a/screenshots/project-collection.png b/screenshots/project-collection.png index 1a60aaf..9077f18 100644 Binary files a/screenshots/project-collection.png and b/screenshots/project-collection.png differ diff --git a/screenshots/ripple-result.png b/screenshots/ripple-result.png index 50f81fc..fb91afd 100644 Binary files a/screenshots/ripple-result.png and b/screenshots/ripple-result.png differ diff --git a/screenshots/ripple-time.png b/screenshots/ripple-time.png index e6c2127..33f8800 100644 Binary files a/screenshots/ripple-time.png and b/screenshots/ripple-time.png differ diff --git a/screenshots/section-navigation.png b/screenshots/section-navigation.png index 553710e..3ea5ba2 100644 Binary files a/screenshots/section-navigation.png and b/screenshots/section-navigation.png differ diff --git a/screenshots/sequencer-audio-waveform.png b/screenshots/sequencer-audio-waveform.png index cbfac94..e123d27 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 553710e..3ea5ba2 100644 Binary files a/screenshots/sequencer-loop.png and b/screenshots/sequencer-loop.png differ diff --git a/screenshots/track-duplication.png b/screenshots/track-duplication.png index d90c876..b92c8b5 100644 Binary files a/screenshots/track-duplication.png and b/screenshots/track-duplication.png differ diff --git a/screenshots/track-insert-vst3-identity.png b/screenshots/track-insert-vst3-identity.png index 6a631c2..5412a87 100644 Binary files a/screenshots/track-insert-vst3-identity.png and b/screenshots/track-insert-vst3-identity.png differ diff --git a/screenshots/track-inserts-parallel.png b/screenshots/track-inserts-parallel.png index 14b6062..f3a1c45 100644 Binary files a/screenshots/track-inserts-parallel.png and b/screenshots/track-inserts-parallel.png differ diff --git a/screenshots/track-inserts-vst3.png b/screenshots/track-inserts-vst3.png index 3fd2235..f64d71d 100644 Binary files a/screenshots/track-inserts-vst3.png and b/screenshots/track-inserts-vst3.png differ diff --git a/screenshots/transport-detached.png b/screenshots/transport-detached.png index 38cc181..546f9b1 100644 Binary files a/screenshots/transport-detached.png and b/screenshots/transport-detached.png differ diff --git a/screenshots/vst2-instrument-descriptors.png b/screenshots/vst2-instrument-descriptors.png index d956efb..94c2900 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 28d8532..c6e9099 100644 Binary files a/screenshots/vst3-instrument-descriptors.png and b/screenshots/vst3-instrument-descriptors.png differ diff --git a/screenshots/workspace-detached.png b/screenshots/workspace-detached.png index 0eed4dc..0a5fc29 100644 Binary files a/screenshots/workspace-detached.png and b/screenshots/workspace-detached.png differ diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c index 41b0c79..3790994 100644 --- a/src/backend/jack_backend.c +++ b/src/backend/jack_backend.c @@ -21,7 +21,7 @@ struct xtc_backend { xtc_audio_plan *audio; xtc_midi_plan *midi; xtc_midi_output_plan *midi_out; - unsigned int output_seen,output_muted; + unsigned int output_seen,output_muted,route_wait; int input_channel,output_channel,midi_thru; unsigned int panic_requested, panic_seen, midi_dropped, peak_left, peak_right; xtc_recorder *recorder; /* Borrowed, installed while callbacks quiescent. */ @@ -167,7 +167,7 @@ static int process(jack_nframes_t frames, void *context) __atomic_store_n(&backend->process_result, (unsigned int)XTC_ERR_IO, __ATOMIC_RELAXED); return 0; } - if (__atomic_load_n(&backend->stop_done, __ATOMIC_ACQUIRE)) { + if (__atomic_load_n(&backend->route_wait,__ATOMIC_ACQUIRE) || __atomic_load_n(&backend->stop_done, __ATOMIC_ACQUIRE)) { memset(output, 0, (size_t)frames * sizeof(*output)); if (right) memset(right, 0, (size_t)frames * sizeof(*right)); __atomic_store_n(&backend->peak_left, 0U, __ATOMIC_RELAXED); @@ -936,10 +936,28 @@ xtc_result xtc_backend_route_midi(xtc_backend *b,xtc_port_direction d,const char r=xtc_backend_midi_name(b,d,&own);if(r!=XTC_OK)return r; if(!b->running)return XTC_ERR_INVALID_STATE; if(!strcmp(remote,jack_port_name(b->midi_input)) || !strcmp(remote,jack_port_name(b->midi_output)))return XTC_ERR_INVALID_ARGUMENT; - code=disconnect ? jack_disconnect(b->client,d==XTC_PORT_OUTPUT ? own : remote,d==XTC_PORT_OUTPUT ? remote : own) : - jack_connect(b->client,d==XTC_PORT_OUTPUT ? own : remote,d==XTC_PORT_OUTPUT ? remote : own); + if(disconnect)code=jack_disconnect(b->client,d==XTC_PORT_OUTPUT ? own : remote,d==XTC_PORT_OUTPUT ? remote : own); + else{unsigned int attempt; + /* PipeWire publishes newly activated ports asynchronously. Control only. */ + for(attempt=0;;++attempt){ + code=jack_connect(b->client,d==XTC_PORT_OUTPUT ? own : remote,d==XTC_PORT_OUTPUT ? remote : own); + if(code==0 || code==EEXIST || attempt==10)break; + {struct timespec pause={0,10000000};nanosleep(&pause,NULL);} + } + } return code==0 || (!disconnect && code==EEXIST) ? XTC_OK : XTC_ERR_IO; } +xtc_result xtc_backend_start_routed(xtc_backend *b,const char *input,const char *output) +{ + xtc_result r;if(!b)return XTC_ERR_INVALID_ARGUMENT; + if(!b->ready || b->running)return XTC_ERR_INVALID_STATE; + __atomic_store_n(&b->route_wait,1U,__ATOMIC_RELEASE); + r=xtc_backend_start(b); + if(r==XTC_OK && input && *input)r=xtc_backend_route_midi(b,XTC_PORT_INPUT,input,0); + if(r==XTC_OK && output && *output)r=xtc_backend_route_midi(b,XTC_PORT_OUTPUT,output,0); + if(r!=XTC_OK && b->running){xtc_result stopped=xtc_backend_stop(b);if(stopped!=XTC_OK)r=stopped;} + __atomic_store_n(&b->route_wait,0U,__ATOMIC_RELEASE);return r; +} xtc_result xtc_backend_read_meters(xtc_backend *backend, xtc_meter_snapshot *out) { size_t i; @@ -1234,4 +1252,5 @@ xtc_result xtc_backend_set_midi_options(xtc_backend *b,int i,int o,int t){(void) xtc_result xtc_backend_midi_name(const xtc_backend *b,xtc_port_direction d,const char **o){(void)b;(void)d;(void)o;return XTC_ERR_UNSUPPORTED;} xtc_result xtc_backend_visit_midi_ports(xtc_backend *b,xtc_port_direction d,xtc_backend_port_visitor v,void *c){(void)b;(void)d;(void)v;(void)c;return XTC_ERR_UNSUPPORTED;} xtc_result xtc_backend_route_midi(xtc_backend *b,xtc_port_direction d,const char *r,int x){(void)b;(void)d;(void)r;(void)x;return XTC_ERR_UNSUPPORTED;} +xtc_result xtc_backend_start_routed(xtc_backend *b,const char *i,const char *o){(void)b;(void)i;(void)o;return XTC_ERR_UNSUPPORTED;} #endif diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 48d8e4a..1a3fdcb 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -2574,12 +2574,8 @@ std::vector MainWindow::midi_devices(bool input) xtc_result MainWindow::start_configured_backend() { xtc_result r=xtc_backend_set_midi_options(backend_,static_cast(settings_.midi_input_channel),static_cast(settings_.midi_output_channel),settings_.midi_thru!=0); - if(r==XTC_OK)r=xtc_backend_start(backend_); - if(r!=XTC_OK)return r; - if(settings_.midi_input_port[0])r=xtc_backend_route_midi(backend_,XTC_PORT_INPUT,settings_.midi_input_port,0); - if(settings_.midi_output_port[0]){auto out=xtc_backend_route_midi(backend_,XTC_PORT_OUTPUT,settings_.midi_output_port,0);if(r==XTC_OK)r=out;} - if(r!=XTC_OK){(void)xtc_backend_stop(backend_);return r;} - return XTC_OK; + if(r==XTC_OK)r=xtc_backend_start_routed(backend_,settings_.midi_input_port,settings_.midi_output_port); + return r; } void MainWindow::show_preferences() { diff --git a/tests/integration/test_configuration_startup.py b/tests/integration/test_configuration_startup.py new file mode 100644 index 0000000..1ae1044 --- /dev/null +++ b/tests/integration/test_configuration_startup.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Exercise the real GUI's default ~/.xtc.conf lifecycle in an isolated child HOME.""" +import configparser +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +binary = str(Path(sys.argv[1]).resolve()) +with tempfile.TemporaryDirectory(prefix="xtc-config-startup-") as directory: + configuration = Path(directory) / ".xtc.conf" + environment = dict(os.environ) + environment["HOME"] = directory # Child only; never reads/writes the user's config. + + def run_window(expected_width): + process = subprocess.Popen([binary], env=environment, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + try: + window = None + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if process.poll() is not None: + raise AssertionError(f"GUI exited early: {process.communicate()}") + found = subprocess.run(["xdotool", "search", "--onlyvisible", "--pid", + str(process.pid), "--name", "xtc"], + capture_output=True, text=True) + if found.returncode == 0: + window = found.stdout.splitlines()[0] + geometry = subprocess.check_output( + ["xdotool", "getwindowgeometry", "--shell", window], text=True) + if f"WIDTH={expected_width}\n" in geometry: + break + time.sleep(.05) + else: + raise AssertionError(f"Expected startup width {expected_width}; window={window}") + subprocess.run(["xdotool", "windowfocus", window], check=True) + subprocess.run(["xdotool", "key", "--window", window, "ctrl+q"], check=True) + output, error = process.communicate(timeout=15) + assert process.returncode == 0, (output, error) + finally: + if process.poll() is None: + process.kill() + process.communicate() + + run_window(1280) + assert configuration.exists() and configuration.stat().st_mode & 0o777 == 0o600 + saved = configparser.ConfigParser() + saved.read(configuration) + assert saved["midi"]["midi_input_channel"] == "-1" + assert saved["editing"]["note_velocity"] == "100" + configuration.write_text("[interface]\nwidth=1360\nheight=860\n" + "[midi]\nmidi_input_channel=5\nmidi_output_channel=7\n" + "[project_defaults]\ntempo=137000\n") + run_window(1360) + saved.read(configuration) + assert saved["midi"]["midi_input_channel"] == "5" + assert saved["midi"]["midi_output_channel"] == "7" + assert saved["project_defaults"]["tempo"] == "137000" + invalid = "[unknown]\nkeep_this_file=1\n" + configuration.write_text(invalid) + run_window(1280) + assert configuration.read_text() == invalid +print("Real startup ~/.xtc.conf creation, defaults, reload and malformed-file preservation passed.") diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 78260e8..71f64bb 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -166,6 +166,8 @@ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph) ++engine_status.submitted_revision; return XTC_OK; } +xtc_result xtc_backend_start_routed(xtc_backend *b,const char *input,const char *output) +{auto r=xtc_backend_start(b);if(r==XTC_OK && input && *input)r=xtc_backend_route_midi(b,XTC_PORT_INPUT,input,0);if(r==XTC_OK && output && *output)r=xtc_backend_route_midi(b,XTC_PORT_OUTPUT,output,0);if(r!=XTC_OK && handle.running)(void)xtc_backend_stop(b);return r;} xtc_result xtc_backend_set_midi_options(xtc_backend *,int input,int output,int thru) {CHECK(input>=-1 && input<16 && output>=-1 && output<16 && thru>=0 && thru<2);return handle.running ? XTC_ERR_INVALID_STATE : XTC_OK;} xtc_result xtc_backend_visit_midi_ports(xtc_backend *,xtc_port_direction d,xtc_backend_port_visitor visit,void *c) diff --git a/tests/integration/test_jack_midi.c b/tests/integration/test_jack_midi.c index b48f1e9..5625043 100644 --- a/tests/integration/test_jack_midi.c +++ b/tests/integration/test_jack_midi.c @@ -59,7 +59,7 @@ int main(void) jack_status_t flags = 0; probe state = {0}; const char *destination; - uint64_t track, clip, note; + uint64_t track, clip, note;unsigned int before_scheduled; int result = EXIT_FAILURE; REQUIRE(mkdtemp(directory)); snprintf(song, sizeof(song), "%s/song.xtc", directory); @@ -77,7 +77,7 @@ int main(void) REQUIRE(state.input && state.right && state.midi && state.receive); REQUIRE(xtc_backend_set_midi_options(backend,-1,-1,1)==XTC_OK); REQUIRE(jack_set_process_callback(client, process, &state) == 0 && jack_activate(client) == 0); - REQUIRE(xtc_backend_start(backend) == XTC_OK); + REQUIRE(xtc_backend_start_routed(backend,jack_port_name(state.midi),jack_port_name(state.receive)) == XTC_OK); REQUIRE(xtc_backend_midi_port_name(backend, &destination) == XTC_OK); wait_blocks(); /* PipeWire JACK publishes newly activated ports asynchronously. */ { @@ -109,14 +109,18 @@ int main(void) REQUIRE(xtc_project_set_mix(p, track, 0.5f, 1, 0, 0) == XTC_OK); REQUIRE(xtc_project_save(p, song) == XTC_OK && xtc_project_load(q, song) == XTC_OK); REQUIRE(xtc_backend_prepare_project(backend, q) == XTC_OK); - REQUIRE(xtc_backend_seek(backend, 0) == XTC_OK && xtc_backend_start(backend) == XTC_OK); + before_scheduled=__atomic_load_n(&state.midi_on,__ATOMIC_RELAXED); + REQUIRE(xtc_backend_seek(backend, 0) == XTC_OK && xtc_backend_start_routed(backend,NULL,jack_port_name(state.receive)) == XTC_OK); + wait_blocks(); /* Publish restarted PipeWire ports before connecting. */ REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(state.input)) == XTC_OK); { const char *own; REQUIRE(xtc_backend_output_name(backend, 1, &own) == XTC_OK); int connected = jack_connect(client, own, jack_port_name(state.right)); REQUIRE(connected == 0 || connected == EEXIST); } wait_blocks(); REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED) && __atomic_load_n(&state.right_loud, __ATOMIC_RELAXED)); + REQUIRE(__atomic_load_n(&state.midi_on,__ATOMIC_RELAXED)>before_scheduled); { xtc_backend_status status; REQUIRE(xtc_backend_get_status(backend, &status) == XTC_OK && status.peak_left == 0 && status.peak_right > 0); } REQUIRE(xtc_backend_stop(backend) == XTC_OK); REQUIRE(xtc_backend_seek(backend, 7680000) == XTC_OK && xtc_backend_start(backend) == XTC_OK); + wait_blocks(); REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(state.input)) == XTC_OK); { const char *own; REQUIRE(xtc_backend_output_name(backend, 1, &own) == XTC_OK); int connected = jack_connect(client, own, jack_port_name(state.right)); REQUIRE(connected == 0 || connected == EEXIST); } diff --git a/tests/unit/test_backend.c b/tests/unit/test_backend.c index 2351f17..66f222f 100644 --- a/tests/unit/test_backend.c +++ b/tests/unit/test_backend.c @@ -25,7 +25,7 @@ static unsigned char sent[512][3];static unsigned int sent_time[512],sent_count; static unsigned int midi_count; static jack_midi_event_t midi_events[256]; static int open_fail, register_fail, callback_fail, activate_fail, deactivate_fail, close_fail; -static int active, closed, port_calls, null_buffer; +static int active, closed, port_calls, null_buffer, process_during_connect; static unsigned int server_frames = 32U; jack_client_t *jack_client_open(const char *name, jack_options_t options, jack_status_t *status, ...) { @@ -88,7 +88,7 @@ const char **jack_get_ports(jack_client_t *client, const char *pattern, const ch } void jack_free(void *pointer) { free(pointer); } int jack_connect(jack_client_t *client, const char *source, const char *dest) -{ (void)client; return strstr(source, "missing") || strstr(dest, "missing") ? -1 : EEXIST; } +{ (void)client; if(process_during_connect && process_cb)process_cb(32,context); return strstr(source, "missing") || strstr(dest, "missing") ? -1 : EEXIST; } int jack_disconnect(jack_client_t *client, const char *source, const char *dest) { (void)client; (void)source; (void)dest; return 0; } static int visit(const char *name, void *arg) @@ -413,7 +413,9 @@ int main(void) CHECK(xtc_project_add_midi_clip(p,t,0,3840,&c)==XTC_OK && xtc_project_add_note(p,c,0,960,60,90,1,&n)==XTC_OK); CHECK(xtc_backend_open(&config,&backend)==XTC_OK && xtc_backend_prepare_project(backend,p)==XTC_OK); CHECK(xtc_backend_midi_name(backend,XTC_PORT_OUTPUT,&midi_name)==XTC_OK && !strcmp(midi_name,"test:midi_out")); - CHECK(xtc_backend_set_midi_options(backend,2,4,1)==XTC_OK && xtc_backend_start(backend)==XTC_OK); + CHECK(xtc_backend_set_midi_options(backend,2,4,1)==XTC_OK); + process_during_connect=1;CHECK(xtc_backend_start_routed(backend,"controller:out","device:in")==XTC_OK);process_during_connect=0; + CHECK(xtc_backend_get_status(backend,&status)==XTC_OK && status.sample_position==0 && !sent_count); CHECK(xtc_backend_route_midi(backend,XTC_PORT_OUTPUT,"device:in",0)==XTC_OK); CHECK(xtc_backend_set_midi_options(backend,-1,-1,0)==XTC_ERR_INVALID_STATE); midi_count=1;midi_events[0]=(jack_midi_event_t){8,3,on};CHECK(process_cb(32,context)==0); diff --git a/tools/capture_screenshots.cpp b/tools/capture_screenshots.cpp index 4aa0921..fa00f8a 100644 --- a/tools/capture_screenshots.cpp +++ b/tools/capture_screenshots.cpp @@ -63,6 +63,17 @@ int main(int argc,char **argv) { MainWindow window(false,"");main_window=&window;window.resize(0,0,1400,900);window.show();check(window.open_project(project.c_str()));pump(); auto *s=first(window);if(!s)throw std::runtime_error("Sequencer missing"); auto reset=[&](){window.reset_layout();check(window.open_project(project.c_str()));window.resize(0,20,1400,900);tab("Sequencer");s->select(0,0);s->position(0);check(window.set_loop(3840,7680,true));window.fit_song();pump();}; + if(std::getenv("XTC_CAPTURE_MIDI_ONLY")) { + reset();dialog("preferences-midi",[&]{window.show_preferences();},"midi"); + dialog("preferences-midi-help",[&]{window.show_preferences();},"MIDI help"); + auto *bar=first(window);auto *file=bar->find_item("File");if(!file)throw std::runtime_error("File menu missing"); + Fl::add_timeout(.3,[](void*){capture("midi-file-menu");if(system("xdotool key Escape"))std::abort();}); + (file+1)->pulldown(0,bar->y()+bar->h(),0,0,nullptr,bar); + std::string midi=root+"/exported-keys.mid";check(window.export_midi_clip(midi.c_str())); + check(window.new_project());check(window.import_midi_file(midi.c_str()));window.fit_song();capture("midi-import-arrangement"); + piano_view();capture("midi-import-piano-roll"); + window.do_callback();std::puts("MIDI workflow captures completed.");return 0; + } if(std::getenv("XTC_CAPTURE_CREATIVE_ONLY")) { reset();check(window.open_track_inserts(0));auto *inserts=window.track_inserts(); check(inserts->apply_template(1));tab("Track inserts");capture("creative-dub-rack"); @@ -118,7 +129,7 @@ int main(int argc,char **argv) { dialog("audio-clip-properties",[&]{s->audio_properties_dialog();});dialog("audio-normalization",[&]{s->normalize_dialog();});check(s->normalize_audio(.25f));capture("audio-normalization-result"); dialog("project-collection",[&]{menu("File/Collect project media...");});check(window.collect_project((root+"/portable").c_str())); dialog("loop-range",[&]{button("Loop range...");});dialog("midi-controller-mapping",[&]{menu("Audio/MIDI controller mapping");});dialog("about",[&]{menu("Help/About");}); - for(const char *section:{"audio","plugins","interface","editing","project_defaults","transport"}){std::string file="preferences-"+std::string(section);for(char &c:file)if(c==' ')c='_';dialog(file.c_str(),[&]{window.show_preferences();},section);} + for(const char *section:{"audio","plugins","midi","interface","editing","project_defaults","transport"}){std::string file="preferences-"+std::string(section);for(char &c:file)if(c==' ')c='_';dialog(file.c_str(),[&]{window.show_preferences();},section);} xtc_settings settings=window.settings();std::string fixtures=std::filesystem::canonical(argv[0]).parent_path().string();snprintf(settings.ladspa_paths,sizeof(settings.ladspa_paths),"%s",fixtures.c_str());settings.clap_paths[0]=settings.vst2_paths[0]=settings.vst3_paths[0]=0; dialog("plugin-library",[&]{std::string path;xtc_choose_plugin_file(settings,path);}); for(auto entry:{std::pair{"plugin-descriptors","plugin-fixture.so"},{"clap-instrument-descriptors","clap-synth-fixture.clap"},{"vst2-instrument-descriptors","vst2-synth-fixture.so"},{"vst3-instrument-descriptors","vst3-synth-fixture.so"}}){unsigned int index=0;dialog(entry.first,[&]{xtc_choose_plugin((fixtures+"/"+entry.second).c_str(),48000,index,strcmp(entry.first,"plugin-descriptors")!=0);});}