Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions DEVELOPMENT.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions HALION-LUA.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,12 @@ ctx.progress(done, total, message)
- `ctx.path_join(root, rel)`: joins paths using forward slashes.
- `ctx.save_preset(path, object, preset_type)`: wraps HALion `savePreset`; defaults `preset_type` to `"H7"`.
- `ctx.log(message)`: prints a build script log line and writes a host-readable progress marker. halionbridge treats build script log and progress lines as `info` output, so they remain visible at the default `HALIONBRIDGE_LOGLEVEL=info`. Very long messages are shortened in the marker filename; keep essential context near the start of the message.
- `ctx.progress(done, total, message)`: prints generic build script progress such as `Progress 12/48 ( 25%) - Building zones` and writes the same message through the host-readable progress marker channel. Numeric fields are padded so the message after ` - ` starts at a stable column within the same progress group. Very long messages are shortened in the marker filename; keep essential context near the start of the message.
- `ctx.progress(done, total, message)`: writes the message text through the host-readable progress marker channel. The `done` and `total` fields are currently accepted for compatibility but not printed by the builder because synchronous HALion global/module execution makes numeric progress bursts misleading. Very long messages are shortened in the marker filename; keep essential context near the start of the message.

`ctx.progress` is build-script-defined progress. `done` and `total` can represent files, zones, presets, phases, or any other work unit meaningful to that build script.

Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms when HALion exposes `getScriptExecTimeOut()` and `setScriptExecTimeOut()`, then restores the previous value after writing the final status marker. Build scripts should not call HALion `wait()` for this purpose; HALion only allows `wait()` inside callbacks, while build script entrypoints run as controller global/module code.

## Build Script Result

Build scripts must return a result table:
Expand All @@ -118,13 +120,15 @@ If a build script throws an error, returns an invalid entrypoint, or returns an

- loading `halionbridge_build.lua`
- loading and invoking the host-selected build script module slice in order
- printing and forwarding file-level progress such as `Processing x.lua` and `Progress x/y files (70%)`
- printing and forwarding file-level progress such as `Processing 12/48: x.lua` and `Completed 12/48 files (25%)`
- passing `ctx` to each build script
- aggregating build script results
- writing `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset`

`builder.lua` must not assume build script fields or build shapes such as `articulations`, `slots`, `wav`, velocity layers, round robin, key mapping, program presets, layer presets, sample zones, or output filenames.

Console timestamps are host observation times. Because current build script entrypoints run as synchronous HALion global/module code, the host may log progress markers in bursts after HALion returns control instead of at the exact moment `ctx.progress()` was called.

## Example: Saving a Layer Preset

```lua
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ The build directory must contain `halionbridge_build.lua` and the Lua build scri
# By default, halionbridge waits forever and prints a warning. Set a timeout when desired.
./halionbridge /path/to/build-directory --timeout-seconds 3600

# Build scripts run one script per HALion process by default and continue after failed chunks.
# Tune chunk size or stop after the first failed chunk when desired.
# Build scripts run up to 15 scripts per HALion process by default and continue after failed chunks.
# Use --build-chunk-size 1 for maximum isolation, or tune chunk size when desired.
./halionbridge /path/to/build-directory --build-chunk-size 30
./halionbridge /path/to/build-directory --fail-fast

Expand All @@ -107,12 +107,14 @@ halionbridge prints timestamped console logs. The default log level is `info`, w

Only one halionbridge build can run at a time for a user account. HALion resolves temporary runtime modules from the shared HALion user script directory, so a second overlapping run exits with a clear error instead of corrupting build output.

Press Ctrl+C to request a graceful stop. Conversion commands stop at converter checkpoints before writing more generated files. HALion builds stop as soon as control returns from the currently running HALion script or between chunks, then normal cleanup runs.

## Features
- **Headless Execution:** Runs as a standalone console app.
- **Embedded Bootstrap VSTPreset:** Applies the bundled HALion bootstrap preset internally; users only pass a build directory.
- **Offline Processing Loop:** Runs a manual processing loop without opening an audio device, to keep HALion alive while LUA instrument scripts execute.
- **Generic HALion Lua Build Scripts:** The embedded builder loads build script modules listed in `halionbridge_build.lua`; the build script modules decide what to build.
- **Converter Setup Commands:** `halionbridge convert sfz` creates a normal halionbridge build directory from `.sfz` files without launching HALion. When no output directory is provided, generated Lua/build files are written flat into the source directory; an explicit output directory can still be passed. The generated Lua build scripts can be reviewed or edited before running `halionbridge <build-directory>`. Converter-generated Lua filenames are kept inside the build root; unsafe generated paths are rejected. Converter-generated helper modules may be written beside build scripts without being listed in `halionbridge_build.lua`.
- **Build Completion Detection:** Runs static `halionbridge_build.lua` lists one script per HALion process by default, relaunching HALion for each chunk so later chunks can continue after a failed chunk. Waits for HALion-written `.vstpreset` status marker presets in the build directory in order to know when each chunk is finished. Temporary progress marker presets are cleaned after logging; failed status markers may remain after failed builds for diagnostics.
- **Build Completion Detection:** Runs static `halionbridge_build.lua` lists in chunks of up to 15 scripts per HALion process by default, relaunching HALion for each chunk so later chunks can continue after a failed chunk. Waits for HALion-written `.vstpreset` status marker presets in the build directory in order to know when each chunk is finished. Temporary progress marker presets are cleaned after logging; failed status markers may remain after failed builds for diagnostics.

See `HALION-LUA.md` for the Lua build script API used by the generic builder workflow.
17 changes: 11 additions & 6 deletions cli/Main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ void printHelp()
<< "\n"
<< "Runtime options:\n"
<< " --timeout-seconds <n> Build completion timeout. Defaults to 0, which waits forever.\n"
<< " --build-chunk-size <n> Number of Lua build scripts per HALion run. Defaults to 1.\n"
<< " --build-chunk-size <n> Number of Lua build scripts per HALion run. Defaults to 15.\n"
<< " --fail-fast Stop after the first failed Lua build chunk.\n"
<< " --gui Use JUCE's GUI-capable VST3 host format and show HALion's editor.\n"
<< " --nokill Keep HALion loaded after build completion or failure for inspection.\n"
Expand Down Expand Up @@ -192,7 +192,8 @@ int runConvertCommand(std::span<const std::string> args)
if (converter->runWithContext != nullptr)
{
auto context = halionbridge::converters::ConverterRunContext{[](const halionbridge::converters::Diagnostic& diagnostic, void*)
{ logDiagnostic(diagnostic); }, nullptr};
{ logDiagnostic(diagnostic); },
[](void*) { return halionbridge::isStopRequested(); }, nullptr};
result = converter->runWithContext(converterArgs, context);
usedStreamingContext = true;
}
Expand Down Expand Up @@ -270,6 +271,7 @@ int main(int argc, char* argv[])
}

halionbridge::log::configureFromEnvironment();
installStopHandlers();

if (juceArgs.size() > 0 && juceArgs[0] == "init")
{
Expand Down Expand Up @@ -305,8 +307,6 @@ int main(int argc, char* argv[])
#endif

halionbridge::installCrashDiagnostics();
installStopHandlers();

juce::ScopedJuceInitialiser_GUI juceInitialiser;

ConsoleLogger logger;
Expand All @@ -331,9 +331,14 @@ int main(int argc, char* argv[])
options->executableFile = halionbridge::detail::toStdPath(executableFile);

halionbridge::Bridge app;
if (!app.run(*options))
const auto runResult = app.runDetailed(*options);
if (runResult != halionbridge::RunResult::success)
{
halionbridge::log::error("Failed to run halionbridge.");
if (runResult == halionbridge::RunResult::stopped)
halionbridge::log::warn("halionbridge stopped by user request.");
else
halionbridge::log::error("Failed to run halionbridge.");

juce::Logger::setCurrentLogger(nullptr);
halionbridge::log::flush();
return 1;
Expand Down
6 changes: 6 additions & 0 deletions converters/common/include/halionbridge_converters/Converter.h
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ struct ConverterResult
struct ConverterRunContext
{
void (*diagnostic)(const Diagnostic& diagnostic, void* userData) = nullptr;
bool (*stopRequested)(void* userData) = nullptr;
void* userData = nullptr;

void report(const Diagnostic& entry) const
{
if (this->diagnostic != nullptr)
this->diagnostic(entry, userData);
}

bool shouldStop() const
{
return stopRequested != nullptr && stopRequested(userData);
}
};

struct ConverterDefinition
Expand Down
49 changes: 42 additions & 7 deletions converters/sfz/src/SfzConverter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -452,12 +452,24 @@ bool hasSfzExtension(const std::filesystem::path& path)
return extension == ".sfz";
}

bool shouldStop(const ConverterRunContext* context)
{
return context != nullptr && context->shouldStop();
}

template <typename Iterator>
void collectSfzFiles(Iterator iterator, const Iterator end, std::vector<std::filesystem::path>& files, std::vector<Diagnostic>& diagnostics)
void collectSfzFiles(Iterator iterator, const Iterator end, const ConverterRunContext* context, std::vector<std::filesystem::path>& files,
std::vector<Diagnostic>& diagnostics)
{
std::error_code error;
while (iterator != end)
{
if (shouldStop(context))
{
diagnostics.push_back(makeError({}, "stopped", "Conversion stopped by user request."));
return;
}

const auto path = iterator->path();
const auto isFile = iterator->is_regular_file(error);
if (error)
Expand All @@ -480,7 +492,7 @@ void collectSfzFiles(Iterator iterator, const Iterator end, std::vector<std::fil
}
}

SfzFileSearchResult findSfzFiles(const std::filesystem::path& sourceDirectory, const bool recursive)
SfzFileSearchResult findSfzFiles(const std::filesystem::path& sourceDirectory, const bool recursive, const ConverterRunContext* context)
{
auto result = SfzFileSearchResult{};
std::error_code error;
Expand All @@ -495,7 +507,7 @@ SfzFileSearchResult findSfzFiles(const std::filesystem::path& sourceDirectory, c
return result;
}

collectSfzFiles(iterator, std::filesystem::recursive_directory_iterator{}, result.files, result.diagnostics);
collectSfzFiles(iterator, std::filesystem::recursive_directory_iterator{}, context, result.files, result.diagnostics);
}
else
{
Expand All @@ -507,7 +519,7 @@ SfzFileSearchResult findSfzFiles(const std::filesystem::path& sourceDirectory, c
return result;
}

collectSfzFiles(iterator, std::filesystem::directory_iterator{}, result.files, result.diagnostics);
collectSfzFiles(iterator, std::filesystem::directory_iterator{}, context, result.files, result.diagnostics);
}

std::sort(result.files.begin(), result.files.end(),
Expand Down Expand Up @@ -1128,6 +1140,7 @@ std::string buildLuaSource(const ConvertedSfz& converted)
lua << "local hb = require(\"halionbridge-sfz\")\n\n"
<< "local layerName = " << luaQuotedString(converted.layerName) << "\n"
<< "local outputFile = " << luaQuotedString(converted.presetFileName) << "\n"
<< "local progressInterval = 25\n"
<< "\n"
<< "-- This file was generated by halionbridge convert sfz. Region data is\n"
<< "-- grouped by source concern so converter authors can inspect the SFZ\n"
Expand All @@ -1147,12 +1160,16 @@ std::string buildLuaSource(const ConvertedSfz& converted)
<< " end\n\n"
<< " for i, region in ipairs(regions) do\n"
<< " local label = hb.region_label(region)\n"
<< " ctx.progress(i - 1, #regions, \"Mapping \" .. label)\n"
<< " if i == 1 then\n"
<< " ctx.progress(i - 1, #regions, \"Mapping \" .. label)\n"
<< " end\n"
<< " local zone, zoneErr = hb.append_sample_zone(ctx, layer, region)\n"
<< " if not zone then\n"
<< " return hb.fail(zoneErr)\n"
<< " end\n"
<< " ctx.progress(i, #regions, \"Mapped \" .. label)\n"
<< " if i == #regions or (i % progressInterval) == 0 then\n"
<< " ctx.progress(i, #regions, \"Mapped \" .. label)\n"
<< " end\n"
<< " end\n\n"
<< " ctx.progress(#regions, #regions + 1, \"Saving \" .. outputFile)\n"
<< " local saved, saveErr = hb.save_layer_preset(ctx, layer, outputFile)\n"
Expand Down Expand Up @@ -1376,12 +1393,18 @@ ConversionResult convertDirectory(const ConversionOptions& options)
}

addDiagnostic(makeInfo(options.sourceDirectory, "scan-started", "Scanning " + options.sourceDirectory.string() + " for SFZ files."));
auto searchResult = findSfzFiles(options.sourceDirectory, options.recursive);
auto searchResult = findSfzFiles(options.sourceDirectory, options.recursive, options.context);
result.diagnostics.insert(result.diagnostics.end(), searchResult.diagnostics.begin(), searchResult.diagnostics.end());
reportPendingDiagnostics();
if (!searchResult.diagnostics.empty())
return result;

if (shouldStop(options.context))
{
addDiagnostic(makeError(options.sourceDirectory, "stopped", "Conversion stopped by user request."));
return result;
}

const auto& sfzFiles = searchResult.files;
if (sfzFiles.empty())
{
Expand All @@ -1408,6 +1431,12 @@ ConversionResult convertDirectory(const ConversionOptions& options)

for (size_t i = 0; i < sfzFiles.size(); ++i)
{
if (shouldStop(options.context))
{
addDiagnostic(makeError(sfzFiles[i], "stopped", "Conversion stopped by user request."));
return result;
}

addDiagnostic(
makeInfo(sfzFiles[i], "convert-started",
"Converting " + std::to_string(i + 1) + "/" + std::to_string(sfzFiles.size()) + ": " + sfzFiles[i].string()));
Expand Down Expand Up @@ -1446,6 +1475,12 @@ ConversionResult convertDirectory(const ConversionOptions& options)
}
}

if (shouldStop(options.context))
{
addDiagnostic(makeError(options.outputDirectory, "stopped", "Conversion stopped by user request before writing generated files."));
return result;
}

std::vector<GeneratedLuaScript> scripts;
scripts.reserve(convertedFiles.size() + 1);
scripts.push_back(
Expand Down
55 changes: 47 additions & 8 deletions halion-lua/builder.lua
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ local PROGRESS_WRITE_FAILURES = 0
local MAX_PROGRESS_MESSAGE_BYTES = 88
local MAX_PROGRESS_MARKER_PATH_BYTES = 240
local TRUNCATION_SUFFIX = "..."
local BUILD_SCRIPT_TIMEOUT_MS = 600000

local function globalNumber(name)
local value = tonumber(_G[name])
Expand Down Expand Up @@ -250,6 +251,38 @@ local function emit(kind, message)
writeProgress(kind, message)
end

local function extendScriptExecutionTimeout()
-- HALion's default controller script timeout is short for build-time scripts
-- that construct hundreds or thousands of zones. This API is valid from
-- global controller code, unlike wait(), which HALion only allows inside
-- callbacks.
if type(getScriptExecTimeOut) ~= "function" or type(setScriptExecTimeOut) ~= "function" then
return nil
end

local okGet, current = pcall(getScriptExecTimeOut)
current = tonumber(current)
if not okGet or current == nil or current >= BUILD_SCRIPT_TIMEOUT_MS then
return nil
end

local okSet = pcall(setScriptExecTimeOut, BUILD_SCRIPT_TIMEOUT_MS)
if not okSet then
return nil
end

emit("info", "Raised HALion script execution timeout from " .. current .. " ms to " .. BUILD_SCRIPT_TIMEOUT_MS .. " ms.")
return current
end

local function restoreScriptExecutionTimeout(previousTimeout)
if previousTimeout == nil or type(setScriptExecTimeOut) ~= "function" then
return
end

pcall(setScriptExecTimeOut, previousTimeout)
end

local function writeStatus(status)
-- halionbridge watches for these marker presets in the build directory.
-- Markers are written through savePreset() because HALion scripting may not
Expand Down Expand Up @@ -288,7 +321,7 @@ local function printFileProgress(done, total)
percent = math.floor((done * 100 / total) + 0.5)
end

emit("info", string.format("Progress %d/%d files (%d%%)", done, total, percent))
emit("info", string.format("Completed %d/%d files (%d%%)", done, total, percent))
end

local function makeContext(moduleName)
Expand Down Expand Up @@ -330,15 +363,19 @@ local function makeContext(moduleName)
if total < 0 then total = 0 end
if total > 0 and done > total then done = total end

local percent = total > 0 and math.floor((done * 100 / total) + 0.5) or 100
local unitWidth = math.max(decimalWidth(done), decimalWidth(total), 1)
local line = string.format("Progress %" .. unitWidth .. "d/%" .. unitWidth .. "d (%3d%%)", done, total, percent)
-- TODO: Re-enable numeric progress when the build runner executes from
-- a callback/onIdle path where progress markers can be observed in real
-- time. In synchronous global/module code the host sees markers in
-- bursts, and the prefix below is more confusing than useful.
-- local percent = total > 0 and math.floor((done * 100 / total) + 0.5) or 100
-- local unitWidth = math.max(decimalWidth(done), decimalWidth(total), 1)
-- local line = string.format("Progress %" .. unitWidth .. "d/%" .. unitWidth .. "d (%3d%%)", done, total, percent)

if message ~= nil and tostring(message) ~= "" then
line = line .. " - " .. tostring(message)
emit("info", tostring(message))
else
emit("info", "Progress marker")
end

emit("info", line)
end

return context
Expand Down Expand Up @@ -421,7 +458,7 @@ local function runBatch()
-- Repeated HALion runs in the same scripting session reload updated
-- build script source files because package.loaded is cleared per module.
moduleName = normalizeModuleName(moduleName)
emit("info", "Processing " .. moduleFileName(moduleName))
emit("info", string.format("Processing %d/%d: %s", index, total, moduleFileName(moduleName)))
scriptsProcessed = scriptsProcessed + 1

package.loaded[moduleName] = nil
Expand Down Expand Up @@ -473,6 +510,7 @@ if RUN_BUILD then
-- A status marker is written even when the runner itself throws. This gives
-- halionbridge a concrete completion signal instead of relying only on the
-- timeout path.
local previousScriptTimeout = extendScriptExecutionTimeout()
local ok, status = pcall(runBatch)
if not ok then
status = {
Expand All @@ -491,4 +529,5 @@ if RUN_BUILD then
end

writeStatus(status)
restoreScriptExecutionTimeout(previousScriptTimeout)
end
Loading