JUCE SDK integration.
This commit is contained in:
parent
8e09f58944
commit
adcbcdfd5e
16 changed files with 1057 additions and 0 deletions
19
Makefile
19
Makefile
|
|
@ -646,3 +646,22 @@ test-steinberg-sdk: steinberg-sdk $(BUILD_DIR)/test_steinberg_sdk $(BUILD_DIR)/s
|
||||||
./$(BUILD_DIR)/test_steinberg_sdk "$(abspath $(STEINBERG_BUILD_DIR))/VST3/Release/xtc-sdk-examples.vst3"
|
./$(BUILD_DIR)/test_steinberg_sdk "$(abspath $(STEINBERG_BUILD_DIR))/VST3/Release/xtc-sdk-examples.vst3"
|
||||||
|
|
||||||
-include $(BUILD_DIR)/tests/unit/test_plugin_module_path.d $(BUILD_DIR)/tests/integration/test_steinberg_sdk.d $(BUILD_DIR)/examples/steinberg/render.d
|
-include $(BUILD_DIR)/tests/unit/test_plugin_module_path.d $(BUILD_DIR)/tests/integration/test_steinberg_sdk.d $(BUILD_DIR)/examples/steinberg/render.d
|
||||||
|
|
||||||
|
# Optional JUCE bridge/examples; externally supplied SDK, no downloads or installs.
|
||||||
|
JUCE_ROOT ?=
|
||||||
|
JUCE_BUILD_DIR ?= $(BUILD_DIR)/juce
|
||||||
|
.PHONY: juce-examples test-juce
|
||||||
|
juce-examples:
|
||||||
|
@test -n "$(JUCE_ROOT)" || { echo "Set JUCE_ROOT to an external JUCE 9 checkout (AGPLv3/commercial)."; exit 1; }
|
||||||
|
cmake -S examples/juce -B "$(JUCE_BUILD_DIR)" -DXTC_JUCE_ROOT="$(abspath $(JUCE_ROOT))" -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build "$(JUCE_BUILD_DIR)" --target xtc-juce-render XtcJuceSynth_VST3 XtcJuceSynth_Standalone --parallel 3
|
||||||
|
|
||||||
|
$(BUILD_DIR)/test_juce_plugin: $(BUILD_DIR)/tests/integration/test_juce_plugin.o $(BUILD_DIR)/libxtc.a
|
||||||
|
$(CC) $(LDFLAGS) -o $@ $^ -lm -ldl
|
||||||
|
|
||||||
|
test-juce: juce-examples $(BUILD_DIR)/test_juce_plugin
|
||||||
|
xvfb-run -a $(PYTHON) tests/integration/test_juce_examples.py "$(JUCE_BUILD_DIR)/xtc-juce-render_artefacts/Release/xtc-juce-render"
|
||||||
|
xvfb-run -a ctest --test-dir "$(JUCE_BUILD_DIR)" --output-on-failure
|
||||||
|
xvfb-run -a ./$(BUILD_DIR)/test_juce_plugin "$(abspath $(JUCE_BUILD_DIR))/XtcJuceSynth_artefacts/Release/VST3/xtc JUCE Synth.vst3"
|
||||||
|
|
||||||
|
-include $(BUILD_DIR)/tests/integration/test_juce_plugin.d
|
||||||
|
|
|
||||||
|
|
@ -201,3 +201,9 @@ VST3 bundle loading, transport context, official-SDK gain/synth examples and
|
||||||
validator checks. Run `make VST3_SDK_ROOT=/path/to/vst3sdk test-steinberg-sdk`
|
validator checks. Run `make VST3_SDK_ROOT=/path/to/vst3sdk test-steinberg-sdk`
|
||||||
for optional SDK qualification. A Windows ASIO device diagnostic is provided;
|
for optional SDK qualification. A Windows ASIO device diagnostic is provided;
|
||||||
a native ASIO playback/capture backend remains planned.
|
a native ASIO playback/capture backend remains planned.
|
||||||
|
|
||||||
|
[JUCE integration (Phase 107)](docs/JUCE.md) provides an optional VST3/standalone
|
||||||
|
Soft Keys instrument with a native editor, DSP effects, presets, MIDI-to-WAV
|
||||||
|
rendering and VST3 hosting examples. Build and qualify it with
|
||||||
|
`make JUCE_ROOT=/path/to/JUCE test-juce`; review JUCE’s AGPLv3/commercial licence
|
||||||
|
before distributing linked binaries. See [the examples](examples/juce/README.md).
|
||||||
|
|
|
||||||
85
docs/JUCE.md
Normal file
85
docs/JUCE.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# JUCE integration (Phase 107)
|
||||||
|
|
||||||
|
JUCE is used by optional, runnable examples alongside xtc's C99 engine and FLTK
|
||||||
|
application. It is an external SDK dependency, not vendored, fetched, linked by
|
||||||
|
normal builds or automatically installed. The integration uses:
|
||||||
|
|
||||||
|
| JUCE capability | Implemented use |
|
||||||
|
| --- | --- |
|
||||||
|
| AudioProcessor and plugin clients | mono/stereo VST3 instrument and standalone application |
|
||||||
|
| Audio/MIDI devices | standalone wrapper's device selection and MIDI routing UI |
|
||||||
|
| AudioProcessorValueTreeState | stable host parameters and validated versioned presets |
|
||||||
|
| GUI, graphics, events | custom native editor, parameter attachments, keyboard, meters and asynchronous preset chooser |
|
||||||
|
| DSP | low-pass filter, stereo reverb and smoothed output gain |
|
||||||
|
| MIDI | host event offsets, GUI event FIFO, Standard MIDI File parsing and tempo conversion |
|
||||||
|
| Audio formats | stereo float32 WAV export |
|
||||||
|
| Plugin hosting | explicit VST3 inspection, parameter discovery and state/processing qualification |
|
||||||
|
|
||||||
|
Synthesis is the real xtc **Soft Keys** implementation, called through
|
||||||
|
`xtc_plugin_*`. C++ remains confined to the optional integration. The SDK build
|
||||||
|
uses a separate PIC build of the C archive with JACK/FLTK disabled; the installed
|
||||||
|
application and existing build directories are not modified.
|
||||||
|
|
||||||
|
## Licensing and dependency impact
|
||||||
|
|
||||||
|
The inspected JUCE checkout is 9.0.4, revision
|
||||||
|
`3fb5cbc0340758596dac39f7c8de47d562b4896e`. Its modules are dual-licensed under
|
||||||
|
**AGPLv3 and the commercial JUCE licence**. A commercial JUCE licence may be
|
||||||
|
required for distribution; choose and comply with the applicable SDK licence.
|
||||||
|
xtc's MIT licence for original integration code does not relicense JUCE or a
|
||||||
|
combined binary. Read the actual SDK `LICENSE.md`, its third-party inventory
|
||||||
|
`JUCE.spdx.json`, and [JUCE licensing](https://juce.com/legal/juce-9-licence/)
|
||||||
|
before distributing those binaries. No SDK source or JUCE-linked binary is added
|
||||||
|
to the normal xtc install/package targets.
|
||||||
|
|
||||||
|
JUCE requires C++17 and its Linux development dependencies, including ALSA,
|
||||||
|
Fontconfig/FreeType and X11 libraries. CMake >= 3.25 and GNU Make build these
|
||||||
|
examples. Xvfb runs automated checks without a desktop. The SDK's `juceaide`
|
||||||
|
tool is compiled at configure time. Builds are substantially heavier than the
|
||||||
|
C-only core. Browser/network features are disabled; normal builds never fetch
|
||||||
|
SDKs or install system packages. Only the inspected SDK revision has been
|
||||||
|
qualified; use the documented revision for reproducibility.
|
||||||
|
|
||||||
|
## Processing and ownership
|
||||||
|
|
||||||
|
The processor creates/destroys its C synth and prepares JUCE DSP in quiescent
|
||||||
|
`prepareToPlay`/`releaseResources`. Host calls must obey AudioProcessor's lifecycle
|
||||||
|
contract. The callback splits arbitrary host buffers into 256-frame C blocks.
|
||||||
|
MIDI note, controller, pitch-bend and all-notes-off messages keep their sample
|
||||||
|
offsets; other messages, including SysEx/program change, are ignored. Events
|
||||||
|
outside the block are not rendered. More than 256 supported events in a chunk
|
||||||
|
silences the whole host block and clears voices. GUI notes enter a bounded
|
||||||
|
single-producer/single-consumer FIFO at offset zero in the next callback;
|
||||||
|
GUI queue overflow requests panic and discards queued notes. No keyboard-state
|
||||||
|
locks or GUI calls occur in the callback.
|
||||||
|
|
||||||
|
Five APVTS parameters control output gain, low-pass cutoff, room size, wet mix
|
||||||
|
and effect enable. Values are read atomically per host block; output gain ramps
|
||||||
|
for 10 ms. This is block-level parameter adoption, not sample-accurate automation.
|
||||||
|
Effects default off, making the wrapper output identical to the C synth at
|
||||||
|
unity gain. Mono/stereo work at 8–192 kHz; float64 is not exposed. The synth has
|
||||||
|
32 voices, attack/release, sustain and pitch bend from the C implementation.
|
||||||
|
The keyboard shows local GUI notes; it does not mirror external MIDI notes.
|
||||||
|
|
||||||
|
Presets contain the five parameter values and schema version 1, not voices,
|
||||||
|
plugin binaries, device choices, project arrangements or reverb history. Restore
|
||||||
|
validates the complete state before adoption (64 KiB maximum). Saving uses a
|
||||||
|
same-directory temporary file; the chooser asks about replacing an existing
|
||||||
|
preset. Host state APIs use the same schema. The wrapper's `.xtcjuce` presets
|
||||||
|
are separate from xtc project/chain presets.
|
||||||
|
|
||||||
|
## Limits and qualification
|
||||||
|
|
||||||
|
See [build and example commands](../examples/juce/README.md). These examples do
|
||||||
|
not migrate the main DAW to JUCE, add a new public backend, extend the C VST3
|
||||||
|
host's editor/controller support, or finish Phase 106's native ASIO work.
|
||||||
|
The JUCE VST3 can be loaded by xtc's existing instrument workflow in default
|
||||||
|
configuration. The C host translates all-off into individual note-offs, allowing
|
||||||
|
the synth release tail rather than an immediate hard stop. Its custom editor/parameters require a capable JUCE/VST3 host or
|
||||||
|
the standalone example. Explicit plugin inspection executes trusted plugin code;
|
||||||
|
it is not a scanner sandbox and must not run inside an audio callback.
|
||||||
|
|
||||||
|
Linux build/offline/editor/plugin results are recorded in Phase 107. Physical
|
||||||
|
audio/MIDI devices, Windows/macOS, ASIO and AU/AAX/LV2/VST2 exports require their
|
||||||
|
own platform, SDK, license and hardware qualification. Unrelated JUCE modules
|
||||||
|
such as video, network services and mobile UI are outside this audio phase.
|
||||||
|
|
@ -3440,6 +3440,64 @@ configuration correctly rejects the unsupported platform; no Windows build or
|
||||||
ASIO hardware result is claimed.
|
ASIO hardware result is claimed.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 107 — JUCE Audio, Plugin, DSP and Interface Examples
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Use JUCE's relevant audio framework capabilities with the actual xtc C99 engine:
|
||||||
|
AudioProcessor/plugin wrappers, standalone audio/MIDI devices, a native editor,
|
||||||
|
parameter/state management, DSP effects, MIDI files, WAV export and VST3 hosting.
|
||||||
|
Keep this integration optional and externally supplied. This user-requested
|
||||||
|
phase can proceed alongside the unfinished platform work in Phase 106; it does
|
||||||
|
not replace the main FLTK application or move the C engine into C++.
|
||||||
|
|
||||||
|
### Tasks and acceptance criteria
|
||||||
|
|
||||||
|
- [x] Add an offline-by-default external JUCE build and explain its
|
||||||
|
AGPLv3/commercial licensing, dependencies and packaging boundary.
|
||||||
|
- [x] Build working VST3 and standalone synth targets using xtc Soft Keys through
|
||||||
|
the public C API; negotiate mono/stereo and handle variable host blocks.
|
||||||
|
- [x] Provide a native JUCE editor with attached host parameters, on-screen MIDI
|
||||||
|
keyboard, peak display, panic and transactional versioned preset files.
|
||||||
|
- [x] Use JUCE DSP for a low-pass filter, reverb and smoothed output gain, with
|
||||||
|
all processing storage prepared outside processBlock.
|
||||||
|
- [x] Implement bounded MIDI queues/overflow panic and preserve host MIDI sample
|
||||||
|
offsets without callback allocation, I/O, GUI calls or host-side locks.
|
||||||
|
- [x] Provide a console example for Standard MIDI File import with tempo
|
||||||
|
conversion and float32 stereo WAV rendering with atomic no-clobber output.
|
||||||
|
- [x] Demonstrate explicit JUCE VST3 discovery, parameter inspection, state
|
||||||
|
capture/restore and actual MIDI processing against the built example.
|
||||||
|
- [x] Test C-render equivalence, stereo/mono, variable blocks, reset, invalid
|
||||||
|
preparation, queue overflow, preset recovery, editor rendering/reopening
|
||||||
|
and loading the JUCE VST3 from xtc's existing C host.
|
||||||
|
- [x] Build with warnings enabled, run the focused integration checks and retain
|
||||||
|
ordinary C/FLTK builds/tests without a JUCE dependency.
|
||||||
|
|
||||||
|
### Implementation status
|
||||||
|
|
||||||
|
Implemented and qualified on Linux with external JUCE 9.0.4 revision
|
||||||
|
`3fb5cbc0340758596dac39f7c8de47d562b4896e`. See [JUCE.md](JUCE.md) and
|
||||||
|
[examples/juce](../examples/juce/README.md) for reproduction.
|
||||||
|
|
||||||
|
Validation (2026-11-06):
|
||||||
|
|
||||||
|
- `make -j4 all test`: ordinary C/FLTK build and regression suite passed.
|
||||||
|
- `make JUCE_ROOT=/path/to/JUCE test-juce`: warning-enabled VST3, standalone
|
||||||
|
and CLI builds; processor/JUCE-host CTests; file/editor acceptance checks;
|
||||||
|
and the existing C-host integration test passed under Xvfb.
|
||||||
|
- Steinberg VST 3.8.1 validator: 47 tests passed, zero failed.
|
||||||
|
- Rendered the demo to a 216000-frame 48 kHz stereo float32 WAV; inspected
|
||||||
|
the actual 1560 × 980 editor snapshot. No physical audio device was opened.
|
||||||
|
- C-host all-off uses individual note-offs, so acceptance checks finite release
|
||||||
|
and eventual silence; the JUCE host also verifies immediate MIDI all-off timing.
|
||||||
|
|
||||||
|
Broader adoption in
|
||||||
|
the main DAW, additional plugin formats, Windows/macOS portability, native ASIO
|
||||||
|
and physical-device latency/loopback qualification remain separate work. Merely
|
||||||
|
building JUCE's standalone wrapper does not establish hardware acceptance.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recommended Execution Order
|
## Recommended Execution Order
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,8 @@ under `share/xtc/examples`. See projects/README.md for opening and playback.
|
||||||
and MIDI synth, qualify them with Steinberg's validator and the xtc C host, and
|
and MIDI synth, qualify them with Steinberg's validator and the xtc C host, and
|
||||||
render a WAV offline. An optional Windows ASIO device diagnostic is also provided;
|
render a WAV offline. An optional Windows ASIO device diagnostic is also provided;
|
||||||
it is not yet a qualified audio backend.
|
it is not yet a qualified audio backend.
|
||||||
|
|
||||||
|
[JUCE examples](juce/README.md) provide a VST3/standalone Soft Keys instrument
|
||||||
|
using the C engine, native controls and keyboard, presets, JUCE DSP effects,
|
||||||
|
MIDI-to-WAV rendering and explicit VST3 hosting checks. The external JUCE SDK is
|
||||||
|
AGPLv3/commercial and is not required by normal builds.
|
||||||
|
|
|
||||||
64
examples/juce/CMakeLists.txt
Normal file
64
examples/juce/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
cmake_minimum_required(VERSION 3.25)
|
||||||
|
project(xtc_juce_examples VERSION 0.1.0 LANGUAGES C CXX)
|
||||||
|
set(XTC_JUCE_ROOT "" CACHE PATH "External JUCE source checkout")
|
||||||
|
if(NOT EXISTS "${XTC_JUCE_ROOT}/modules/juce_audio_utils/juce_audio_utils.h")
|
||||||
|
message(FATAL_ERROR "Set XTC_JUCE_ROOT to an external JUCE checkout; normal xtc builds do not download JUCE")
|
||||||
|
endif()
|
||||||
|
if(NOT UNIX OR APPLE)
|
||||||
|
message(FATAL_ERROR "The xtc C core bridge is currently qualified on Linux/POSIX only")
|
||||||
|
endif()
|
||||||
|
message(STATUS "JUCE is AGPLv3/commercial: review the external SDK LICENSE.md before distributing these binaries")
|
||||||
|
set(JUCE_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||||
|
set(JUCE_BUILD_EXTRAS OFF CACHE BOOL "" FORCE)
|
||||||
|
add_subdirectory("${XTC_JUCE_ROOT}" juce-sdk EXCLUDE_FROM_ALL)
|
||||||
|
find_program(XTC_MAKE_EXECUTABLE NAMES gmake make REQUIRED)
|
||||||
|
get_filename_component(XTC_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
|
||||||
|
set(XTC_CORE_BUILD "${CMAKE_CURRENT_BINARY_DIR}/c-core")
|
||||||
|
add_custom_target(xtc-juce-core
|
||||||
|
COMMAND "${XTC_MAKE_EXECUTABLE}" -C "${XTC_ROOT}"
|
||||||
|
"BUILD_DIR=${XTC_CORE_BUILD}" "CC=${CMAKE_C_COMPILER}"
|
||||||
|
"CFLAGS=-O2 -g -fPIC -std=c99 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -Iinclude"
|
||||||
|
PKG_CONFIG=false FLTK_CONFIG=false "${XTC_CORE_BUILD}/libxtc.a"
|
||||||
|
BYPRODUCTS "${XTC_CORE_BUILD}/libxtc.a"
|
||||||
|
VERBATIM USES_TERMINAL)
|
||||||
|
add_library(xtc-juce-c-core STATIC IMPORTED)
|
||||||
|
set_target_properties(xtc-juce-c-core PROPERTIES
|
||||||
|
IMPORTED_LOCATION "${XTC_CORE_BUILD}/libxtc.a"
|
||||||
|
INTERFACE_INCLUDE_DIRECTORIES "${XTC_ROOT}/include")
|
||||||
|
add_dependencies(xtc-juce-c-core xtc-juce-core)
|
||||||
|
|
||||||
|
juce_add_plugin(XtcJuceSynth
|
||||||
|
COMPANY_NAME xtc
|
||||||
|
BUNDLE_ID org.xtc.juce.synth
|
||||||
|
PLUGIN_MANUFACTURER_CODE Xtca
|
||||||
|
PLUGIN_CODE Xjcs
|
||||||
|
PRODUCT_NAME "xtc JUCE Synth"
|
||||||
|
IS_SYNTH TRUE
|
||||||
|
NEEDS_MIDI_INPUT TRUE
|
||||||
|
NEEDS_MIDI_OUTPUT FALSE
|
||||||
|
EDITOR_WANTS_KEYBOARD_FOCUS FALSE
|
||||||
|
COPY_PLUGIN_AFTER_BUILD FALSE
|
||||||
|
FORMATS VST3 Standalone
|
||||||
|
NEEDS_WEB_BROWSER FALSE
|
||||||
|
NEEDS_CURL FALSE)
|
||||||
|
target_sources(XtcJuceSynth PRIVATE Processor.cpp Editor.cpp PluginEntry.cpp)
|
||||||
|
target_compile_features(XtcJuceSynth PUBLIC cxx_std_17)
|
||||||
|
target_compile_definitions(XtcJuceSynth PUBLIC
|
||||||
|
JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0 JUCE_VST3_CAN_REPLACE_VST2=0 JUCE_PLUGINHOST_VST3=1
|
||||||
|
)
|
||||||
|
target_include_directories(XtcJuceSynth PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}" "${XTC_ROOT}/include")
|
||||||
|
target_link_libraries(XtcJuceSynth PRIVATE xtc-juce-c-core juce::juce_audio_utils juce::juce_dsp
|
||||||
|
PUBLIC juce::juce_recommended_config_flags juce::juce_recommended_warning_flags)
|
||||||
|
|
||||||
|
add_executable(xtc-juce-render Render.cpp)
|
||||||
|
set_target_properties(xtc-juce-render PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/xtc-juce-render_artefacts/$<CONFIG>")
|
||||||
|
target_compile_features(xtc-juce-render PRIVATE cxx_std_17)
|
||||||
|
target_include_directories(xtc-juce-render PRIVATE "${XTC_JUCE_ROOT}/modules")
|
||||||
|
# Exact float comparisons intentionally check bit-identical C bridge output.
|
||||||
|
set_source_files_properties(Render.cpp PROPERTIES COMPILE_OPTIONS "-Wno-float-equal")
|
||||||
|
target_compile_definitions(xtc-juce-render PRIVATE JUCE_GLOBAL_MODULE_SETTINGS_INCLUDED=1)
|
||||||
|
target_link_libraries(xtc-juce-render PRIVATE XtcJuceSynth xtc-juce-c-core)
|
||||||
|
enable_testing()
|
||||||
|
add_test(NAME xtc-juce-processor COMMAND xtc-juce-render --self-test)
|
||||||
|
|
||||||
|
add_test(NAME xtc-juce-vst3 COMMAND xtc-juce-render --host-check "$<TARGET_FILE_DIR:XtcJuceSynth_VST3>/../..")
|
||||||
97
examples/juce/Editor.cpp
Normal file
97
examples/juce/Editor.cpp
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
#include "Editor.h"
|
||||||
|
XtcJuceEditor::XtcJuceEditor(XtcJuceProcessor& owner)
|
||||||
|
: AudioProcessorEditor(owner),engine(owner)
|
||||||
|
{
|
||||||
|
const std::array<const char*,4> ids{{"gain","cutoff","room","wet"}};
|
||||||
|
const std::array<const char*,4> names{{"OUTPUT / dB","TONE / Hz","ROOM SIZE","REVERB MIX"}};
|
||||||
|
for (size_t i=0;i<sliders.size();++i) {
|
||||||
|
auto& slider=sliders[i];
|
||||||
|
slider.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag);
|
||||||
|
slider.setTextBoxStyle(juce::Slider::TextBoxBelow,false,95,24);
|
||||||
|
slider.setColour(juce::Slider::rotarySliderFillColourId,juce::Colour(0xff74dbc1));
|
||||||
|
slider.setColour(juce::Slider::textBoxOutlineColourId,juce::Colours::transparentBlack);
|
||||||
|
addAndMakeVisible(slider);
|
||||||
|
labels[i].setText(names[i],juce::dontSendNotification);
|
||||||
|
labels[i].setJustificationType(juce::Justification::centred); addAndMakeVisible(labels[i]);
|
||||||
|
attachments[i]=std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(engine.parameters,ids[i],slider);
|
||||||
|
slider.textFromValueFunction=[i](double value) { return juce::String(value,i==1 ? 0 : 2); };
|
||||||
|
slider.updateText();
|
||||||
|
}
|
||||||
|
for (auto* button : {&save,&load,&panicButton}) addAndMakeVisible(*button);
|
||||||
|
addAndMakeVisible(effects); addAndMakeVisible(status); addAndMakeVisible(keyboard);
|
||||||
|
effectsAttachment=std::make_unique<juce::AudioProcessorValueTreeState::ButtonAttachment>(engine.parameters,"effects",effects);
|
||||||
|
keyboardState.addListener(this);
|
||||||
|
keyboard.setAvailableRange(36,96); keyboard.setLowestVisibleKey(48); keyboard.setKeyWidth(24);
|
||||||
|
keyboard.setColour(juce::MidiKeyboardComponent::keyDownOverlayColourId,juce::Colour(0xff74dbc1));
|
||||||
|
save.onClick=[this]{choosePreset(true);}; load.onClick=[this]{choosePreset(false);};
|
||||||
|
panicButton.onClick=[this]{keyboardState.allNotesOff(0);engine.panic();};
|
||||||
|
setSize(780,490); startTimerHz(30);
|
||||||
|
}
|
||||||
|
XtcJuceEditor::~XtcJuceEditor()
|
||||||
|
{
|
||||||
|
stopTimer(); keyboardState.removeListener(this); engine.panic();
|
||||||
|
}
|
||||||
|
void XtcJuceEditor::paint(juce::Graphics& graphics)
|
||||||
|
{
|
||||||
|
graphics.fillAll(juce::Colour(0xff101c23));
|
||||||
|
graphics.setColour(juce::Colour(0xff74dbc1));
|
||||||
|
graphics.setFont(juce::FontOptions(40.0f,juce::Font::bold)); graphics.drawText("xtc",24,15,100,55,juce::Justification::left);
|
||||||
|
graphics.setColour(juce::Colour(0xffedf5f5));
|
||||||
|
graphics.setFont(juce::FontOptions(23.0f)); graphics.drawText("Soft Keys",130,20,300,40,juce::Justification::left);
|
||||||
|
graphics.setColour(juce::Colour(0xffa4b7c1)); graphics.setFont(juce::FontOptions(14.0f));
|
||||||
|
graphics.drawText("Play. Shape. Explore.",132,58,420,25,juce::Justification::left);
|
||||||
|
graphics.setColour(juce::Colour(0xff283a44)); graphics.fillRoundedRectangle(590,38,160,9,4);
|
||||||
|
graphics.setColour(juce::Colour(0xff74dbc1)); graphics.fillRoundedRectangle(590,38,160*juce::jlimit(0.0f,1.0f,level),9,4);
|
||||||
|
}
|
||||||
|
void XtcJuceEditor::resized()
|
||||||
|
{
|
||||||
|
for (size_t i=0;i<sliders.size();++i) {
|
||||||
|
const int x=20+static_cast<int>(i)*190;
|
||||||
|
labels[i].setBounds(x,105,170,25); sliders[i].setBounds(x,135,170,150);
|
||||||
|
}
|
||||||
|
effects.setBounds(25,300,220,30); save.setBounds(380,300,120,30);
|
||||||
|
load.setBounds(510,300,120,30); panicButton.setBounds(640,300,110,30);
|
||||||
|
status.setBounds(25,340,725,30); keyboard.setBounds(20,385,740,85);
|
||||||
|
}
|
||||||
|
void XtcJuceEditor::timerCallback()
|
||||||
|
{
|
||||||
|
level=engine.peak(); repaint(580,30,180,25);
|
||||||
|
const juce::String text=engine.processingFailed() ? "Audio preparation or processing failed; reopen the device to retry."
|
||||||
|
: (engine.droppedEvents() ? "MIDI overflow: voices cleared. Reduce the event rate."
|
||||||
|
: "Use the keyboard below or select a MIDI input in the standalone Audio/MIDI settings.");
|
||||||
|
status.setText(presetStatus.isNotEmpty() ? presetStatus : text,juce::dontSendNotification);
|
||||||
|
}
|
||||||
|
void XtcJuceEditor::handleNoteOn(juce::MidiKeyboardState*,int channel,int note,float velocity)
|
||||||
|
{ engine.enqueueNote(true,channel,note,velocity); }
|
||||||
|
void XtcJuceEditor::handleNoteOff(juce::MidiKeyboardState*,int channel,int note,float velocity)
|
||||||
|
{ engine.enqueueNote(false,channel,note,velocity); }
|
||||||
|
void XtcJuceEditor::choosePreset(bool saving)
|
||||||
|
{
|
||||||
|
if (chooser) return;
|
||||||
|
chooser=std::make_unique<juce::FileChooser>(saving ? "Save xtc JUCE preset" : "Load xtc JUCE preset",
|
||||||
|
juce::File{},"*.xtcjuce");
|
||||||
|
const int browserFlags=juce::FileBrowserComponent::canSelectFiles |
|
||||||
|
(saving ? juce::FileBrowserComponent::saveMode | juce::FileBrowserComponent::warnAboutOverwriting
|
||||||
|
: juce::FileBrowserComponent::openMode);
|
||||||
|
chooser->launchAsync(browserFlags,[safe=juce::Component::SafePointer<XtcJuceEditor>(this),saving](const juce::FileChooser& dialog){
|
||||||
|
if (!safe) return;
|
||||||
|
const auto file=dialog.getResult();
|
||||||
|
if (file!=juce::File{}) {
|
||||||
|
if (saving) {
|
||||||
|
juce::MemoryBlock state; safe->engine.getStateInformation(state);
|
||||||
|
juce::TemporaryFile temporary(file);
|
||||||
|
if (!temporary.getFile().replaceWithData(state.getData(),state.getSize()) ||
|
||||||
|
!temporary.overwriteTargetFileWithTemporary())
|
||||||
|
safe->presetStatus="Could not save preset.";
|
||||||
|
else safe->presetStatus="Preset saved.";
|
||||||
|
} else {
|
||||||
|
juce::MemoryBlock state;
|
||||||
|
if (file.getSize()>0 && file.getSize()<=65536 && file.loadFileAsData(state))
|
||||||
|
safe->presetStatus=safe->engine.restorePreset(state.getData(),static_cast<int>(state.getSize()))
|
||||||
|
? "Preset loaded." : "Invalid preset; current settings retained.";
|
||||||
|
else safe->presetStatus="Could not read preset (limit 64 KiB).";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
safe->chooser.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
30
examples/juce/Editor.h
Normal file
30
examples/juce/Editor.h
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
#pragma once
|
||||||
|
#include "Processor.h"
|
||||||
|
class XtcJuceEditor final : public juce::AudioProcessorEditor,
|
||||||
|
private juce::MidiKeyboardState::Listener,
|
||||||
|
private juce::Timer {
|
||||||
|
public:
|
||||||
|
explicit XtcJuceEditor(XtcJuceProcessor&);
|
||||||
|
~XtcJuceEditor() override;
|
||||||
|
void paint(juce::Graphics&) override;
|
||||||
|
void resized() override;
|
||||||
|
private:
|
||||||
|
XtcJuceProcessor& engine;
|
||||||
|
juce::MidiKeyboardState keyboardState;
|
||||||
|
juce::MidiKeyboardComponent keyboard{keyboardState,juce::MidiKeyboardComponent::horizontalKeyboard};
|
||||||
|
std::array<juce::Slider,4> sliders;
|
||||||
|
std::array<juce::Label,4> labels;
|
||||||
|
std::array<std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment>,4> attachments;
|
||||||
|
juce::ToggleButton effects{"Filter + reverb"};
|
||||||
|
std::unique_ptr<juce::AudioProcessorValueTreeState::ButtonAttachment> effectsAttachment;
|
||||||
|
juce::TextButton save{"Save preset"},load{"Load preset"},panicButton{"Panic"};
|
||||||
|
juce::Label status;
|
||||||
|
juce::String presetStatus;
|
||||||
|
std::unique_ptr<juce::FileChooser> chooser;
|
||||||
|
float level=0;
|
||||||
|
void handleNoteOn(juce::MidiKeyboardState*,int,int,float) override;
|
||||||
|
void handleNoteOff(juce::MidiKeyboardState*,int,int,float) override;
|
||||||
|
void timerCallback() override;
|
||||||
|
void choosePreset(bool saving);
|
||||||
|
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(XtcJuceEditor)
|
||||||
|
};
|
||||||
2
examples/juce/PluginEntry.cpp
Normal file
2
examples/juce/PluginEntry.cpp
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#include "Processor.h"
|
||||||
|
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() { return new XtcJuceProcessor; }
|
||||||
170
examples/juce/Processor.cpp
Normal file
170
examples/juce/Processor.cpp
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
#include "Processor.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <charconv>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
juce::AudioProcessorValueTreeState::ParameterLayout XtcJuceProcessor::layout()
|
||||||
|
{
|
||||||
|
juce::AudioProcessorValueTreeState::ParameterLayout result;
|
||||||
|
result.add(std::make_unique<juce::AudioParameterFloat>(juce::ParameterID{"gain",1}, "Output (dB)", -60.0f, 6.0f, 0.0f));
|
||||||
|
juce::NormalisableRange<float> frequency(20.0f,20000.0f);
|
||||||
|
frequency.setSkewForCentre(1000.0f);
|
||||||
|
result.add(std::make_unique<juce::AudioParameterFloat>(juce::ParameterID{"cutoff",1}, "Low-pass (Hz)", frequency, 8000.0f));
|
||||||
|
result.add(std::make_unique<juce::AudioParameterFloat>(juce::ParameterID{"room",1}, "Room size", 0.0f, 1.0f, .5f));
|
||||||
|
result.add(std::make_unique<juce::AudioParameterFloat>(juce::ParameterID{"wet",1}, "Reverb mix", 0.0f, 1.0f, .2f));
|
||||||
|
result.add(std::make_unique<juce::AudioParameterBool>(juce::ParameterID{"effects",1}, "Enable filter + reverb", false));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
XtcJuceProcessor::XtcJuceProcessor()
|
||||||
|
: AudioProcessor(BusesProperties().withOutput("Output",juce::AudioChannelSet::stereo(),true)),
|
||||||
|
parameters(*this,nullptr,"XTC_JUCE_STATE",layout())
|
||||||
|
{
|
||||||
|
static_assert(std::atomic<float>::is_always_lock_free && std::atomic<bool>::is_always_lock_free &&
|
||||||
|
std::atomic<unsigned int>::is_always_lock_free,"Realtime bridge requires lock-free atomics");
|
||||||
|
parameters.state.setProperty("version",1,nullptr);
|
||||||
|
gainDb=parameters.getRawParameterValue("gain"); cutoff=parameters.getRawParameterValue("cutoff");
|
||||||
|
room=parameters.getRawParameterValue("room"); wet=parameters.getRawParameterValue("wet");
|
||||||
|
effects=parameters.getRawParameterValue("effects");
|
||||||
|
}
|
||||||
|
XtcJuceProcessor::~XtcJuceProcessor() { releaseResources(); }
|
||||||
|
bool XtcJuceProcessor::isBusesLayoutSupported(const BusesLayout& bus) const
|
||||||
|
{
|
||||||
|
return bus.getMainInputChannelSet().isDisabled() &&
|
||||||
|
(bus.getMainOutputChannelSet()==juce::AudioChannelSet::mono() ||
|
||||||
|
bus.getMainOutputChannelSet()==juce::AudioChannelSet::stereo());
|
||||||
|
}
|
||||||
|
void XtcJuceProcessor::prepareToPlay(double sampleRate, int maxBlock)
|
||||||
|
{
|
||||||
|
releaseResources();
|
||||||
|
if (!std::isfinite(sampleRate) || sampleRate<8000 || sampleRate>192000 || maxBlock<1) {
|
||||||
|
failed.store(true); return;
|
||||||
|
}
|
||||||
|
xtc_plugin_spec spec{};
|
||||||
|
auto result=xtc_plugin_probe_format(XTC_PLUGIN_BUILTIN,XTC_BUILTIN_PATH,0,sampleRate,&spec);
|
||||||
|
if (result==XTC_OK) result=xtc_plugin_create(&spec,sampleRate,chunkFrames,&synth);
|
||||||
|
if (result!=XTC_OK) { failed.store(true); return; }
|
||||||
|
rate=sampleRate;
|
||||||
|
const juce::dsp::ProcessSpec processing{rate,chunkFrames,static_cast<juce::uint32>(getTotalNumOutputChannels())};
|
||||||
|
filter.prepare(processing); filter.setType(juce::dsp::StateVariableTPTFilterType::lowpass);
|
||||||
|
reverb.prepare(processing);
|
||||||
|
gain.setRampDurationSeconds(.01); gain.setGainDecibels(gainDb->load()); gain.prepare(processing);
|
||||||
|
filter.reset(); reverb.reset(); gain.reset();
|
||||||
|
position=0; panicRequested.store(true); failed.store(false); prepared.store(true,std::memory_order_release);
|
||||||
|
}
|
||||||
|
void XtcJuceProcessor::releaseResources()
|
||||||
|
{
|
||||||
|
prepared.store(false,std::memory_order_release);
|
||||||
|
xtc_plugin_destroy(synth); synth=nullptr;
|
||||||
|
meter.store(0); position=0;
|
||||||
|
}
|
||||||
|
void XtcJuceProcessor::reset()
|
||||||
|
{
|
||||||
|
// Host contract: reset has exclusive processing ownership.
|
||||||
|
xtc_plugin_reset_history(synth); filter.reset(); reverb.reset(); gain.reset(); position=0;
|
||||||
|
}
|
||||||
|
void XtcJuceProcessor::enqueueNote(bool on, int channel, int note, float velocity) noexcept
|
||||||
|
{
|
||||||
|
if (channel<1 || channel>16 || note<0 || note>127 || !std::isfinite(velocity)) return;
|
||||||
|
int start1=0,size1=0,start2=0,size2=0;
|
||||||
|
keyboardQueue.prepareToWrite(1,start1,size1,start2,size2);
|
||||||
|
if (!size1) { dropped.fetch_add(1); panic(); return; }
|
||||||
|
auto& e=keyboardEvents[static_cast<size_t>(start1)];
|
||||||
|
e={0,on ? XTC_MIDI_NOTE_ON : XTC_MIDI_NOTE_OFF,static_cast<unsigned int>(channel-1),
|
||||||
|
static_cast<unsigned int>(note),static_cast<unsigned int>(juce::jlimit(1,127,juce::roundToInt(velocity*127)))};
|
||||||
|
keyboardQueue.finishedWrite(1);
|
||||||
|
}
|
||||||
|
void XtcJuceProcessor::discardKeyboard() noexcept
|
||||||
|
{
|
||||||
|
int start1=0,size1=0,start2=0,size2=0;
|
||||||
|
keyboardQueue.prepareToRead(queueCapacity,start1,size1,start2,size2);
|
||||||
|
keyboardQueue.finishedRead(size1+size2);
|
||||||
|
}
|
||||||
|
void XtcJuceProcessor::processBlock(juce::AudioBuffer<float>& audio, juce::MidiBuffer& midi)
|
||||||
|
{
|
||||||
|
juce::ScopedNoDenormals noDenormals;
|
||||||
|
const int frames=audio.getNumSamples(),channels=audio.getNumChannels();
|
||||||
|
audio.clear();
|
||||||
|
if (!ready() || !synth || (channels!=1 && channels!=2)) { midi.clear(); return; }
|
||||||
|
if (frames==0) { midi.clear(); return; }
|
||||||
|
if (position>std::numeric_limits<uint64_t>::max()-static_cast<uint64_t>(frames)) reset();
|
||||||
|
if (panicRequested.exchange(false,std::memory_order_acq_rel)) {
|
||||||
|
discardKeyboard(); xtc_plugin_reset_history(synth);
|
||||||
|
}
|
||||||
|
gain.setGainDecibels(gainDb->load(std::memory_order_relaxed));
|
||||||
|
filter.setCutoffFrequency(std::min(cutoff->load(std::memory_order_relaxed),static_cast<float>(rate*.45)));
|
||||||
|
juce::Reverb::Parameters settings;
|
||||||
|
settings.roomSize=room->load(std::memory_order_relaxed);
|
||||||
|
settings.wetLevel=wet->load(std::memory_order_relaxed); settings.dryLevel=1-settings.wetLevel;
|
||||||
|
reverb.setParameters(settings);
|
||||||
|
const bool enableEffects=effects->load(std::memory_order_relaxed)>.5f;
|
||||||
|
auto input=midi.cbegin();
|
||||||
|
for (int base=0;base<frames;base+=chunkFrames) {
|
||||||
|
const int length=std::min(chunkFrames,frames-base);
|
||||||
|
size_t count=0;
|
||||||
|
if (base==0) {
|
||||||
|
int start1=0,size1=0,start2=0,size2=0;
|
||||||
|
keyboardQueue.prepareToRead(queueCapacity,start1,size1,start2,size2);
|
||||||
|
for (int i=0;i<size1;++i) events[count++]=keyboardEvents[static_cast<size_t>(start1+i)];
|
||||||
|
for (int i=0;i<size2;++i) events[count++]=keyboardEvents[static_cast<size_t>(start2+i)];
|
||||||
|
keyboardQueue.finishedRead(size1+size2);
|
||||||
|
}
|
||||||
|
while (input!=midi.cend() && (*input).samplePosition<base+length) {
|
||||||
|
const auto message=*input++;
|
||||||
|
if (message.samplePosition<base) continue;
|
||||||
|
xtc_midi_event event{};
|
||||||
|
if (xtc_midi_decode(message.data,static_cast<size_t>(message.numBytes),
|
||||||
|
static_cast<unsigned int>(message.samplePosition-base),&event)!=XTC_OK) continue;
|
||||||
|
if (count==events.size()) {
|
||||||
|
dropped.fetch_add(1); xtc_plugin_reset_history(synth);
|
||||||
|
audio.clear(); midi.clear(); meter.store(0); return;
|
||||||
|
}
|
||||||
|
events[count++]=event;
|
||||||
|
}
|
||||||
|
if (xtc_plugin_process_midi_at(synth,events.data(),count,scratch.data(),static_cast<size_t>(length),position)!=XTC_OK) {
|
||||||
|
failed.store(true); xtc_plugin_reset_history(synth); audio.clear(); midi.clear(); meter.store(0); return;
|
||||||
|
}
|
||||||
|
for (int channel=0;channel<channels;++channel)
|
||||||
|
juce::FloatVectorOperations::copy(audio.getWritePointer(channel,base),scratch.data(),length);
|
||||||
|
auto block=juce::dsp::AudioBlock<float>(audio).getSubBlock(static_cast<size_t>(base),static_cast<size_t>(length));
|
||||||
|
juce::dsp::ProcessContextReplacing<float> context(block);
|
||||||
|
if (enableEffects) { filter.process(context); reverb.process(context); }
|
||||||
|
gain.process(context);
|
||||||
|
position+=static_cast<uint64_t>(length);
|
||||||
|
}
|
||||||
|
// Single atomics only; editor timer reads the last block's absolute peak.
|
||||||
|
meter.store(audio.getMagnitude(0,frames),std::memory_order_relaxed);
|
||||||
|
midi.clear();
|
||||||
|
}
|
||||||
|
void XtcJuceProcessor::getStateInformation(juce::MemoryBlock& destination)
|
||||||
|
{
|
||||||
|
const auto state=parameters.copyState();
|
||||||
|
if (auto xml=state.createXml()) copyXmlToBinary(*xml,destination);
|
||||||
|
}
|
||||||
|
void XtcJuceProcessor::setStateInformation(const void* data, int bytes)
|
||||||
|
{ (void)restorePreset(data,bytes); }
|
||||||
|
bool XtcJuceProcessor::restorePreset(const void* data, int bytes)
|
||||||
|
{
|
||||||
|
if (!data || bytes<=0 || bytes>65536) return false;
|
||||||
|
auto xml=getXmlFromBinary(data,bytes);
|
||||||
|
if (!xml || !xml->hasTagName("XTC_JUCE_STATE")) return false;
|
||||||
|
auto state=juce::ValueTree::fromXml(*xml);
|
||||||
|
// Validate the entire candidate before APVTS can clamp or partially adopt it.
|
||||||
|
if (!state.isValid() || state["version"].toString()!="1" || state.getNumChildren()!=5) return false;
|
||||||
|
juce::StringArray seen;
|
||||||
|
for (const auto& child : state) {
|
||||||
|
const auto id=child["id"].toString();
|
||||||
|
auto* parameter=parameters.getParameter(id);
|
||||||
|
const auto encoded=child["value"].toString().toStdString();
|
||||||
|
float value=0;
|
||||||
|
const auto parsed=std::from_chars(encoded.data(),encoded.data()+encoded.size(),value);
|
||||||
|
if (!child.hasType("PARAM") || !parameter || seen.contains(id) || !child.hasProperty("value") ||
|
||||||
|
parsed.ec!=std::errc{} || parsed.ptr!=encoded.data()+encoded.size() ||
|
||||||
|
!std::isfinite(value) || value<parameter->getNormalisableRange().start || value>parameter->getNormalisableRange().end ||
|
||||||
|
(id=="effects" && value>0 && value<1)) return false;
|
||||||
|
seen.add(id);
|
||||||
|
}
|
||||||
|
parameters.replaceState(state); panic(); return true;
|
||||||
|
}
|
||||||
|
juce::AudioProcessorEditor* XtcJuceProcessor::createEditor() { return new XtcJuceEditor(*this); }
|
||||||
66
examples/juce/Processor.h
Normal file
66
examples/juce/Processor.h
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
#pragma once
|
||||||
|
#include <juce_audio_utils/juce_audio_utils.h>
|
||||||
|
#include <juce_dsp/juce_dsp.h>
|
||||||
|
#include "xtc/xtc_plugin.h"
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
// JUCE owns host integration/UI/DSP; synthesis remains behind xtc's C API.
|
||||||
|
class XtcJuceProcessor final : public juce::AudioProcessor {
|
||||||
|
public:
|
||||||
|
XtcJuceProcessor();
|
||||||
|
~XtcJuceProcessor() override;
|
||||||
|
const juce::String getName() const override { return "xtc JUCE Synth"; }
|
||||||
|
void prepareToPlay(double, int) override;
|
||||||
|
void releaseResources() override;
|
||||||
|
bool isBusesLayoutSupported(const BusesLayout&) const override;
|
||||||
|
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
|
||||||
|
using juce::AudioProcessor::processBlock;
|
||||||
|
void reset() override;
|
||||||
|
bool acceptsMidi() const override { return true; }
|
||||||
|
bool producesMidi() const override { return false; }
|
||||||
|
double getTailLengthSeconds() const override { return 3.0; }
|
||||||
|
bool hasEditor() const override { return true; }
|
||||||
|
juce::AudioProcessorEditor* createEditor() override;
|
||||||
|
int getNumPrograms() override { return 1; }
|
||||||
|
int getCurrentProgram() override { return 0; }
|
||||||
|
void setCurrentProgram(int) override {}
|
||||||
|
const juce::String getProgramName(int) override { return "Default"; }
|
||||||
|
void changeProgramName(int, const juce::String&) override {}
|
||||||
|
void getStateInformation(juce::MemoryBlock&) override;
|
||||||
|
void setStateInformation(const void*, int) override;
|
||||||
|
bool restorePreset(const void*, int);
|
||||||
|
|
||||||
|
juce::AudioProcessorValueTreeState parameters;
|
||||||
|
// One GUI producer; one processBlock consumer. Never calls MidiKeyboardState
|
||||||
|
// or its locks on the audio thread. Overflow requests panic instead of stuck notes.
|
||||||
|
void enqueueNote(bool on, int channel, int note, float velocity) noexcept;
|
||||||
|
void panic() noexcept { panicRequested.store(true, std::memory_order_release); }
|
||||||
|
float peak() const noexcept { return meter.load(std::memory_order_relaxed); }
|
||||||
|
unsigned int droppedEvents() const noexcept { return dropped.load(std::memory_order_relaxed); }
|
||||||
|
bool ready() const noexcept { return prepared.load(std::memory_order_acquire); }
|
||||||
|
bool processingFailed() const noexcept { return failed.load(std::memory_order_relaxed); }
|
||||||
|
private:
|
||||||
|
static juce::AudioProcessorValueTreeState::ParameterLayout layout();
|
||||||
|
static constexpr int chunkFrames = 256, queueCapacity = 128;
|
||||||
|
xtc_plugin* synth = nullptr;
|
||||||
|
std::array<float, chunkFrames> scratch{};
|
||||||
|
std::array<xtc_midi_event, XTC_MIDI_MAX_EVENTS> events{};
|
||||||
|
std::array<xtc_midi_event, queueCapacity> keyboardEvents{};
|
||||||
|
juce::AbstractFifo keyboardQueue{queueCapacity};
|
||||||
|
juce::dsp::StateVariableTPTFilter<float> filter;
|
||||||
|
juce::dsp::Reverb reverb;
|
||||||
|
juce::dsp::Gain<float> gain;
|
||||||
|
std::atomic<float>* gainDb = nullptr;
|
||||||
|
std::atomic<float>* cutoff = nullptr;
|
||||||
|
std::atomic<float>* room = nullptr;
|
||||||
|
std::atomic<float>* wet = nullptr;
|
||||||
|
std::atomic<float>* effects = nullptr;
|
||||||
|
std::atomic<float> meter{0};
|
||||||
|
std::atomic<unsigned int> dropped{0};
|
||||||
|
std::atomic<bool> panicRequested{false}, prepared{false}, failed{false};
|
||||||
|
double rate = 48000;
|
||||||
|
uint64_t position = 0;
|
||||||
|
void discardKeyboard() noexcept;
|
||||||
|
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(XtcJuceProcessor)
|
||||||
|
};
|
||||||
96
examples/juce/README.md
Normal file
96
examples/juce/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# JUCE + xtc examples
|
||||||
|
|
||||||
|
These optional examples use an external **JUCE 9.0.4** checkout (qualified
|
||||||
|
revision `3fb5cbc0340758596dac39f7c8de47d562b4896e`). JUCE is AGPLv3/commercial;
|
||||||
|
a commercial licence may be required for distribution. Read
|
||||||
|
[the integration/license details](../../docs/JUCE.md). Original xtc example code
|
||||||
|
is MIT; JUCE and combined binaries retain their applicable licence obligations.
|
||||||
|
No SDK or linked binary is copied into normal xtc packages.
|
||||||
|
|
||||||
|
## Build and test (Linux)
|
||||||
|
|
||||||
|
Supply JUCE explicitly, for example in `/your/path/JUCE`. With CMake >= 3.25,
|
||||||
|
GNU Make, a C/C++17 toolchain and JUCE's Linux development prerequisites:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make JUCE_ROOT=/your/path/JUCE juce-examples
|
||||||
|
make JUCE_ROOT=/your/path/JUCE test-juce
|
||||||
|
```
|
||||||
|
|
||||||
|
The build never downloads JUCE, installs dependencies or copies plugins into
|
||||||
|
user plugin directories. It builds its own C99 PIC archive with JACK/FLTK disabled
|
||||||
|
under `build/juce/c-core`. `test-juce` also requires Xvfb and runs the processor
|
||||||
|
and JUCE-host checks, then loads the built instrument through xtc's C VST3 host.
|
||||||
|
Normal `make`, `make gui` and `make test` remain JUCE-independent.
|
||||||
|
|
||||||
|
Build products:
|
||||||
|
|
||||||
|
- `build/juce/XtcJuceSynth_artefacts/Release/VST3/xtc JUCE Synth.vst3`
|
||||||
|
- `build/juce/XtcJuceSynth_artefacts/Release/Standalone/xtc JUCE Synth`
|
||||||
|
- `build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render`
|
||||||
|
|
||||||
|
Set `BUILD_DIR=...` to move the main build root, or `JUCE_BUILD_DIR=...` to select
|
||||||
|
only the SDK example build directory.
|
||||||
|
|
||||||
|
## Play and shape Soft Keys
|
||||||
|
|
||||||
|
```sh
|
||||||
|
"./build/juce/XtcJuceSynth_artefacts/Release/Standalone/xtc JUCE Synth"
|
||||||
|
```
|
||||||
|
|
||||||
|
The standalone opens JUCE's audio/MIDI device interface. Select the intended
|
||||||
|
output and MIDI inputs explicitly. Play its on-screen keyboard or a selected
|
||||||
|
MIDI source. Enable **Filter + reverb**, adjust Tone/Room/Mix, and set Output.
|
||||||
|
Save/load `.xtcjuce` presets; Panic clears held voices. The native editor is also
|
||||||
|
available when loading the VST3 into a host with editor support. xtc's existing
|
||||||
|
C host supports its default instrument configuration, without the JUCE editor.
|
||||||
|
Building/testing these examples does not open a physical audio device. Running
|
||||||
|
the standalone may open the system default device; hardware playback is not
|
||||||
|
inferred from successful builds.
|
||||||
|
|
||||||
|
## Render a chord or MIDI file without an audio server
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render --render /absolute/new-demo.wav
|
||||||
|
./build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render --render /absolute/new-song.wav --midi /absolute/song.mid
|
||||||
|
```
|
||||||
|
|
||||||
|
The output is 48 kHz stereo float32 WAV with filter/reverb enabled and -6 dB
|
||||||
|
output. The default renders a staggered C-major chord. MIDI input is type 0/1
|
||||||
|
PPQN, including its tempo map, merged stably across tracks. Supported note,
|
||||||
|
CC and bend messages use rounded sample positions; other messages are ignored.
|
||||||
|
Input limits: 4 MiB, 100000 supported events and ten minutes of event timestamps.
|
||||||
|
The renderer allows half a second after the last event, forces all-notes-off,
|
||||||
|
then renders a fixed three-second tail. It does not derive exact plugin tails.
|
||||||
|
|
||||||
|
Output is written to a same-directory temporary file and atomically linked to
|
||||||
|
an absent destination. Existing files are preserved, including a concurrent
|
||||||
|
creator at publication. Errors remove the temporary and publish no output.
|
||||||
|
On a machine without an X server, prefix commands with `xvfb-run -a`.
|
||||||
|
|
||||||
|
## Inspect/host a trusted VST3
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render --inspect-vst3 /absolute/Plugin.vst3
|
||||||
|
./build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render --host-check "$PWD/build/juce/XtcJuceSynth_artefacts/Release/VST3/xtc JUCE Synth.vst3"
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspection loads only the named module, enumerates its classes and instantiates
|
||||||
|
them to list parameters and channels. It executes trusted code and is not an
|
||||||
|
isolated scanner. `--host-check` is specific to the example: it checks five
|
||||||
|
parameters, opaque state restoration and actual MIDI processing through JUCE's
|
||||||
|
VST3 host. These operations do not start an audio device.
|
||||||
|
|
||||||
|
## Regression checks and an actual editor image
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xvfb-run -a ctest --test-dir build/juce --output-on-failure
|
||||||
|
xvfb-run -a ./build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render --snapshot /absolute/new-editor.png
|
||||||
|
```
|
||||||
|
|
||||||
|
The processor test checks exact C-synth output, note timing, variable blocks,
|
||||||
|
mono/stereo, reset, host/GUI MIDI overflow, preset recovery and effect processing.
|
||||||
|
The snapshot command renders the actual editor to a 1560 × 980 PNG and checks
|
||||||
|
that it can close/reopen without starting audio hardware. It refuses to overwrite
|
||||||
|
a destination. Windows/macOS, alternate plugin formats and physical devices have
|
||||||
|
not been qualified. See [Phase 107](../../docs/ROADMAP.md) for recorded results.
|
||||||
243
examples/juce/Render.cpp
Normal file
243
examples/juce/Render.cpp
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
#include "Processor.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <vector>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
void require(bool condition,const char* message) { if (!condition) throw std::runtime_error(message); }
|
||||||
|
void set(XtcJuceProcessor& p,const juce::String& id,float value)
|
||||||
|
{
|
||||||
|
auto* parameter=p.parameters.getParameter(id); require(parameter!=nullptr,"Unknown parameter");
|
||||||
|
parameter->setValueNotifyingHost(parameter->convertTo0to1(value));
|
||||||
|
}
|
||||||
|
void prepare(XtcJuceProcessor& processor,int channels=2,double rate=48000)
|
||||||
|
{
|
||||||
|
processor.setPlayConfigDetails(0,channels,rate,256); processor.prepareToPlay(rate,256);
|
||||||
|
require(processor.ready(),"Could not prepare xtc synth");
|
||||||
|
// Consume preparation panic before callers enqueue keyboard notes.
|
||||||
|
juce::AudioBuffer<float> silence(channels,1); juce::MidiBuffer empty;
|
||||||
|
processor.processBlock(silence,empty); processor.reset();
|
||||||
|
}
|
||||||
|
void selfTest()
|
||||||
|
{
|
||||||
|
XtcJuceProcessor processor; prepare(processor);
|
||||||
|
juce::AudioBuffer<float> audio(2,513); juce::MidiBuffer midi;
|
||||||
|
midi.addEvent(juce::MidiMessage::noteOn(3,69,static_cast<juce::uint8>(110)),13);
|
||||||
|
midi.addEvent(juce::MidiMessage::noteOff(3,69),401);
|
||||||
|
midi.addEvent(juce::MidiMessage::allNotesOff(3),480);
|
||||||
|
processor.processBlock(audio,midi);
|
||||||
|
require(!processor.processingFailed() && midi.isEmpty(),"Callback failed or produced unexpected MIDI");
|
||||||
|
for (int i=0;i<13;++i) require(audio.getSample(0,i)==0,"Note played early");
|
||||||
|
require(audio.getMagnitude(20,380)>.02f,"Note did not sound");
|
||||||
|
for (int i=480;i<513;++i) require(audio.getSample(0,i)==0,"All notes off was not sample aligned");
|
||||||
|
xtc_plugin_spec spec{}; xtc_plugin* reference=nullptr;
|
||||||
|
require(xtc_plugin_probe_format(XTC_PLUGIN_BUILTIN,XTC_BUILTIN_PATH,0,48000,&spec)==XTC_OK,"Reference probe failed");
|
||||||
|
require(xtc_plugin_create(&spec,48000,256,&reference)==XTC_OK,"Reference prepare failed");
|
||||||
|
std::array<float,256> expected{};
|
||||||
|
const std::array<xtc_midi_event,3> source{{{13,XTC_MIDI_NOTE_ON,2,69,110},
|
||||||
|
{401,XTC_MIDI_NOTE_OFF,2,69,0},{480,XTC_MIDI_ALL_OFF,2,0,0}}};
|
||||||
|
for (int base=0;base<513;base+=256) {
|
||||||
|
std::array<xtc_midi_event,3> block{}; size_t count=0;
|
||||||
|
const int length=std::min(256,513-base);
|
||||||
|
for (const auto& event : source) if (event.offset>=static_cast<unsigned int>(base) && event.offset<static_cast<unsigned int>(base+length)) {
|
||||||
|
block[count]=event; block[count++].offset-=static_cast<unsigned int>(base);
|
||||||
|
}
|
||||||
|
require(xtc_plugin_process_midi_at(reference,block.data(),count,expected.data(),static_cast<size_t>(length),static_cast<uint64_t>(base))==XTC_OK,"Reference render failed");
|
||||||
|
for (int i=0;i<length;++i) for (int channel=0;channel<2;++channel)
|
||||||
|
require(audio.getSample(channel,base+i)==expected[static_cast<size_t>(i)],"JUCE bridge changed the C synth output");
|
||||||
|
}
|
||||||
|
xtc_plugin_destroy(reference);
|
||||||
|
processor.reset(); midi.clear(); processor.processBlock(audio,midi);
|
||||||
|
require(audio.getMagnitude(0,audio.getNumSamples())==0,"Reset retained held voices");
|
||||||
|
// GUI events use a lock-free queue; its overflow must never leave a held note.
|
||||||
|
processor.enqueueNote(true,1,60,1); processor.processBlock(audio,midi);
|
||||||
|
require(audio.getMagnitude(0,513)>0,"Keyboard queue did not sound");
|
||||||
|
for (int i=0;i<300;++i) processor.enqueueNote(true,1,60,1);
|
||||||
|
processor.processBlock(audio,midi);
|
||||||
|
require(processor.droppedEvents()>0 && audio.getMagnitude(0,513)==0,"GUI overflow did not panic safely");
|
||||||
|
for (int i=0;i<300;++i) midi.addEvent(juce::MidiMessage::noteOn(1,60,static_cast<juce::uint8>(127)),0);
|
||||||
|
processor.processBlock(audio,midi);
|
||||||
|
require(audio.getMagnitude(0,513)==0,"Host MIDI overflow emitted a partial block");
|
||||||
|
processor.processBlock(audio,midi);
|
||||||
|
require(audio.getMagnitude(0,513)==0,"Host MIDI overflow left stuck voices");
|
||||||
|
// Host-sized blocks may exceed the prepared internal chunk size.
|
||||||
|
XtcJuceProcessor whole,split; prepare(whole); prepare(split);
|
||||||
|
juce::AudioBuffer<float> large(2,4097); midi.addEvent(juce::MidiMessage::noteOn(1,65,static_cast<juce::uint8>(100)),0);
|
||||||
|
whole.processBlock(large,midi);
|
||||||
|
for (int base=0;base<4097;base+=256) {
|
||||||
|
const int length=std::min(256,4097-base); juce::AudioBuffer<float> part(2,length);
|
||||||
|
if (base==0) midi.addEvent(juce::MidiMessage::noteOn(1,65,static_cast<juce::uint8>(100)),0);
|
||||||
|
split.processBlock(part,midi);
|
||||||
|
for (int i=0;i<length;++i) require(large.getSample(0,base+i)==part.getSample(0,i),"Block-size-dependent synthesis");
|
||||||
|
}
|
||||||
|
// Roundtrip all parameters, reject malformed/incomplete/out-of-range presets.
|
||||||
|
set(processor,"gain",-12); set(processor,"cutoff",2300); set(processor,"effects",1);
|
||||||
|
juce::MemoryBlock state; processor.getStateInformation(state);
|
||||||
|
XtcJuceProcessor restored; restored.setStateInformation(state.getData(),static_cast<int>(state.getSize()));
|
||||||
|
require(restored.parameters.getRawParameterValue("gain")->load()==-12 &&
|
||||||
|
restored.parameters.getRawParameterValue("effects")->load()==1,"Preset did not restore");
|
||||||
|
auto reject=[&](juce::ValueTree candidate) {
|
||||||
|
juce::MemoryBlock corrupt; auto xml=candidate.createXml(); juce::AudioProcessor::copyXmlToBinary(*xml,corrupt);
|
||||||
|
restored.setStateInformation(corrupt.getData(),static_cast<int>(corrupt.getSize()));
|
||||||
|
require(restored.parameters.getRawParameterValue("gain")->load()==-12,"Invalid state partially changed parameters");
|
||||||
|
};
|
||||||
|
auto candidate=processor.parameters.copyState(); candidate.setProperty("version",99,nullptr); reject(candidate);
|
||||||
|
candidate=processor.parameters.copyState(); candidate.removeChild(0,nullptr); reject(candidate);
|
||||||
|
candidate=processor.parameters.copyState(); candidate.getChild(0).setProperty("value",999999,nullptr); reject(candidate);
|
||||||
|
candidate=processor.parameters.copyState(); candidate.getChild(0).setProperty("value","not-a-number",nullptr); reject(candidate);
|
||||||
|
restored.setStateInformation("bad",3); require(restored.parameters.getRawParameterValue("gain")->load()==-12,"Malformed state accepted");
|
||||||
|
prepare(restored,1,44100); midi.addEvent(juce::MidiMessage::noteOn(1,60,static_cast<juce::uint8>(100)),0);
|
||||||
|
juce::AudioBuffer<float> mono(1,256); restored.processBlock(mono,midi);
|
||||||
|
for (int i=0;i<256;++i) require(std::isfinite(mono.getSample(0,i)),"DSP produced nonfinite samples");
|
||||||
|
restored.releaseResources(); mono.applyGain(0); midi.clear(); restored.processBlock(mono,midi);
|
||||||
|
require(mono.getMagnitude(0,256)==0,"Released processor did not silence");
|
||||||
|
restored.prepareToPlay(0,256); require(!restored.ready() && restored.processingFailed(),"Invalid format accepted");
|
||||||
|
std::cout << "JUCE bridge: exact C synthesis, MIDI offsets, stereo/mono, variable blocks, reset, overflow, preset recovery and DSP passed.\n";
|
||||||
|
}
|
||||||
|
struct Scheduled { int64_t frame; juce::MidiMessage message; };
|
||||||
|
std::vector<Scheduled> readMidi(const juce::File& path)
|
||||||
|
{
|
||||||
|
require(path.existsAsFile() && path.getSize()<=4*1024*1024,"MIDI file missing or larger than 4 MiB");
|
||||||
|
juce::FileInputStream input(path); juce::MidiFile file; int fileType=-1;
|
||||||
|
require(input.openedOk() && file.readFrom(input,false,&fileType),"Cannot read Standard MIDI File");
|
||||||
|
require(fileType==0 || fileType==1,"Only type 0/1 MIDI files are supported");
|
||||||
|
require(file.getTimeFormat()>0,"Only PPQN MIDI files are supported");
|
||||||
|
file.convertTimestampTicksToSeconds();
|
||||||
|
std::vector<Scheduled> events;
|
||||||
|
for (int track=0;track<file.getNumTracks();++track) {
|
||||||
|
const auto* sequence=file.getTrack(track);
|
||||||
|
for (int i=0;i<sequence->getNumEvents();++i) {
|
||||||
|
const auto& message=sequence->getEventPointer(i)->message;
|
||||||
|
if (!(message.isNoteOnOrOff() || message.isController() || message.isPitchWheel())) continue;
|
||||||
|
const double seconds=message.getTimeStamp();
|
||||||
|
require(std::isfinite(seconds) && seconds>=0 && seconds<=600,"MIDI time exceeds ten-minute example limit");
|
||||||
|
require(events.size()<100000,"MIDI example limit: 100000 events");
|
||||||
|
events.push_back({static_cast<int64_t>(std::llround(seconds*48000)),message});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::stable_sort(events.begin(),events.end(),[](const auto& a,const auto& b){return a.frame<b.frame;});
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
void inspectPlugin(const juce::File& bundle,bool verifyExample)
|
||||||
|
{
|
||||||
|
require(bundle.exists(),"Selected VST3 bundle does not exist");
|
||||||
|
juce::AudioPluginFormatManager manager;
|
||||||
|
auto format=std::make_unique<juce::VST3PluginFormat>();
|
||||||
|
auto* vst3=format.get(); manager.addFormat(std::move(format));
|
||||||
|
juce::OwnedArray<juce::PluginDescription> descriptions;
|
||||||
|
vst3->findAllTypesForFile(descriptions,bundle.getFullPathName());
|
||||||
|
require(!descriptions.isEmpty(),"No VST3 audio classes found");
|
||||||
|
for (const auto* description : descriptions) {
|
||||||
|
juce::String error;
|
||||||
|
auto instance=manager.createPluginInstance(*description,48000,256,error);
|
||||||
|
require(instance!=nullptr,error.isNotEmpty() ? error.toRawUTF8() : "Cannot instantiate VST3");
|
||||||
|
std::cout << description->name << " | " << description->manufacturerName << " | " << description->version
|
||||||
|
<< " | " << instance->getTotalNumInputChannels() << " inputs / " << instance->getTotalNumOutputChannels()
|
||||||
|
<< " outputs | " << instance->getParameters().size() << " parameters\n";
|
||||||
|
for (auto* parameter : instance->getParameters())
|
||||||
|
std::cout << " " << parameter->getName(64) << " = " << parameter->getValue() << '\n';
|
||||||
|
if (verifyExample) {
|
||||||
|
require(instance->getParameters().size()>=5 && description->isInstrument,"Not the xtc JUCE example");
|
||||||
|
juce::MemoryBlock state; instance->getStateInformation(state);
|
||||||
|
require(state.getSize()>0,"JUCE host did not capture VST3 state");
|
||||||
|
auto* output=instance->getParameters().getFirst(); const float original=output->getValue();
|
||||||
|
output->setValueNotifyingHost(.3f);
|
||||||
|
instance->setStateInformation(state.getData(),static_cast<int>(state.getSize()));
|
||||||
|
require(std::abs(output->getValue()-original)<.00001f,"VST3 opaque-state roundtrip failed");
|
||||||
|
instance->setPlayConfigDetails(0,2,48000,256); instance->prepareToPlay(48000,256);
|
||||||
|
juce::AudioBuffer<float> audio(2,256); juce::MidiBuffer midi;
|
||||||
|
midi.addEvent(juce::MidiMessage::noteOn(1,69,static_cast<juce::uint8>(110)),13);
|
||||||
|
midi.addEvent(juce::MidiMessage::allNotesOff(1),200);
|
||||||
|
instance->processBlock(audio,midi);
|
||||||
|
for (int i=0;i<13;++i) require(audio.getSample(0,i)==0,"VST3 note played early");
|
||||||
|
require(audio.getMagnitude(13,187)>.005f,"JUCE-hosted VST3 produced silence");
|
||||||
|
for (int i=200;i<256;++i) require(audio.getSample(0,i)==0,"VST3 all-off offset failed");
|
||||||
|
instance->releaseResources();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (verifyExample) std::cout << "JUCE VST3 hosting: discovery, parameters, opaque-state roundtrip and MIDI processing passed.\n";
|
||||||
|
}
|
||||||
|
void snapshot(const juce::File& destination)
|
||||||
|
{
|
||||||
|
require(!destination.exists(),"Snapshot already exists");
|
||||||
|
XtcJuceProcessor processor; prepare(processor);
|
||||||
|
set(processor,"effects",1); set(processor,"gain",-6);
|
||||||
|
std::unique_ptr<juce::AudioProcessorEditor> editor(processor.createEditor());
|
||||||
|
require(editor && editor->getWidth()==780 && editor->getHeight()==490,"Editor layout failed");
|
||||||
|
const auto image=editor->createComponentSnapshot(editor->getLocalBounds(),true,2.0f);
|
||||||
|
require(image.isValid(),"Editor snapshot failed");
|
||||||
|
juce::TemporaryFile temporary(destination); juce::PNGImageFormat png;
|
||||||
|
{ juce::FileOutputStream stream(temporary.getFile()); require(stream.openedOk() && png.writeImageToStream(image,stream),"PNG encoding failed"); }
|
||||||
|
require(::link(temporary.getFile().getFullPathName().toRawUTF8(),destination.getFullPathName().toRawUTF8())==0,"PNG publication failed");
|
||||||
|
editor.reset(); editor.reset(processor.createEditor());
|
||||||
|
require(editor!=nullptr,"Editor could not reopen");
|
||||||
|
std::cout << "Saved actual JUCE editor snapshot without opening an audio device.\n";
|
||||||
|
}
|
||||||
|
void render(const juce::File& destination,const juce::File& midiFile)
|
||||||
|
{
|
||||||
|
require(!destination.exists(),"Output already exists; choose a new WAV path");
|
||||||
|
auto scheduled=midiFile==juce::File{} ? std::vector<Scheduled>{
|
||||||
|
{0,juce::MidiMessage::noteOn(1,60,static_cast<juce::uint8>(100))},
|
||||||
|
{12000,juce::MidiMessage::noteOn(1,64,static_cast<juce::uint8>(95))},
|
||||||
|
{24000,juce::MidiMessage::noteOn(1,67,static_cast<juce::uint8>(90))},
|
||||||
|
{48000,juce::MidiMessage::allNotesOff(1)}} : readMidi(midiFile);
|
||||||
|
require(!scheduled.empty(),"MIDI file has no supported events");
|
||||||
|
const int64_t total=scheduled.back().frame+24000+3*48000;
|
||||||
|
XtcJuceProcessor processor; set(processor,"effects",1); set(processor,"gain",-6); prepare(processor);
|
||||||
|
auto name=(destination.getParentDirectory().getFullPathName()+"/.xtc-juce-XXXXXX").toStdString();
|
||||||
|
std::vector<char> temporary(name.begin(),name.end()); temporary.push_back(0);
|
||||||
|
const int fd=mkstemp(temporary.data()); require(fd>=0,"Cannot create output temporary file");
|
||||||
|
close(fd);
|
||||||
|
struct Cleanup { juce::File file; ~Cleanup(){file.deleteFile();} } cleanup{juce::File(temporary.data())};
|
||||||
|
std::unique_ptr<juce::OutputStream> stream=std::make_unique<juce::FileOutputStream>(cleanup.file);
|
||||||
|
juce::WavAudioFormat format;
|
||||||
|
auto writer=format.createWriterFor(stream,juce::AudioFormatWriterOptions{}.withSampleRate(48000)
|
||||||
|
.withNumChannels(2).withBitsPerSample(32).withSampleFormat(juce::AudioFormatWriterOptions::SampleFormat::floatingPoint));
|
||||||
|
require(writer!=nullptr,"Cannot open WAV writer");
|
||||||
|
size_t index=0;
|
||||||
|
for (int64_t base=0;base<total;base+=256) {
|
||||||
|
const int length=static_cast<int>(std::min<int64_t>(256,total-base));
|
||||||
|
juce::AudioBuffer<float> audio(2,length); juce::MidiBuffer midi;
|
||||||
|
while (index<scheduled.size() && scheduled[index].frame<base+length) {
|
||||||
|
midi.addEvent(scheduled[index].message,static_cast<int>(scheduled[index].frame-base)); ++index;
|
||||||
|
}
|
||||||
|
// Force release at the start of the fixed tail, including files with missing note-offs.
|
||||||
|
if (base<=total-3*48000 && base+length>total-3*48000)
|
||||||
|
for (int channel=1;channel<=16;++channel)
|
||||||
|
midi.addEvent(juce::MidiMessage::allNotesOff(channel),static_cast<int>(total-3*48000-base));
|
||||||
|
processor.processBlock(audio,midi);
|
||||||
|
require(!processor.processingFailed(),"xtc processing failed");
|
||||||
|
require(processor.droppedEvents()==0,"MIDI density exceeds 256 supported events per 256-frame chunk");
|
||||||
|
require(writer->writeFromAudioSampleBuffer(audio,0,length),"WAV write failed");
|
||||||
|
}
|
||||||
|
require(writer->flush(),"WAV finalization failed");
|
||||||
|
writer.reset();
|
||||||
|
// Atomic no-clobber publication on the qualified POSIX platform.
|
||||||
|
require(::link(cleanup.file.getFullPathName().toRawUTF8(),destination.getFullPathName().toRawUTF8())==0,
|
||||||
|
"Cannot publish WAV (destination may already exist)");
|
||||||
|
std::cout << "Rendered " << total << " stereo float32 frames at 48000 Hz to " << destination.getFullPathName() << '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int main(int argc,char** argv)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
juce::ScopedJuceInitialiser_GUI juceRuntime;
|
||||||
|
if (argc==2 && juce::String(argv[1])=="--self-test") { selfTest(); return 0; }
|
||||||
|
if (argc==3 && (juce::String(argv[1])=="--inspect-vst3" || juce::String(argv[1])=="--host-check")) {
|
||||||
|
inspectPlugin(juce::File::getCurrentWorkingDirectory().getChildFile(argv[2]),juce::String(argv[1])=="--host-check"); return 0;
|
||||||
|
}
|
||||||
|
if (argc==3 && juce::String(argv[1])=="--snapshot") {
|
||||||
|
snapshot(juce::File::getCurrentWorkingDirectory().getChildFile(argv[2])); return 0;
|
||||||
|
}
|
||||||
|
if ((argc==3 || argc==5) && juce::String(argv[1])=="--render") {
|
||||||
|
require(argc==3 || juce::String(argv[3])=="--midi","Expected --midi FILE.mid");
|
||||||
|
render(juce::File::getCurrentWorkingDirectory().getChildFile(argv[2]),
|
||||||
|
argc==5 ? juce::File::getCurrentWorkingDirectory().getChildFile(argv[4]) : juce::File{});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::cerr << "Usage: xtc-juce-render --self-test | --snapshot NEW.png | --render NEW.wav [--midi FILE.mid] | --inspect-vst3 BUNDLE | --host-check EXAMPLE_BUNDLE\n"; return 2;
|
||||||
|
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
|
||||||
|
}
|
||||||
80
tests/integration/test_juce_examples.py
Normal file
80
tests/integration/test_juce_examples.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Independent checks of the optional JUCE CLI's file and rendering contracts."""
|
||||||
|
from pathlib import Path
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
binary = str(Path(sys.argv[1]).resolve())
|
||||||
|
|
||||||
|
def run(*args, expected=0):
|
||||||
|
result = subprocess.run([binary, *map(str, args)], capture_output=True, text=True)
|
||||||
|
assert result.returncode == expected, (args, result.returncode, result.stdout, result.stderr)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def wav(path):
|
||||||
|
data = path.read_bytes()
|
||||||
|
assert data[:4] == b"RIFF" and data[8:12] == b"WAVE"
|
||||||
|
chunks = {}
|
||||||
|
cursor = 12
|
||||||
|
while cursor + 8 <= len(data):
|
||||||
|
kind, size = struct.unpack_from("<4sI", data, cursor)
|
||||||
|
chunks[kind] = data[cursor + 8: cursor + 8 + size]
|
||||||
|
cursor += 8 + size + size % 2
|
||||||
|
code, channels, rate, _, _, bits = struct.unpack_from("<HHIIHH", chunks[b"fmt "])
|
||||||
|
assert code in (3, 0xFFFE) and channels == 2 and rate == 48000 and bits == 32
|
||||||
|
if code == 0xFFFE:
|
||||||
|
assert struct.unpack_from("<I", chunks[b"fmt "], 24)[0] == 3
|
||||||
|
samples = struct.unpack("<" + "f" * (len(chunks[b"data"]) // 4), chunks[b"data"])
|
||||||
|
assert all(math.isfinite(x) for x in samples)
|
||||||
|
assert max(map(abs, samples)) > .005
|
||||||
|
return len(samples) // channels
|
||||||
|
|
||||||
|
def vlq(value):
|
||||||
|
result = [value & 127]
|
||||||
|
while value >> 7:
|
||||||
|
value >>= 7
|
||||||
|
result.insert(0, 128 | (value & 127))
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
def track(data):
|
||||||
|
return b"MTrk" + struct.pack(">I", len(data)) + data
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="xtc-juce-accept-") as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
output = root / "demo.wav"
|
||||||
|
run("--render", output)
|
||||||
|
assert wav(output) == 216000 # one-second chord + .5 s hold + 3 s tail
|
||||||
|
digest = hashlib.sha256(output.read_bytes()).digest()
|
||||||
|
run("--render", output, expected=1)
|
||||||
|
assert hashlib.sha256(output.read_bytes()).digest() == digest
|
||||||
|
midi = root / "tempo.mid"
|
||||||
|
header = b"MThd" + struct.pack(">IHHH", 6, 1, 2, 480)
|
||||||
|
tempo = b"\x00\xff\x51\x03\x07\xa1\x20" + vlq(480) + b"\xff\x51\x03\x0f\x42\x40" + b"\x00\xff\x2f\x00"
|
||||||
|
notes = b"\x00\x90\x3c\x64" + vlq(960) + b"\x80\x3c\x00\x00\xff\x2f\x00"
|
||||||
|
midi.write_bytes(header + track(tempo) + track(notes))
|
||||||
|
imported = root / "imported.wav"
|
||||||
|
run("--render", imported, "--midi", midi)
|
||||||
|
assert wav(imported) == 240000 # .5 s at 120 BPM + 1 s at 60 BPM + .5 + 3
|
||||||
|
missing_output = root / "failed.wav"
|
||||||
|
run("--render", missing_output, "--midi", root / "absent.mid", expected=1)
|
||||||
|
midi.write_bytes(b"not midi")
|
||||||
|
run("--render", missing_output, "--midi", midi, expected=1)
|
||||||
|
# Dense MIDI is rejected instead of publishing a partially dropped render.
|
||||||
|
dense = b"\x00\x90\x3c\x64" * 257 + vlq(960) + b"\x80\x3c\x00\x00\xff\x2f\x00"
|
||||||
|
midi.write_bytes(b"MThd" + struct.pack(">IHHH", 6, 0, 1, 480) + track(dense))
|
||||||
|
run("--render", missing_output, "--midi", midi, expected=1)
|
||||||
|
assert not missing_output.exists() and not list(root.glob(".xtc-juce-*"))
|
||||||
|
png = root / "editor.png"
|
||||||
|
run("--snapshot", png)
|
||||||
|
contents = png.read_bytes()
|
||||||
|
assert contents[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
assert struct.unpack_from(">II", contents, 16) == (1560, 980)
|
||||||
|
png_digest = hashlib.sha256(contents).digest()
|
||||||
|
run("--snapshot", png, expected=1)
|
||||||
|
assert hashlib.sha256(png.read_bytes()).digest() == png_digest
|
||||||
|
run("--invalid", expected=2)
|
||||||
|
print("JUCE examples: float WAV, tempo-map MIDI, no-clobber, failure cleanup and actual editor render/reopen passed.")
|
||||||
30
tests/integration/test_juce_plugin.c
Normal file
30
tests/integration/test_juce_plugin.c
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/* Exercise the JUCE-built VST3 through xtc's existing C host, not JUCE's host. */
|
||||||
|
#include "xtc/xtc_plugin.h"
|
||||||
|
#include <assert.h>
|
||||||
|
#include <math.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
int main(int argc,char **argv)
|
||||||
|
{
|
||||||
|
xtc_plugin_spec spec;
|
||||||
|
xtc_plugin *plugin=NULL;
|
||||||
|
xtc_midi_event events[]={{13,XTC_MIDI_NOTE_ON,0,69,100},{200,XTC_MIDI_ALL_OFF,0,0,0}};
|
||||||
|
float data[256]; size_t i, block; float peak=0;
|
||||||
|
assert(argc==2 && argv[1][0]=='/');
|
||||||
|
assert(xtc_plugin_probe_format(XTC_PLUGIN_VST3,argv[1],0,48000,&spec)==XTC_OK);
|
||||||
|
assert(spec.instrument && spec.inputs==0 && spec.format==XTC_PLUGIN_VST3);
|
||||||
|
assert(xtc_plugin_create(&spec,48000,256,&plugin)==XTC_OK);
|
||||||
|
assert(xtc_plugin_process_midi(plugin,events,2,data,256)==XTC_OK);
|
||||||
|
for(i=0;i<13;++i)assert(data[i]==0);
|
||||||
|
for(i=13;i<200;++i){assert(isfinite(data[i]));if(fabsf(data[i])>peak)peak=fabsf(data[i]);}
|
||||||
|
assert(peak>.005f);
|
||||||
|
/* The C VST3 host maps ALL_OFF to individual note-offs, allowing release. */
|
||||||
|
for(i=200;i<256;++i)assert(isfinite(data[i]));
|
||||||
|
for(block=0;block<200;++block) {
|
||||||
|
assert(xtc_plugin_process_midi(plugin,NULL,0,data,256)==XTC_OK);
|
||||||
|
for(i=0;i<256;++i)assert(isfinite(data[i]));
|
||||||
|
}
|
||||||
|
for(i=0;i<256;++i)assert(data[i]==0);
|
||||||
|
xtc_plugin_destroy(plugin);
|
||||||
|
puts("JUCE VST3 loaded through the xtc C host: identity, mono negotiation, MIDI offsets, note-off release and eventual silence passed.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
6
third_party/README.md
vendored
6
third_party/README.md
vendored
|
|
@ -23,3 +23,9 @@ Optional full-SDK examples in `examples/steinberg` compile from explicitly
|
||||||
supplied external VST3/ASIO SDK roots. Normal builds do not fetch or link them;
|
supplied external VST3/ASIO SDK roots. Normal builds do not fetch or link them;
|
||||||
no external SDK source or ASIO runtime is included in source/binary packages.
|
no external SDK source or ASIO runtime is included in source/binary packages.
|
||||||
See [SDK integration and licenses](../examples/steinberg/README.md).
|
See [SDK integration and licenses](../examples/steinberg/README.md).
|
||||||
|
|
||||||
|
The optional `examples/juce` integration uses an external JUCE source root, not
|
||||||
|
vendored headers. The inspected JUCE 9.0.4 revision is AGPLv3/commercial; a
|
||||||
|
commercial licence may be required. Original example source does not relicense
|
||||||
|
JUCE or its dependencies. JUCE-linked outputs stay outside normal install and
|
||||||
|
package targets. See [JUCE dependency/license details](../docs/JUCE.md).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue