JUCE SDK integration.
This commit is contained in:
parent
8e09f58944
commit
adcbcdfd5e
16 changed files with 1057 additions and 0 deletions
64
examples/juce/CMakeLists.txt
Normal file
64
examples/juce/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
cmake_minimum_required(VERSION 3.25)
|
||||
project(xtc_juce_examples VERSION 0.1.0 LANGUAGES C CXX)
|
||||
set(XTC_JUCE_ROOT "" CACHE PATH "External JUCE source checkout")
|
||||
if(NOT EXISTS "${XTC_JUCE_ROOT}/modules/juce_audio_utils/juce_audio_utils.h")
|
||||
message(FATAL_ERROR "Set XTC_JUCE_ROOT to an external JUCE checkout; normal xtc builds do not download JUCE")
|
||||
endif()
|
||||
if(NOT UNIX OR APPLE)
|
||||
message(FATAL_ERROR "The xtc C core bridge is currently qualified on Linux/POSIX only")
|
||||
endif()
|
||||
message(STATUS "JUCE is AGPLv3/commercial: review the external SDK LICENSE.md before distributing these binaries")
|
||||
set(JUCE_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(JUCE_BUILD_EXTRAS OFF CACHE BOOL "" FORCE)
|
||||
add_subdirectory("${XTC_JUCE_ROOT}" juce-sdk EXCLUDE_FROM_ALL)
|
||||
find_program(XTC_MAKE_EXECUTABLE NAMES gmake make REQUIRED)
|
||||
get_filename_component(XTC_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
|
||||
set(XTC_CORE_BUILD "${CMAKE_CURRENT_BINARY_DIR}/c-core")
|
||||
add_custom_target(xtc-juce-core
|
||||
COMMAND "${XTC_MAKE_EXECUTABLE}" -C "${XTC_ROOT}"
|
||||
"BUILD_DIR=${XTC_CORE_BUILD}" "CC=${CMAKE_C_COMPILER}"
|
||||
"CFLAGS=-O2 -g -fPIC -std=c99 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -Iinclude"
|
||||
PKG_CONFIG=false FLTK_CONFIG=false "${XTC_CORE_BUILD}/libxtc.a"
|
||||
BYPRODUCTS "${XTC_CORE_BUILD}/libxtc.a"
|
||||
VERBATIM USES_TERMINAL)
|
||||
add_library(xtc-juce-c-core STATIC IMPORTED)
|
||||
set_target_properties(xtc-juce-c-core PROPERTIES
|
||||
IMPORTED_LOCATION "${XTC_CORE_BUILD}/libxtc.a"
|
||||
INTERFACE_INCLUDE_DIRECTORIES "${XTC_ROOT}/include")
|
||||
add_dependencies(xtc-juce-c-core xtc-juce-core)
|
||||
|
||||
juce_add_plugin(XtcJuceSynth
|
||||
COMPANY_NAME xtc
|
||||
BUNDLE_ID org.xtc.juce.synth
|
||||
PLUGIN_MANUFACTURER_CODE Xtca
|
||||
PLUGIN_CODE Xjcs
|
||||
PRODUCT_NAME "xtc JUCE Synth"
|
||||
IS_SYNTH TRUE
|
||||
NEEDS_MIDI_INPUT TRUE
|
||||
NEEDS_MIDI_OUTPUT FALSE
|
||||
EDITOR_WANTS_KEYBOARD_FOCUS FALSE
|
||||
COPY_PLUGIN_AFTER_BUILD FALSE
|
||||
FORMATS VST3 Standalone
|
||||
NEEDS_WEB_BROWSER FALSE
|
||||
NEEDS_CURL FALSE)
|
||||
target_sources(XtcJuceSynth PRIVATE Processor.cpp Editor.cpp PluginEntry.cpp)
|
||||
target_compile_features(XtcJuceSynth PUBLIC cxx_std_17)
|
||||
target_compile_definitions(XtcJuceSynth PUBLIC
|
||||
JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0 JUCE_VST3_CAN_REPLACE_VST2=0 JUCE_PLUGINHOST_VST3=1
|
||||
)
|
||||
target_include_directories(XtcJuceSynth PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}" "${XTC_ROOT}/include")
|
||||
target_link_libraries(XtcJuceSynth PRIVATE xtc-juce-c-core juce::juce_audio_utils juce::juce_dsp
|
||||
PUBLIC juce::juce_recommended_config_flags juce::juce_recommended_warning_flags)
|
||||
|
||||
add_executable(xtc-juce-render Render.cpp)
|
||||
set_target_properties(xtc-juce-render PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/xtc-juce-render_artefacts/$<CONFIG>")
|
||||
target_compile_features(xtc-juce-render PRIVATE cxx_std_17)
|
||||
target_include_directories(xtc-juce-render PRIVATE "${XTC_JUCE_ROOT}/modules")
|
||||
# Exact float comparisons intentionally check bit-identical C bridge output.
|
||||
set_source_files_properties(Render.cpp PROPERTIES COMPILE_OPTIONS "-Wno-float-equal")
|
||||
target_compile_definitions(xtc-juce-render PRIVATE JUCE_GLOBAL_MODULE_SETTINGS_INCLUDED=1)
|
||||
target_link_libraries(xtc-juce-render PRIVATE XtcJuceSynth xtc-juce-c-core)
|
||||
enable_testing()
|
||||
add_test(NAME xtc-juce-processor COMMAND xtc-juce-render --self-test)
|
||||
|
||||
add_test(NAME xtc-juce-vst3 COMMAND xtc-juce-render --host-check "$<TARGET_FILE_DIR:XtcJuceSynth_VST3>/../..")
|
||||
97
examples/juce/Editor.cpp
Normal file
97
examples/juce/Editor.cpp
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
#include "Editor.h"
|
||||
XtcJuceEditor::XtcJuceEditor(XtcJuceProcessor& owner)
|
||||
: AudioProcessorEditor(owner),engine(owner)
|
||||
{
|
||||
const std::array<const char*,4> ids{{"gain","cutoff","room","wet"}};
|
||||
const std::array<const char*,4> names{{"OUTPUT / dB","TONE / Hz","ROOM SIZE","REVERB MIX"}};
|
||||
for (size_t i=0;i<sliders.size();++i) {
|
||||
auto& slider=sliders[i];
|
||||
slider.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag);
|
||||
slider.setTextBoxStyle(juce::Slider::TextBoxBelow,false,95,24);
|
||||
slider.setColour(juce::Slider::rotarySliderFillColourId,juce::Colour(0xff74dbc1));
|
||||
slider.setColour(juce::Slider::textBoxOutlineColourId,juce::Colours::transparentBlack);
|
||||
addAndMakeVisible(slider);
|
||||
labels[i].setText(names[i],juce::dontSendNotification);
|
||||
labels[i].setJustificationType(juce::Justification::centred); addAndMakeVisible(labels[i]);
|
||||
attachments[i]=std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(engine.parameters,ids[i],slider);
|
||||
slider.textFromValueFunction=[i](double value) { return juce::String(value,i==1 ? 0 : 2); };
|
||||
slider.updateText();
|
||||
}
|
||||
for (auto* button : {&save,&load,&panicButton}) addAndMakeVisible(*button);
|
||||
addAndMakeVisible(effects); addAndMakeVisible(status); addAndMakeVisible(keyboard);
|
||||
effectsAttachment=std::make_unique<juce::AudioProcessorValueTreeState::ButtonAttachment>(engine.parameters,"effects",effects);
|
||||
keyboardState.addListener(this);
|
||||
keyboard.setAvailableRange(36,96); keyboard.setLowestVisibleKey(48); keyboard.setKeyWidth(24);
|
||||
keyboard.setColour(juce::MidiKeyboardComponent::keyDownOverlayColourId,juce::Colour(0xff74dbc1));
|
||||
save.onClick=[this]{choosePreset(true);}; load.onClick=[this]{choosePreset(false);};
|
||||
panicButton.onClick=[this]{keyboardState.allNotesOff(0);engine.panic();};
|
||||
setSize(780,490); startTimerHz(30);
|
||||
}
|
||||
XtcJuceEditor::~XtcJuceEditor()
|
||||
{
|
||||
stopTimer(); keyboardState.removeListener(this); engine.panic();
|
||||
}
|
||||
void XtcJuceEditor::paint(juce::Graphics& graphics)
|
||||
{
|
||||
graphics.fillAll(juce::Colour(0xff101c23));
|
||||
graphics.setColour(juce::Colour(0xff74dbc1));
|
||||
graphics.setFont(juce::FontOptions(40.0f,juce::Font::bold)); graphics.drawText("xtc",24,15,100,55,juce::Justification::left);
|
||||
graphics.setColour(juce::Colour(0xffedf5f5));
|
||||
graphics.setFont(juce::FontOptions(23.0f)); graphics.drawText("Soft Keys",130,20,300,40,juce::Justification::left);
|
||||
graphics.setColour(juce::Colour(0xffa4b7c1)); graphics.setFont(juce::FontOptions(14.0f));
|
||||
graphics.drawText("Play. Shape. Explore.",132,58,420,25,juce::Justification::left);
|
||||
graphics.setColour(juce::Colour(0xff283a44)); graphics.fillRoundedRectangle(590,38,160,9,4);
|
||||
graphics.setColour(juce::Colour(0xff74dbc1)); graphics.fillRoundedRectangle(590,38,160*juce::jlimit(0.0f,1.0f,level),9,4);
|
||||
}
|
||||
void XtcJuceEditor::resized()
|
||||
{
|
||||
for (size_t i=0;i<sliders.size();++i) {
|
||||
const int x=20+static_cast<int>(i)*190;
|
||||
labels[i].setBounds(x,105,170,25); sliders[i].setBounds(x,135,170,150);
|
||||
}
|
||||
effects.setBounds(25,300,220,30); save.setBounds(380,300,120,30);
|
||||
load.setBounds(510,300,120,30); panicButton.setBounds(640,300,110,30);
|
||||
status.setBounds(25,340,725,30); keyboard.setBounds(20,385,740,85);
|
||||
}
|
||||
void XtcJuceEditor::timerCallback()
|
||||
{
|
||||
level=engine.peak(); repaint(580,30,180,25);
|
||||
const juce::String text=engine.processingFailed() ? "Audio preparation or processing failed; reopen the device to retry."
|
||||
: (engine.droppedEvents() ? "MIDI overflow: voices cleared. Reduce the event rate."
|
||||
: "Use the keyboard below or select a MIDI input in the standalone Audio/MIDI settings.");
|
||||
status.setText(presetStatus.isNotEmpty() ? presetStatus : text,juce::dontSendNotification);
|
||||
}
|
||||
void XtcJuceEditor::handleNoteOn(juce::MidiKeyboardState*,int channel,int note,float velocity)
|
||||
{ engine.enqueueNote(true,channel,note,velocity); }
|
||||
void XtcJuceEditor::handleNoteOff(juce::MidiKeyboardState*,int channel,int note,float velocity)
|
||||
{ engine.enqueueNote(false,channel,note,velocity); }
|
||||
void XtcJuceEditor::choosePreset(bool saving)
|
||||
{
|
||||
if (chooser) return;
|
||||
chooser=std::make_unique<juce::FileChooser>(saving ? "Save xtc JUCE preset" : "Load xtc JUCE preset",
|
||||
juce::File{},"*.xtcjuce");
|
||||
const int browserFlags=juce::FileBrowserComponent::canSelectFiles |
|
||||
(saving ? juce::FileBrowserComponent::saveMode | juce::FileBrowserComponent::warnAboutOverwriting
|
||||
: juce::FileBrowserComponent::openMode);
|
||||
chooser->launchAsync(browserFlags,[safe=juce::Component::SafePointer<XtcJuceEditor>(this),saving](const juce::FileChooser& dialog){
|
||||
if (!safe) return;
|
||||
const auto file=dialog.getResult();
|
||||
if (file!=juce::File{}) {
|
||||
if (saving) {
|
||||
juce::MemoryBlock state; safe->engine.getStateInformation(state);
|
||||
juce::TemporaryFile temporary(file);
|
||||
if (!temporary.getFile().replaceWithData(state.getData(),state.getSize()) ||
|
||||
!temporary.overwriteTargetFileWithTemporary())
|
||||
safe->presetStatus="Could not save preset.";
|
||||
else safe->presetStatus="Preset saved.";
|
||||
} else {
|
||||
juce::MemoryBlock state;
|
||||
if (file.getSize()>0 && file.getSize()<=65536 && file.loadFileAsData(state))
|
||||
safe->presetStatus=safe->engine.restorePreset(state.getData(),static_cast<int>(state.getSize()))
|
||||
? "Preset loaded." : "Invalid preset; current settings retained.";
|
||||
else safe->presetStatus="Could not read preset (limit 64 KiB).";
|
||||
}
|
||||
}
|
||||
safe->chooser.reset();
|
||||
});
|
||||
}
|
||||
30
examples/juce/Editor.h
Normal file
30
examples/juce/Editor.h
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
#pragma once
|
||||
#include "Processor.h"
|
||||
class XtcJuceEditor final : public juce::AudioProcessorEditor,
|
||||
private juce::MidiKeyboardState::Listener,
|
||||
private juce::Timer {
|
||||
public:
|
||||
explicit XtcJuceEditor(XtcJuceProcessor&);
|
||||
~XtcJuceEditor() override;
|
||||
void paint(juce::Graphics&) override;
|
||||
void resized() override;
|
||||
private:
|
||||
XtcJuceProcessor& engine;
|
||||
juce::MidiKeyboardState keyboardState;
|
||||
juce::MidiKeyboardComponent keyboard{keyboardState,juce::MidiKeyboardComponent::horizontalKeyboard};
|
||||
std::array<juce::Slider,4> sliders;
|
||||
std::array<juce::Label,4> labels;
|
||||
std::array<std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment>,4> attachments;
|
||||
juce::ToggleButton effects{"Filter + reverb"};
|
||||
std::unique_ptr<juce::AudioProcessorValueTreeState::ButtonAttachment> effectsAttachment;
|
||||
juce::TextButton save{"Save preset"},load{"Load preset"},panicButton{"Panic"};
|
||||
juce::Label status;
|
||||
juce::String presetStatus;
|
||||
std::unique_ptr<juce::FileChooser> chooser;
|
||||
float level=0;
|
||||
void handleNoteOn(juce::MidiKeyboardState*,int,int,float) override;
|
||||
void handleNoteOff(juce::MidiKeyboardState*,int,int,float) override;
|
||||
void timerCallback() override;
|
||||
void choosePreset(bool saving);
|
||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(XtcJuceEditor)
|
||||
};
|
||||
2
examples/juce/PluginEntry.cpp
Normal file
2
examples/juce/PluginEntry.cpp
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#include "Processor.h"
|
||||
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() { return new XtcJuceProcessor; }
|
||||
170
examples/juce/Processor.cpp
Normal file
170
examples/juce/Processor.cpp
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
#include "Processor.h"
|
||||
#include "Editor.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <charconv>
|
||||
#include <limits>
|
||||
|
||||
juce::AudioProcessorValueTreeState::ParameterLayout XtcJuceProcessor::layout()
|
||||
{
|
||||
juce::AudioProcessorValueTreeState::ParameterLayout result;
|
||||
result.add(std::make_unique<juce::AudioParameterFloat>(juce::ParameterID{"gain",1}, "Output (dB)", -60.0f, 6.0f, 0.0f));
|
||||
juce::NormalisableRange<float> frequency(20.0f,20000.0f);
|
||||
frequency.setSkewForCentre(1000.0f);
|
||||
result.add(std::make_unique<juce::AudioParameterFloat>(juce::ParameterID{"cutoff",1}, "Low-pass (Hz)", frequency, 8000.0f));
|
||||
result.add(std::make_unique<juce::AudioParameterFloat>(juce::ParameterID{"room",1}, "Room size", 0.0f, 1.0f, .5f));
|
||||
result.add(std::make_unique<juce::AudioParameterFloat>(juce::ParameterID{"wet",1}, "Reverb mix", 0.0f, 1.0f, .2f));
|
||||
result.add(std::make_unique<juce::AudioParameterBool>(juce::ParameterID{"effects",1}, "Enable filter + reverb", false));
|
||||
return result;
|
||||
}
|
||||
XtcJuceProcessor::XtcJuceProcessor()
|
||||
: AudioProcessor(BusesProperties().withOutput("Output",juce::AudioChannelSet::stereo(),true)),
|
||||
parameters(*this,nullptr,"XTC_JUCE_STATE",layout())
|
||||
{
|
||||
static_assert(std::atomic<float>::is_always_lock_free && std::atomic<bool>::is_always_lock_free &&
|
||||
std::atomic<unsigned int>::is_always_lock_free,"Realtime bridge requires lock-free atomics");
|
||||
parameters.state.setProperty("version",1,nullptr);
|
||||
gainDb=parameters.getRawParameterValue("gain"); cutoff=parameters.getRawParameterValue("cutoff");
|
||||
room=parameters.getRawParameterValue("room"); wet=parameters.getRawParameterValue("wet");
|
||||
effects=parameters.getRawParameterValue("effects");
|
||||
}
|
||||
XtcJuceProcessor::~XtcJuceProcessor() { releaseResources(); }
|
||||
bool XtcJuceProcessor::isBusesLayoutSupported(const BusesLayout& bus) const
|
||||
{
|
||||
return bus.getMainInputChannelSet().isDisabled() &&
|
||||
(bus.getMainOutputChannelSet()==juce::AudioChannelSet::mono() ||
|
||||
bus.getMainOutputChannelSet()==juce::AudioChannelSet::stereo());
|
||||
}
|
||||
void XtcJuceProcessor::prepareToPlay(double sampleRate, int maxBlock)
|
||||
{
|
||||
releaseResources();
|
||||
if (!std::isfinite(sampleRate) || sampleRate<8000 || sampleRate>192000 || maxBlock<1) {
|
||||
failed.store(true); return;
|
||||
}
|
||||
xtc_plugin_spec spec{};
|
||||
auto result=xtc_plugin_probe_format(XTC_PLUGIN_BUILTIN,XTC_BUILTIN_PATH,0,sampleRate,&spec);
|
||||
if (result==XTC_OK) result=xtc_plugin_create(&spec,sampleRate,chunkFrames,&synth);
|
||||
if (result!=XTC_OK) { failed.store(true); return; }
|
||||
rate=sampleRate;
|
||||
const juce::dsp::ProcessSpec processing{rate,chunkFrames,static_cast<juce::uint32>(getTotalNumOutputChannels())};
|
||||
filter.prepare(processing); filter.setType(juce::dsp::StateVariableTPTFilterType::lowpass);
|
||||
reverb.prepare(processing);
|
||||
gain.setRampDurationSeconds(.01); gain.setGainDecibels(gainDb->load()); gain.prepare(processing);
|
||||
filter.reset(); reverb.reset(); gain.reset();
|
||||
position=0; panicRequested.store(true); failed.store(false); prepared.store(true,std::memory_order_release);
|
||||
}
|
||||
void XtcJuceProcessor::releaseResources()
|
||||
{
|
||||
prepared.store(false,std::memory_order_release);
|
||||
xtc_plugin_destroy(synth); synth=nullptr;
|
||||
meter.store(0); position=0;
|
||||
}
|
||||
void XtcJuceProcessor::reset()
|
||||
{
|
||||
// Host contract: reset has exclusive processing ownership.
|
||||
xtc_plugin_reset_history(synth); filter.reset(); reverb.reset(); gain.reset(); position=0;
|
||||
}
|
||||
void XtcJuceProcessor::enqueueNote(bool on, int channel, int note, float velocity) noexcept
|
||||
{
|
||||
if (channel<1 || channel>16 || note<0 || note>127 || !std::isfinite(velocity)) return;
|
||||
int start1=0,size1=0,start2=0,size2=0;
|
||||
keyboardQueue.prepareToWrite(1,start1,size1,start2,size2);
|
||||
if (!size1) { dropped.fetch_add(1); panic(); return; }
|
||||
auto& e=keyboardEvents[static_cast<size_t>(start1)];
|
||||
e={0,on ? XTC_MIDI_NOTE_ON : XTC_MIDI_NOTE_OFF,static_cast<unsigned int>(channel-1),
|
||||
static_cast<unsigned int>(note),static_cast<unsigned int>(juce::jlimit(1,127,juce::roundToInt(velocity*127)))};
|
||||
keyboardQueue.finishedWrite(1);
|
||||
}
|
||||
void XtcJuceProcessor::discardKeyboard() noexcept
|
||||
{
|
||||
int start1=0,size1=0,start2=0,size2=0;
|
||||
keyboardQueue.prepareToRead(queueCapacity,start1,size1,start2,size2);
|
||||
keyboardQueue.finishedRead(size1+size2);
|
||||
}
|
||||
void XtcJuceProcessor::processBlock(juce::AudioBuffer<float>& audio, juce::MidiBuffer& midi)
|
||||
{
|
||||
juce::ScopedNoDenormals noDenormals;
|
||||
const int frames=audio.getNumSamples(),channels=audio.getNumChannels();
|
||||
audio.clear();
|
||||
if (!ready() || !synth || (channels!=1 && channels!=2)) { midi.clear(); return; }
|
||||
if (frames==0) { midi.clear(); return; }
|
||||
if (position>std::numeric_limits<uint64_t>::max()-static_cast<uint64_t>(frames)) reset();
|
||||
if (panicRequested.exchange(false,std::memory_order_acq_rel)) {
|
||||
discardKeyboard(); xtc_plugin_reset_history(synth);
|
||||
}
|
||||
gain.setGainDecibels(gainDb->load(std::memory_order_relaxed));
|
||||
filter.setCutoffFrequency(std::min(cutoff->load(std::memory_order_relaxed),static_cast<float>(rate*.45)));
|
||||
juce::Reverb::Parameters settings;
|
||||
settings.roomSize=room->load(std::memory_order_relaxed);
|
||||
settings.wetLevel=wet->load(std::memory_order_relaxed); settings.dryLevel=1-settings.wetLevel;
|
||||
reverb.setParameters(settings);
|
||||
const bool enableEffects=effects->load(std::memory_order_relaxed)>.5f;
|
||||
auto input=midi.cbegin();
|
||||
for (int base=0;base<frames;base+=chunkFrames) {
|
||||
const int length=std::min(chunkFrames,frames-base);
|
||||
size_t count=0;
|
||||
if (base==0) {
|
||||
int start1=0,size1=0,start2=0,size2=0;
|
||||
keyboardQueue.prepareToRead(queueCapacity,start1,size1,start2,size2);
|
||||
for (int i=0;i<size1;++i) events[count++]=keyboardEvents[static_cast<size_t>(start1+i)];
|
||||
for (int i=0;i<size2;++i) events[count++]=keyboardEvents[static_cast<size_t>(start2+i)];
|
||||
keyboardQueue.finishedRead(size1+size2);
|
||||
}
|
||||
while (input!=midi.cend() && (*input).samplePosition<base+length) {
|
||||
const auto message=*input++;
|
||||
if (message.samplePosition<base) continue;
|
||||
xtc_midi_event event{};
|
||||
if (xtc_midi_decode(message.data,static_cast<size_t>(message.numBytes),
|
||||
static_cast<unsigned int>(message.samplePosition-base),&event)!=XTC_OK) continue;
|
||||
if (count==events.size()) {
|
||||
dropped.fetch_add(1); xtc_plugin_reset_history(synth);
|
||||
audio.clear(); midi.clear(); meter.store(0); return;
|
||||
}
|
||||
events[count++]=event;
|
||||
}
|
||||
if (xtc_plugin_process_midi_at(synth,events.data(),count,scratch.data(),static_cast<size_t>(length),position)!=XTC_OK) {
|
||||
failed.store(true); xtc_plugin_reset_history(synth); audio.clear(); midi.clear(); meter.store(0); return;
|
||||
}
|
||||
for (int channel=0;channel<channels;++channel)
|
||||
juce::FloatVectorOperations::copy(audio.getWritePointer(channel,base),scratch.data(),length);
|
||||
auto block=juce::dsp::AudioBlock<float>(audio).getSubBlock(static_cast<size_t>(base),static_cast<size_t>(length));
|
||||
juce::dsp::ProcessContextReplacing<float> context(block);
|
||||
if (enableEffects) { filter.process(context); reverb.process(context); }
|
||||
gain.process(context);
|
||||
position+=static_cast<uint64_t>(length);
|
||||
}
|
||||
// Single atomics only; editor timer reads the last block's absolute peak.
|
||||
meter.store(audio.getMagnitude(0,frames),std::memory_order_relaxed);
|
||||
midi.clear();
|
||||
}
|
||||
void XtcJuceProcessor::getStateInformation(juce::MemoryBlock& destination)
|
||||
{
|
||||
const auto state=parameters.copyState();
|
||||
if (auto xml=state.createXml()) copyXmlToBinary(*xml,destination);
|
||||
}
|
||||
void XtcJuceProcessor::setStateInformation(const void* data, int bytes)
|
||||
{ (void)restorePreset(data,bytes); }
|
||||
bool XtcJuceProcessor::restorePreset(const void* data, int bytes)
|
||||
{
|
||||
if (!data || bytes<=0 || bytes>65536) return false;
|
||||
auto xml=getXmlFromBinary(data,bytes);
|
||||
if (!xml || !xml->hasTagName("XTC_JUCE_STATE")) return false;
|
||||
auto state=juce::ValueTree::fromXml(*xml);
|
||||
// Validate the entire candidate before APVTS can clamp or partially adopt it.
|
||||
if (!state.isValid() || state["version"].toString()!="1" || state.getNumChildren()!=5) return false;
|
||||
juce::StringArray seen;
|
||||
for (const auto& child : state) {
|
||||
const auto id=child["id"].toString();
|
||||
auto* parameter=parameters.getParameter(id);
|
||||
const auto encoded=child["value"].toString().toStdString();
|
||||
float value=0;
|
||||
const auto parsed=std::from_chars(encoded.data(),encoded.data()+encoded.size(),value);
|
||||
if (!child.hasType("PARAM") || !parameter || seen.contains(id) || !child.hasProperty("value") ||
|
||||
parsed.ec!=std::errc{} || parsed.ptr!=encoded.data()+encoded.size() ||
|
||||
!std::isfinite(value) || value<parameter->getNormalisableRange().start || value>parameter->getNormalisableRange().end ||
|
||||
(id=="effects" && value>0 && value<1)) return false;
|
||||
seen.add(id);
|
||||
}
|
||||
parameters.replaceState(state); panic(); return true;
|
||||
}
|
||||
juce::AudioProcessorEditor* XtcJuceProcessor::createEditor() { return new XtcJuceEditor(*this); }
|
||||
66
examples/juce/Processor.h
Normal file
66
examples/juce/Processor.h
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#pragma once
|
||||
#include <juce_audio_utils/juce_audio_utils.h>
|
||||
#include <juce_dsp/juce_dsp.h>
|
||||
#include "xtc/xtc_plugin.h"
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
|
||||
// JUCE owns host integration/UI/DSP; synthesis remains behind xtc's C API.
|
||||
class XtcJuceProcessor final : public juce::AudioProcessor {
|
||||
public:
|
||||
XtcJuceProcessor();
|
||||
~XtcJuceProcessor() override;
|
||||
const juce::String getName() const override { return "xtc JUCE Synth"; }
|
||||
void prepareToPlay(double, int) override;
|
||||
void releaseResources() override;
|
||||
bool isBusesLayoutSupported(const BusesLayout&) const override;
|
||||
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
|
||||
using juce::AudioProcessor::processBlock;
|
||||
void reset() override;
|
||||
bool acceptsMidi() const override { return true; }
|
||||
bool producesMidi() const override { return false; }
|
||||
double getTailLengthSeconds() const override { return 3.0; }
|
||||
bool hasEditor() const override { return true; }
|
||||
juce::AudioProcessorEditor* createEditor() override;
|
||||
int getNumPrograms() override { return 1; }
|
||||
int getCurrentProgram() override { return 0; }
|
||||
void setCurrentProgram(int) override {}
|
||||
const juce::String getProgramName(int) override { return "Default"; }
|
||||
void changeProgramName(int, const juce::String&) override {}
|
||||
void getStateInformation(juce::MemoryBlock&) override;
|
||||
void setStateInformation(const void*, int) override;
|
||||
bool restorePreset(const void*, int);
|
||||
|
||||
juce::AudioProcessorValueTreeState parameters;
|
||||
// One GUI producer; one processBlock consumer. Never calls MidiKeyboardState
|
||||
// or its locks on the audio thread. Overflow requests panic instead of stuck notes.
|
||||
void enqueueNote(bool on, int channel, int note, float velocity) noexcept;
|
||||
void panic() noexcept { panicRequested.store(true, std::memory_order_release); }
|
||||
float peak() const noexcept { return meter.load(std::memory_order_relaxed); }
|
||||
unsigned int droppedEvents() const noexcept { return dropped.load(std::memory_order_relaxed); }
|
||||
bool ready() const noexcept { return prepared.load(std::memory_order_acquire); }
|
||||
bool processingFailed() const noexcept { return failed.load(std::memory_order_relaxed); }
|
||||
private:
|
||||
static juce::AudioProcessorValueTreeState::ParameterLayout layout();
|
||||
static constexpr int chunkFrames = 256, queueCapacity = 128;
|
||||
xtc_plugin* synth = nullptr;
|
||||
std::array<float, chunkFrames> scratch{};
|
||||
std::array<xtc_midi_event, XTC_MIDI_MAX_EVENTS> events{};
|
||||
std::array<xtc_midi_event, queueCapacity> keyboardEvents{};
|
||||
juce::AbstractFifo keyboardQueue{queueCapacity};
|
||||
juce::dsp::StateVariableTPTFilter<float> filter;
|
||||
juce::dsp::Reverb reverb;
|
||||
juce::dsp::Gain<float> gain;
|
||||
std::atomic<float>* gainDb = nullptr;
|
||||
std::atomic<float>* cutoff = nullptr;
|
||||
std::atomic<float>* room = nullptr;
|
||||
std::atomic<float>* wet = nullptr;
|
||||
std::atomic<float>* effects = nullptr;
|
||||
std::atomic<float> meter{0};
|
||||
std::atomic<unsigned int> dropped{0};
|
||||
std::atomic<bool> panicRequested{false}, prepared{false}, failed{false};
|
||||
double rate = 48000;
|
||||
uint64_t position = 0;
|
||||
void discardKeyboard() noexcept;
|
||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(XtcJuceProcessor)
|
||||
};
|
||||
96
examples/juce/README.md
Normal file
96
examples/juce/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# JUCE + xtc examples
|
||||
|
||||
These optional examples use an external **JUCE 9.0.4** checkout (qualified
|
||||
revision `3fb5cbc0340758596dac39f7c8de47d562b4896e`). JUCE is AGPLv3/commercial;
|
||||
a commercial licence may be required for distribution. Read
|
||||
[the integration/license details](../../docs/JUCE.md). Original xtc example code
|
||||
is MIT; JUCE and combined binaries retain their applicable licence obligations.
|
||||
No SDK or linked binary is copied into normal xtc packages.
|
||||
|
||||
## Build and test (Linux)
|
||||
|
||||
Supply JUCE explicitly, for example in `/your/path/JUCE`. With CMake >= 3.25,
|
||||
GNU Make, a C/C++17 toolchain and JUCE's Linux development prerequisites:
|
||||
|
||||
```sh
|
||||
make JUCE_ROOT=/your/path/JUCE juce-examples
|
||||
make JUCE_ROOT=/your/path/JUCE test-juce
|
||||
```
|
||||
|
||||
The build never downloads JUCE, installs dependencies or copies plugins into
|
||||
user plugin directories. It builds its own C99 PIC archive with JACK/FLTK disabled
|
||||
under `build/juce/c-core`. `test-juce` also requires Xvfb and runs the processor
|
||||
and JUCE-host checks, then loads the built instrument through xtc's C VST3 host.
|
||||
Normal `make`, `make gui` and `make test` remain JUCE-independent.
|
||||
|
||||
Build products:
|
||||
|
||||
- `build/juce/XtcJuceSynth_artefacts/Release/VST3/xtc JUCE Synth.vst3`
|
||||
- `build/juce/XtcJuceSynth_artefacts/Release/Standalone/xtc JUCE Synth`
|
||||
- `build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render`
|
||||
|
||||
Set `BUILD_DIR=...` to move the main build root, or `JUCE_BUILD_DIR=...` to select
|
||||
only the SDK example build directory.
|
||||
|
||||
## Play and shape Soft Keys
|
||||
|
||||
```sh
|
||||
"./build/juce/XtcJuceSynth_artefacts/Release/Standalone/xtc JUCE Synth"
|
||||
```
|
||||
|
||||
The standalone opens JUCE's audio/MIDI device interface. Select the intended
|
||||
output and MIDI inputs explicitly. Play its on-screen keyboard or a selected
|
||||
MIDI source. Enable **Filter + reverb**, adjust Tone/Room/Mix, and set Output.
|
||||
Save/load `.xtcjuce` presets; Panic clears held voices. The native editor is also
|
||||
available when loading the VST3 into a host with editor support. xtc's existing
|
||||
C host supports its default instrument configuration, without the JUCE editor.
|
||||
Building/testing these examples does not open a physical audio device. Running
|
||||
the standalone may open the system default device; hardware playback is not
|
||||
inferred from successful builds.
|
||||
|
||||
## Render a chord or MIDI file without an audio server
|
||||
|
||||
```sh
|
||||
./build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render --render /absolute/new-demo.wav
|
||||
./build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render --render /absolute/new-song.wav --midi /absolute/song.mid
|
||||
```
|
||||
|
||||
The output is 48 kHz stereo float32 WAV with filter/reverb enabled and -6 dB
|
||||
output. The default renders a staggered C-major chord. MIDI input is type 0/1
|
||||
PPQN, including its tempo map, merged stably across tracks. Supported note,
|
||||
CC and bend messages use rounded sample positions; other messages are ignored.
|
||||
Input limits: 4 MiB, 100000 supported events and ten minutes of event timestamps.
|
||||
The renderer allows half a second after the last event, forces all-notes-off,
|
||||
then renders a fixed three-second tail. It does not derive exact plugin tails.
|
||||
|
||||
Output is written to a same-directory temporary file and atomically linked to
|
||||
an absent destination. Existing files are preserved, including a concurrent
|
||||
creator at publication. Errors remove the temporary and publish no output.
|
||||
On a machine without an X server, prefix commands with `xvfb-run -a`.
|
||||
|
||||
## Inspect/host a trusted VST3
|
||||
|
||||
```sh
|
||||
./build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render --inspect-vst3 /absolute/Plugin.vst3
|
||||
./build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render --host-check "$PWD/build/juce/XtcJuceSynth_artefacts/Release/VST3/xtc JUCE Synth.vst3"
|
||||
```
|
||||
|
||||
Inspection loads only the named module, enumerates its classes and instantiates
|
||||
them to list parameters and channels. It executes trusted code and is not an
|
||||
isolated scanner. `--host-check` is specific to the example: it checks five
|
||||
parameters, opaque state restoration and actual MIDI processing through JUCE's
|
||||
VST3 host. These operations do not start an audio device.
|
||||
|
||||
## Regression checks and an actual editor image
|
||||
|
||||
```sh
|
||||
xvfb-run -a ctest --test-dir build/juce --output-on-failure
|
||||
xvfb-run -a ./build/juce/xtc-juce-render_artefacts/Release/xtc-juce-render --snapshot /absolute/new-editor.png
|
||||
```
|
||||
|
||||
The processor test checks exact C-synth output, note timing, variable blocks,
|
||||
mono/stereo, reset, host/GUI MIDI overflow, preset recovery and effect processing.
|
||||
The snapshot command renders the actual editor to a 1560 × 980 PNG and checks
|
||||
that it can close/reopen without starting audio hardware. It refuses to overwrite
|
||||
a destination. Windows/macOS, alternate plugin formats and physical devices have
|
||||
not been qualified. See [Phase 107](../../docs/ROADMAP.md) for recorded results.
|
||||
243
examples/juce/Render.cpp
Normal file
243
examples/juce/Render.cpp
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
#include "Processor.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace {
|
||||
void require(bool condition,const char* message) { if (!condition) throw std::runtime_error(message); }
|
||||
void set(XtcJuceProcessor& p,const juce::String& id,float value)
|
||||
{
|
||||
auto* parameter=p.parameters.getParameter(id); require(parameter!=nullptr,"Unknown parameter");
|
||||
parameter->setValueNotifyingHost(parameter->convertTo0to1(value));
|
||||
}
|
||||
void prepare(XtcJuceProcessor& processor,int channels=2,double rate=48000)
|
||||
{
|
||||
processor.setPlayConfigDetails(0,channels,rate,256); processor.prepareToPlay(rate,256);
|
||||
require(processor.ready(),"Could not prepare xtc synth");
|
||||
// Consume preparation panic before callers enqueue keyboard notes.
|
||||
juce::AudioBuffer<float> silence(channels,1); juce::MidiBuffer empty;
|
||||
processor.processBlock(silence,empty); processor.reset();
|
||||
}
|
||||
void selfTest()
|
||||
{
|
||||
XtcJuceProcessor processor; prepare(processor);
|
||||
juce::AudioBuffer<float> audio(2,513); juce::MidiBuffer midi;
|
||||
midi.addEvent(juce::MidiMessage::noteOn(3,69,static_cast<juce::uint8>(110)),13);
|
||||
midi.addEvent(juce::MidiMessage::noteOff(3,69),401);
|
||||
midi.addEvent(juce::MidiMessage::allNotesOff(3),480);
|
||||
processor.processBlock(audio,midi);
|
||||
require(!processor.processingFailed() && midi.isEmpty(),"Callback failed or produced unexpected MIDI");
|
||||
for (int i=0;i<13;++i) require(audio.getSample(0,i)==0,"Note played early");
|
||||
require(audio.getMagnitude(20,380)>.02f,"Note did not sound");
|
||||
for (int i=480;i<513;++i) require(audio.getSample(0,i)==0,"All notes off was not sample aligned");
|
||||
xtc_plugin_spec spec{}; xtc_plugin* reference=nullptr;
|
||||
require(xtc_plugin_probe_format(XTC_PLUGIN_BUILTIN,XTC_BUILTIN_PATH,0,48000,&spec)==XTC_OK,"Reference probe failed");
|
||||
require(xtc_plugin_create(&spec,48000,256,&reference)==XTC_OK,"Reference prepare failed");
|
||||
std::array<float,256> expected{};
|
||||
const std::array<xtc_midi_event,3> source{{{13,XTC_MIDI_NOTE_ON,2,69,110},
|
||||
{401,XTC_MIDI_NOTE_OFF,2,69,0},{480,XTC_MIDI_ALL_OFF,2,0,0}}};
|
||||
for (int base=0;base<513;base+=256) {
|
||||
std::array<xtc_midi_event,3> block{}; size_t count=0;
|
||||
const int length=std::min(256,513-base);
|
||||
for (const auto& event : source) if (event.offset>=static_cast<unsigned int>(base) && event.offset<static_cast<unsigned int>(base+length)) {
|
||||
block[count]=event; block[count++].offset-=static_cast<unsigned int>(base);
|
||||
}
|
||||
require(xtc_plugin_process_midi_at(reference,block.data(),count,expected.data(),static_cast<size_t>(length),static_cast<uint64_t>(base))==XTC_OK,"Reference render failed");
|
||||
for (int i=0;i<length;++i) for (int channel=0;channel<2;++channel)
|
||||
require(audio.getSample(channel,base+i)==expected[static_cast<size_t>(i)],"JUCE bridge changed the C synth output");
|
||||
}
|
||||
xtc_plugin_destroy(reference);
|
||||
processor.reset(); midi.clear(); processor.processBlock(audio,midi);
|
||||
require(audio.getMagnitude(0,audio.getNumSamples())==0,"Reset retained held voices");
|
||||
// GUI events use a lock-free queue; its overflow must never leave a held note.
|
||||
processor.enqueueNote(true,1,60,1); processor.processBlock(audio,midi);
|
||||
require(audio.getMagnitude(0,513)>0,"Keyboard queue did not sound");
|
||||
for (int i=0;i<300;++i) processor.enqueueNote(true,1,60,1);
|
||||
processor.processBlock(audio,midi);
|
||||
require(processor.droppedEvents()>0 && audio.getMagnitude(0,513)==0,"GUI overflow did not panic safely");
|
||||
for (int i=0;i<300;++i) midi.addEvent(juce::MidiMessage::noteOn(1,60,static_cast<juce::uint8>(127)),0);
|
||||
processor.processBlock(audio,midi);
|
||||
require(audio.getMagnitude(0,513)==0,"Host MIDI overflow emitted a partial block");
|
||||
processor.processBlock(audio,midi);
|
||||
require(audio.getMagnitude(0,513)==0,"Host MIDI overflow left stuck voices");
|
||||
// Host-sized blocks may exceed the prepared internal chunk size.
|
||||
XtcJuceProcessor whole,split; prepare(whole); prepare(split);
|
||||
juce::AudioBuffer<float> large(2,4097); midi.addEvent(juce::MidiMessage::noteOn(1,65,static_cast<juce::uint8>(100)),0);
|
||||
whole.processBlock(large,midi);
|
||||
for (int base=0;base<4097;base+=256) {
|
||||
const int length=std::min(256,4097-base); juce::AudioBuffer<float> part(2,length);
|
||||
if (base==0) midi.addEvent(juce::MidiMessage::noteOn(1,65,static_cast<juce::uint8>(100)),0);
|
||||
split.processBlock(part,midi);
|
||||
for (int i=0;i<length;++i) require(large.getSample(0,base+i)==part.getSample(0,i),"Block-size-dependent synthesis");
|
||||
}
|
||||
// Roundtrip all parameters, reject malformed/incomplete/out-of-range presets.
|
||||
set(processor,"gain",-12); set(processor,"cutoff",2300); set(processor,"effects",1);
|
||||
juce::MemoryBlock state; processor.getStateInformation(state);
|
||||
XtcJuceProcessor restored; restored.setStateInformation(state.getData(),static_cast<int>(state.getSize()));
|
||||
require(restored.parameters.getRawParameterValue("gain")->load()==-12 &&
|
||||
restored.parameters.getRawParameterValue("effects")->load()==1,"Preset did not restore");
|
||||
auto reject=[&](juce::ValueTree candidate) {
|
||||
juce::MemoryBlock corrupt; auto xml=candidate.createXml(); juce::AudioProcessor::copyXmlToBinary(*xml,corrupt);
|
||||
restored.setStateInformation(corrupt.getData(),static_cast<int>(corrupt.getSize()));
|
||||
require(restored.parameters.getRawParameterValue("gain")->load()==-12,"Invalid state partially changed parameters");
|
||||
};
|
||||
auto candidate=processor.parameters.copyState(); candidate.setProperty("version",99,nullptr); reject(candidate);
|
||||
candidate=processor.parameters.copyState(); candidate.removeChild(0,nullptr); reject(candidate);
|
||||
candidate=processor.parameters.copyState(); candidate.getChild(0).setProperty("value",999999,nullptr); reject(candidate);
|
||||
candidate=processor.parameters.copyState(); candidate.getChild(0).setProperty("value","not-a-number",nullptr); reject(candidate);
|
||||
restored.setStateInformation("bad",3); require(restored.parameters.getRawParameterValue("gain")->load()==-12,"Malformed state accepted");
|
||||
prepare(restored,1,44100); midi.addEvent(juce::MidiMessage::noteOn(1,60,static_cast<juce::uint8>(100)),0);
|
||||
juce::AudioBuffer<float> mono(1,256); restored.processBlock(mono,midi);
|
||||
for (int i=0;i<256;++i) require(std::isfinite(mono.getSample(0,i)),"DSP produced nonfinite samples");
|
||||
restored.releaseResources(); mono.applyGain(0); midi.clear(); restored.processBlock(mono,midi);
|
||||
require(mono.getMagnitude(0,256)==0,"Released processor did not silence");
|
||||
restored.prepareToPlay(0,256); require(!restored.ready() && restored.processingFailed(),"Invalid format accepted");
|
||||
std::cout << "JUCE bridge: exact C synthesis, MIDI offsets, stereo/mono, variable blocks, reset, overflow, preset recovery and DSP passed.\n";
|
||||
}
|
||||
struct Scheduled { int64_t frame; juce::MidiMessage message; };
|
||||
std::vector<Scheduled> readMidi(const juce::File& path)
|
||||
{
|
||||
require(path.existsAsFile() && path.getSize()<=4*1024*1024,"MIDI file missing or larger than 4 MiB");
|
||||
juce::FileInputStream input(path); juce::MidiFile file; int fileType=-1;
|
||||
require(input.openedOk() && file.readFrom(input,false,&fileType),"Cannot read Standard MIDI File");
|
||||
require(fileType==0 || fileType==1,"Only type 0/1 MIDI files are supported");
|
||||
require(file.getTimeFormat()>0,"Only PPQN MIDI files are supported");
|
||||
file.convertTimestampTicksToSeconds();
|
||||
std::vector<Scheduled> events;
|
||||
for (int track=0;track<file.getNumTracks();++track) {
|
||||
const auto* sequence=file.getTrack(track);
|
||||
for (int i=0;i<sequence->getNumEvents();++i) {
|
||||
const auto& message=sequence->getEventPointer(i)->message;
|
||||
if (!(message.isNoteOnOrOff() || message.isController() || message.isPitchWheel())) continue;
|
||||
const double seconds=message.getTimeStamp();
|
||||
require(std::isfinite(seconds) && seconds>=0 && seconds<=600,"MIDI time exceeds ten-minute example limit");
|
||||
require(events.size()<100000,"MIDI example limit: 100000 events");
|
||||
events.push_back({static_cast<int64_t>(std::llround(seconds*48000)),message});
|
||||
}
|
||||
}
|
||||
std::stable_sort(events.begin(),events.end(),[](const auto& a,const auto& b){return a.frame<b.frame;});
|
||||
return events;
|
||||
}
|
||||
void inspectPlugin(const juce::File& bundle,bool verifyExample)
|
||||
{
|
||||
require(bundle.exists(),"Selected VST3 bundle does not exist");
|
||||
juce::AudioPluginFormatManager manager;
|
||||
auto format=std::make_unique<juce::VST3PluginFormat>();
|
||||
auto* vst3=format.get(); manager.addFormat(std::move(format));
|
||||
juce::OwnedArray<juce::PluginDescription> descriptions;
|
||||
vst3->findAllTypesForFile(descriptions,bundle.getFullPathName());
|
||||
require(!descriptions.isEmpty(),"No VST3 audio classes found");
|
||||
for (const auto* description : descriptions) {
|
||||
juce::String error;
|
||||
auto instance=manager.createPluginInstance(*description,48000,256,error);
|
||||
require(instance!=nullptr,error.isNotEmpty() ? error.toRawUTF8() : "Cannot instantiate VST3");
|
||||
std::cout << description->name << " | " << description->manufacturerName << " | " << description->version
|
||||
<< " | " << instance->getTotalNumInputChannels() << " inputs / " << instance->getTotalNumOutputChannels()
|
||||
<< " outputs | " << instance->getParameters().size() << " parameters\n";
|
||||
for (auto* parameter : instance->getParameters())
|
||||
std::cout << " " << parameter->getName(64) << " = " << parameter->getValue() << '\n';
|
||||
if (verifyExample) {
|
||||
require(instance->getParameters().size()>=5 && description->isInstrument,"Not the xtc JUCE example");
|
||||
juce::MemoryBlock state; instance->getStateInformation(state);
|
||||
require(state.getSize()>0,"JUCE host did not capture VST3 state");
|
||||
auto* output=instance->getParameters().getFirst(); const float original=output->getValue();
|
||||
output->setValueNotifyingHost(.3f);
|
||||
instance->setStateInformation(state.getData(),static_cast<int>(state.getSize()));
|
||||
require(std::abs(output->getValue()-original)<.00001f,"VST3 opaque-state roundtrip failed");
|
||||
instance->setPlayConfigDetails(0,2,48000,256); instance->prepareToPlay(48000,256);
|
||||
juce::AudioBuffer<float> audio(2,256); juce::MidiBuffer midi;
|
||||
midi.addEvent(juce::MidiMessage::noteOn(1,69,static_cast<juce::uint8>(110)),13);
|
||||
midi.addEvent(juce::MidiMessage::allNotesOff(1),200);
|
||||
instance->processBlock(audio,midi);
|
||||
for (int i=0;i<13;++i) require(audio.getSample(0,i)==0,"VST3 note played early");
|
||||
require(audio.getMagnitude(13,187)>.005f,"JUCE-hosted VST3 produced silence");
|
||||
for (int i=200;i<256;++i) require(audio.getSample(0,i)==0,"VST3 all-off offset failed");
|
||||
instance->releaseResources();
|
||||
}
|
||||
}
|
||||
if (verifyExample) std::cout << "JUCE VST3 hosting: discovery, parameters, opaque-state roundtrip and MIDI processing passed.\n";
|
||||
}
|
||||
void snapshot(const juce::File& destination)
|
||||
{
|
||||
require(!destination.exists(),"Snapshot already exists");
|
||||
XtcJuceProcessor processor; prepare(processor);
|
||||
set(processor,"effects",1); set(processor,"gain",-6);
|
||||
std::unique_ptr<juce::AudioProcessorEditor> editor(processor.createEditor());
|
||||
require(editor && editor->getWidth()==780 && editor->getHeight()==490,"Editor layout failed");
|
||||
const auto image=editor->createComponentSnapshot(editor->getLocalBounds(),true,2.0f);
|
||||
require(image.isValid(),"Editor snapshot failed");
|
||||
juce::TemporaryFile temporary(destination); juce::PNGImageFormat png;
|
||||
{ juce::FileOutputStream stream(temporary.getFile()); require(stream.openedOk() && png.writeImageToStream(image,stream),"PNG encoding failed"); }
|
||||
require(::link(temporary.getFile().getFullPathName().toRawUTF8(),destination.getFullPathName().toRawUTF8())==0,"PNG publication failed");
|
||||
editor.reset(); editor.reset(processor.createEditor());
|
||||
require(editor!=nullptr,"Editor could not reopen");
|
||||
std::cout << "Saved actual JUCE editor snapshot without opening an audio device.\n";
|
||||
}
|
||||
void render(const juce::File& destination,const juce::File& midiFile)
|
||||
{
|
||||
require(!destination.exists(),"Output already exists; choose a new WAV path");
|
||||
auto scheduled=midiFile==juce::File{} ? std::vector<Scheduled>{
|
||||
{0,juce::MidiMessage::noteOn(1,60,static_cast<juce::uint8>(100))},
|
||||
{12000,juce::MidiMessage::noteOn(1,64,static_cast<juce::uint8>(95))},
|
||||
{24000,juce::MidiMessage::noteOn(1,67,static_cast<juce::uint8>(90))},
|
||||
{48000,juce::MidiMessage::allNotesOff(1)}} : readMidi(midiFile);
|
||||
require(!scheduled.empty(),"MIDI file has no supported events");
|
||||
const int64_t total=scheduled.back().frame+24000+3*48000;
|
||||
XtcJuceProcessor processor; set(processor,"effects",1); set(processor,"gain",-6); prepare(processor);
|
||||
auto name=(destination.getParentDirectory().getFullPathName()+"/.xtc-juce-XXXXXX").toStdString();
|
||||
std::vector<char> temporary(name.begin(),name.end()); temporary.push_back(0);
|
||||
const int fd=mkstemp(temporary.data()); require(fd>=0,"Cannot create output temporary file");
|
||||
close(fd);
|
||||
struct Cleanup { juce::File file; ~Cleanup(){file.deleteFile();} } cleanup{juce::File(temporary.data())};
|
||||
std::unique_ptr<juce::OutputStream> stream=std::make_unique<juce::FileOutputStream>(cleanup.file);
|
||||
juce::WavAudioFormat format;
|
||||
auto writer=format.createWriterFor(stream,juce::AudioFormatWriterOptions{}.withSampleRate(48000)
|
||||
.withNumChannels(2).withBitsPerSample(32).withSampleFormat(juce::AudioFormatWriterOptions::SampleFormat::floatingPoint));
|
||||
require(writer!=nullptr,"Cannot open WAV writer");
|
||||
size_t index=0;
|
||||
for (int64_t base=0;base<total;base+=256) {
|
||||
const int length=static_cast<int>(std::min<int64_t>(256,total-base));
|
||||
juce::AudioBuffer<float> audio(2,length); juce::MidiBuffer midi;
|
||||
while (index<scheduled.size() && scheduled[index].frame<base+length) {
|
||||
midi.addEvent(scheduled[index].message,static_cast<int>(scheduled[index].frame-base)); ++index;
|
||||
}
|
||||
// Force release at the start of the fixed tail, including files with missing note-offs.
|
||||
if (base<=total-3*48000 && base+length>total-3*48000)
|
||||
for (int channel=1;channel<=16;++channel)
|
||||
midi.addEvent(juce::MidiMessage::allNotesOff(channel),static_cast<int>(total-3*48000-base));
|
||||
processor.processBlock(audio,midi);
|
||||
require(!processor.processingFailed(),"xtc processing failed");
|
||||
require(processor.droppedEvents()==0,"MIDI density exceeds 256 supported events per 256-frame chunk");
|
||||
require(writer->writeFromAudioSampleBuffer(audio,0,length),"WAV write failed");
|
||||
}
|
||||
require(writer->flush(),"WAV finalization failed");
|
||||
writer.reset();
|
||||
// Atomic no-clobber publication on the qualified POSIX platform.
|
||||
require(::link(cleanup.file.getFullPathName().toRawUTF8(),destination.getFullPathName().toRawUTF8())==0,
|
||||
"Cannot publish WAV (destination may already exist)");
|
||||
std::cout << "Rendered " << total << " stereo float32 frames at 48000 Hz to " << destination.getFullPathName() << '\n';
|
||||
}
|
||||
}
|
||||
int main(int argc,char** argv)
|
||||
{
|
||||
try {
|
||||
juce::ScopedJuceInitialiser_GUI juceRuntime;
|
||||
if (argc==2 && juce::String(argv[1])=="--self-test") { selfTest(); return 0; }
|
||||
if (argc==3 && (juce::String(argv[1])=="--inspect-vst3" || juce::String(argv[1])=="--host-check")) {
|
||||
inspectPlugin(juce::File::getCurrentWorkingDirectory().getChildFile(argv[2]),juce::String(argv[1])=="--host-check"); return 0;
|
||||
}
|
||||
if (argc==3 && juce::String(argv[1])=="--snapshot") {
|
||||
snapshot(juce::File::getCurrentWorkingDirectory().getChildFile(argv[2])); return 0;
|
||||
}
|
||||
if ((argc==3 || argc==5) && juce::String(argv[1])=="--render") {
|
||||
require(argc==3 || juce::String(argv[3])=="--midi","Expected --midi FILE.mid");
|
||||
render(juce::File::getCurrentWorkingDirectory().getChildFile(argv[2]),
|
||||
argc==5 ? juce::File::getCurrentWorkingDirectory().getChildFile(argv[4]) : juce::File{});
|
||||
return 0;
|
||||
}
|
||||
std::cerr << "Usage: xtc-juce-render --self-test | --snapshot NEW.png | --render NEW.wav [--midi FILE.mid] | --inspect-vst3 BUNDLE | --host-check EXAMPLE_BUNDLE\n"; return 2;
|
||||
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue