JUCE SDK integration.

This commit is contained in:
Johannes Findeisen 2026-11-06 18:29:35 +01:00
commit adcbcdfd5e
16 changed files with 1057 additions and 0 deletions

View file

@ -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

View file

@ -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
View 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.

View file

@ -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

View file

@ -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.

View 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
View 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
View 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)
};

View file

@ -0,0 +1,2 @@
#include "Processor.h"
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() { return new XtcJuceProcessor; }

170
examples/juce/Processor.cpp Normal file
View 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
View 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
View 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
View 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; }
}

View 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.")

View 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;
}

View file

@ -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).