diff --git a/Makefile b/Makefile index aba210f..64c55c4 100644 --- a/Makefile +++ b/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" -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 diff --git a/README.md b/README.md index 6dc8d13..2670d75 100644 --- a/README.md +++ b/README.md @@ -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` for optional SDK qualification. A Windows ASIO device diagnostic is provided; 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). diff --git a/docs/JUCE.md b/docs/JUCE.md new file mode 100644 index 0000000..c9c1a8f --- /dev/null +++ b/docs/JUCE.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4c04a22..db3526d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3440,6 +3440,64 @@ configuration correctly rejects the unsupported platform; no Windows build or 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 diff --git a/examples/README.md b/examples/README.md index 38d65f4..3742cbf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 render a WAV offline. An optional Windows ASIO device diagnostic is also provided; 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. diff --git a/examples/juce/CMakeLists.txt b/examples/juce/CMakeLists.txt new file mode 100644 index 0000000..e842179 --- /dev/null +++ b/examples/juce/CMakeLists.txt @@ -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/$") +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 "$/../..") diff --git a/examples/juce/Editor.cpp b/examples/juce/Editor.cpp new file mode 100644 index 0000000..56166d5 --- /dev/null +++ b/examples/juce/Editor.cpp @@ -0,0 +1,97 @@ +#include "Editor.h" +XtcJuceEditor::XtcJuceEditor(XtcJuceProcessor& owner) + : AudioProcessorEditor(owner),engine(owner) +{ + const std::array ids{{"gain","cutoff","room","wet"}}; + const std::array names{{"OUTPUT / dB","TONE / Hz","ROOM SIZE","REVERB MIX"}}; + for (size_t i=0;i(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(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(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(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(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(state.getSize())) + ? "Preset loaded." : "Invalid preset; current settings retained."; + else safe->presetStatus="Could not read preset (limit 64 KiB)."; + } + } + safe->chooser.reset(); + }); +} diff --git a/examples/juce/Editor.h b/examples/juce/Editor.h new file mode 100644 index 0000000..be112be --- /dev/null +++ b/examples/juce/Editor.h @@ -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 sliders; + std::array labels; + std::array,4> attachments; + juce::ToggleButton effects{"Filter + reverb"}; + std::unique_ptr effectsAttachment; + juce::TextButton save{"Save preset"},load{"Load preset"},panicButton{"Panic"}; + juce::Label status; + juce::String presetStatus; + std::unique_ptr 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) +}; diff --git a/examples/juce/PluginEntry.cpp b/examples/juce/PluginEntry.cpp new file mode 100644 index 0000000..b660926 --- /dev/null +++ b/examples/juce/PluginEntry.cpp @@ -0,0 +1,2 @@ +#include "Processor.h" +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() { return new XtcJuceProcessor; } diff --git a/examples/juce/Processor.cpp b/examples/juce/Processor.cpp new file mode 100644 index 0000000..2193331 --- /dev/null +++ b/examples/juce/Processor.cpp @@ -0,0 +1,170 @@ +#include "Processor.h" +#include "Editor.h" +#include +#include +#include +#include + +juce::AudioProcessorValueTreeState::ParameterLayout XtcJuceProcessor::layout() +{ + juce::AudioProcessorValueTreeState::ParameterLayout result; + result.add(std::make_unique(juce::ParameterID{"gain",1}, "Output (dB)", -60.0f, 6.0f, 0.0f)); + juce::NormalisableRange frequency(20.0f,20000.0f); + frequency.setSkewForCentre(1000.0f); + result.add(std::make_unique(juce::ParameterID{"cutoff",1}, "Low-pass (Hz)", frequency, 8000.0f)); + result.add(std::make_unique(juce::ParameterID{"room",1}, "Room size", 0.0f, 1.0f, .5f)); + result.add(std::make_unique(juce::ParameterID{"wet",1}, "Reverb mix", 0.0f, 1.0f, .2f)); + result.add(std::make_unique(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::is_always_lock_free && std::atomic::is_always_lock_free && + std::atomic::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(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(start1)]; + e={0,on ? XTC_MIDI_NOTE_ON : XTC_MIDI_NOTE_OFF,static_cast(channel-1), + static_cast(note),static_cast(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& 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::max()-static_cast(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(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(start1+i)]; + for (int i=0;i(start2+i)]; + keyboardQueue.finishedRead(size1+size2); + } + while (input!=midi.cend() && (*input).samplePosition(message.numBytes), + static_cast(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(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(audio).getSubBlock(static_cast(base),static_cast(length)); + juce::dsp::ProcessContextReplacing context(block); + if (enableEffects) { filter.process(context); reverb.process(context); } + gain.process(context); + position+=static_cast(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) || valuegetNormalisableRange().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); } diff --git a/examples/juce/Processor.h b/examples/juce/Processor.h new file mode 100644 index 0000000..c19862b --- /dev/null +++ b/examples/juce/Processor.h @@ -0,0 +1,66 @@ +#pragma once +#include +#include +#include "xtc/xtc_plugin.h" +#include +#include + +// 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&, 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 scratch{}; + std::array events{}; + std::array keyboardEvents{}; + juce::AbstractFifo keyboardQueue{queueCapacity}; + juce::dsp::StateVariableTPTFilter filter; + juce::dsp::Reverb reverb; + juce::dsp::Gain gain; + std::atomic* gainDb = nullptr; + std::atomic* cutoff = nullptr; + std::atomic* room = nullptr; + std::atomic* wet = nullptr; + std::atomic* effects = nullptr; + std::atomic meter{0}; + std::atomic dropped{0}; + std::atomic panicRequested{false}, prepared{false}, failed{false}; + double rate = 48000; + uint64_t position = 0; + void discardKeyboard() noexcept; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(XtcJuceProcessor) +}; diff --git a/examples/juce/README.md b/examples/juce/README.md new file mode 100644 index 0000000..9a62391 --- /dev/null +++ b/examples/juce/README.md @@ -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. diff --git a/examples/juce/Render.cpp b/examples/juce/Render.cpp new file mode 100644 index 0000000..4ac19f0 --- /dev/null +++ b/examples/juce/Render.cpp @@ -0,0 +1,243 @@ +#include "Processor.h" +#include +#include +#include +#include +#include +#include + +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 silence(channels,1); juce::MidiBuffer empty; + processor.processBlock(silence,empty); processor.reset(); +} +void selfTest() +{ + XtcJuceProcessor processor; prepare(processor); + juce::AudioBuffer audio(2,513); juce::MidiBuffer midi; + midi.addEvent(juce::MidiMessage::noteOn(3,69,static_cast(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 expected{}; + const std::array 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 block{}; size_t count=0; + const int length=std::min(256,513-base); + for (const auto& event : source) if (event.offset>=static_cast(base) && event.offset(base+length)) { + block[count]=event; block[count++].offset-=static_cast(base); + } + require(xtc_plugin_process_midi_at(reference,block.data(),count,expected.data(),static_cast(length),static_cast(base))==XTC_OK,"Reference render failed"); + for (int i=0;i(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(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 large(2,4097); midi.addEvent(juce::MidiMessage::noteOn(1,65,static_cast(100)),0); + whole.processBlock(large,midi); + for (int base=0;base<4097;base+=256) { + const int length=std::min(256,4097-base); juce::AudioBuffer part(2,length); + if (base==0) midi.addEvent(juce::MidiMessage::noteOn(1,65,static_cast(100)),0); + split.processBlock(part,midi); + for (int i=0;i(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(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(100)),0); + juce::AudioBuffer 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 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 events; + for (int track=0;trackgetNumEvents();++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(std::llround(seconds*48000)),message}); + } + } + std::stable_sort(events.begin(),events.end(),[](const auto& a,const auto& b){return a.frame(); + auto* vst3=format.get(); manager.addFormat(std::move(format)); + juce::OwnedArray 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(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 audio(2,256); juce::MidiBuffer midi; + midi.addEvent(juce::MidiMessage::noteOn(1,69,static_cast(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 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{ + {0,juce::MidiMessage::noteOn(1,60,static_cast(100))}, + {12000,juce::MidiMessage::noteOn(1,64,static_cast(95))}, + {24000,juce::MidiMessage::noteOn(1,67,static_cast(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 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 stream=std::make_unique(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(std::min(256,total-base)); + juce::AudioBuffer audio(2,length); juce::MidiBuffer midi; + while (index(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(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; } +} diff --git a/tests/integration/test_juce_examples.py b/tests/integration/test_juce_examples.py new file mode 100644 index 0000000..d1755cf --- /dev/null +++ b/tests/integration/test_juce_examples.py @@ -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(" .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.") diff --git a/tests/integration/test_juce_plugin.c b/tests/integration/test_juce_plugin.c new file mode 100644 index 0000000..65c5665 --- /dev/null +++ b/tests/integration/test_juce_plugin.c @@ -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 +#include +#include +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; +} diff --git a/third_party/README.md b/third_party/README.md index cb9accc..0b4fa90 100644 --- a/third_party/README.md +++ b/third_party/README.md @@ -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; no external SDK source or ASIO runtime is included in source/binary packages. 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).