From 217f5960bfb6a35e4f8ee5b7e68f0c6bc8b9ab12 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sun, 14 Jun 2026 23:53:15 +0200 Subject: [PATCH 1/3] script timeout fixes --- DEVELOPMENT.md | 6 ++---- HALION-LUA.md | 2 ++ converters/sfz/src/SfzConverter.cpp | 13 +++++++++++-- halion-lua/builder.lua | 14 ++++++++++++++ tests/Tests.cpp | 8 ++++++++ 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bff8041..8ee18cd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -162,9 +162,7 @@ Converter CLI diagnostics can be streamed through `ConverterRunContext` while st `converters/common` owns reusable converter infrastructure: converter registration, build-directory writing, Lua string escaping, overwrite checks, and deterministic LF output. Format-specific converter modules should pass generated Lua files to this common emitter instead of rewriting build-file logic. Generated Lua files have a role: `buildEntrypoint` files are written and listed in `halionbridge_build.lua`, while `helperModule` files are written but not listed. The emitter accepts only flat relative `.lua` filenames, rejects absolute paths, root paths, `.`/`..`, nested paths, non-`.lua` names, duplicate filenames, duplicate build-entrypoint module names, helper-only output, and reserved helper filenames used as build entrypoints. Validation and overwrite checks run before writing; later filesystem write failures can still leave partial output. -`converters/sfz` owns all sfizz interaction. sfizz is an implementation dependency only; sfizz headers and types must not appear in public converter APIs. The v1 SFZ mapping creates one HALion layer preset per source SFZ and maps sample paths, sample playback range, key ranges, velocity ranges, root key, sustain-loop fields, static amp-envelope fields, static pitch/tuning fields, static gain/velocity/pan fields, and simple tone fields into the tested HALion sample-zone Lua pattern. Generated Lua treats zone type, sample filename, key range, velocity range, root key assignment, and amp-envelope assignment as required; failure returns an unsuccessful build result before saving the preset. Optional naming, sample playback range, sustain-loop parameter assignment, sample oscillator level, amp velocity-to-level, amp pan, filter fields, and static pitch/tuning parameter assignment remain non-fatal and are logged by the generated build script when HALion rejects them. SFZ `offset` maps to HALion `SampleOsc.SampleStart`; SFZ inclusive `end=N` maps to HALion `SampleOsc.SampleEnd=N+1`, and the helper writes sample end before sample start so HALion does not clamp start markers against an unset end marker. For WAV-backed regions without explicit `end=`, the converter emits `sample_playback.natural_end` from the WAV frame count so the helper can initialize `SampleOsc.SampleEnd` before optional start or loop marker writes without treating the file-derived length as an authored SFZ end. SFZ `loop_mode=loop_continuous` maps to HALion sustain loop mode `1`, `loop_mode=loop_sustain` maps to HALion sustain loop mode `4`, and generated loop data writes `loop_end + 1` only to `SampleOsc.SustainLoopEndA`; loop assignment no longer truncates `SampleOsc.SampleEnd` to the loop end. Static gain conversion maps SFZ `volume` in dB plus the verified `+7.8 dB` HALion compensation to per-zone `SampleOsc.Level`; generated layers disable velocity-setting inheritance and set `VelocityToLevelCurve=1`, which probe `030` verified as HALion's squared Main velocity curve for SFZ default `amp_veltrack=100`. `amp_veltrack` is clamped to SFZ's -100..100 percent range with a converter warning before being written to HALion `Amp Env.VelocityToLevel`; probe `032` keeps `amp_veltrack=200` as an out-of-range clamp regression. SFZ `pan` maps to zone `Amp.Pan`; probe `033` verified center, half-left/right, and full-left/right against sforzando, and changing HALion pan law made the match worse, so HALion's default pan law is intentionally left unchanged. Static pitch conversion maps `pitch_keycenter` to `SampleOsc.Rootkey`, combines `transpose * 100 + tune` into `SampleOsc.Tune` with a -1200..1200 cent clamp, and maps `pitch_keytrack` to `Pitch.CenterKey` plus `Pitch.KeyFollow` with a -200..200 percent clamp. Static filter conversion is intentionally rough: supported sforzando-recognized LPF, HPF, BPF, BRF, `lsh`, `hsh`, and `peq` families map to HALion Classic filter type/mode/shape/cutoff/resonance values selected in probes `059`/`060`; `lpf_2p` keeps its earlier piecewise calibrated resonance table from probes `056`/`057`. State-variable `_sv` filter names and `pink` remain unsupported warnings rather than silent approximations. - -Keep README SFZ content focused on the user workflow, generated file shape, overwrite safety, and broad supported-feature categories. Opcode-level mapping, HALion parameter names, probe-derived constants, and parity caveats belong in this development section and the private specification. +`converters/sfz` owns all sfizz interaction. sfizz is an implementation dependency only; sfizz headers and types must not appear in public converter APIs. The v1 SFZ mapping creates one HALion layer preset per source SFZ and maps sample paths, sample playback range, key ranges, velocity ranges, root key, sustain-loop fields, static amp-envelope fields, static pitch/tuning fields, static gain/velocity/pan fields, and simple tone fields into the tested HALion sample-zone Lua pattern. Generated Lua treats zone type, sample filename, key range, velocity range, root key assignment, and amp-envelope assignment as required; failure returns an unsuccessful build result before saving the preset. Optional naming, sample playback range, sustain-loop parameter assignment, sample oscillator level, amp velocity-to-level, amp pan, filter fields, and static pitch/tuning parameter assignment remain non-fatal and are logged by the generated build script when HALion rejects them. SFZ `offset` maps to HALion `SampleOsc.SampleStart`; SFZ inclusive `end=N` maps to HALion `SampleOsc.SampleEnd=N+1`, and the helper writes sample end before sample start so HALion does not clamp start markers against an unset end marker. For WAV-backed regions without explicit `end=`, the converter emits `sample_playback.natural_end` from the WAV frame count so the helper can initialize `SampleOsc.SampleEnd` before optional start or loop marker writes without treating the file-derived length as an authored SFZ end. SFZ `loop_mode=loop_continuous` maps to HALion sustain loop mode `1`, `loop_mode=loop_sustain` maps to HALion sustain loop mode `4`, and generated loop data writes `loop_end + 1` only to `SampleOsc.SustainLoopEndA`; loop assignment no longer truncates `SampleOsc.SampleEnd` to the loop end. Generated SFZ scripts call `ctx.yield(1)` every ten mapped regions and write region progress only periodically so large SFZ files do not spend the whole HALion Lua callback in a tight zone-creation loop or thousands of progress-marker `savePreset()` calls. Static gain conversion maps SFZ `volume` in dB plus the verified `+7.8 dB` HALion compensation to per-zone `SampleOsc.Level`; generated layers disable velocity-setting inheritance and set `VelocityToLevelCurve=1`, which probe `030` verified as HALion's squared Main velocity curve for SFZ default `amp_veltrack=100`. `amp_veltrack` is clamped to SFZ's -100..100 percent range with a converter warning before being written to HALion `Amp Env.VelocityToLevel`; probe `032` keeps `amp_veltrack=200` as an out-of-range clamp regression. SFZ `pan` maps to zone `Amp.Pan`; probe `033` verified center, half-left/right, and full-left/right against sforzando, and changing HALion pan law made the match worse, so HALion's default pan law is intentionally left unchanged. Static pitch conversion maps `pitch_keycenter` to `SampleOsc.Rootkey`, combines `transpose * 100 + tune` into `SampleOsc.Tune` with a -1200..1200 cent clamp, and maps `pitch_keytrack` to `Pitch.CenterKey` plus `Pitch.KeyFollow` with a -200..200 percent clamp. Static filter conversion is intentionally rough: supported sforzando-recognized LPF, HPF, BPF, BRF, `lsh`, `hsh`, and `peq` families map to HALion Classic filter type/mode/shape/cutoff/resonance values selected in probes `059`/`060`; `lpf_2p` keeps its earlier piecewise calibrated resonance table from probes `056`/`057`. State-variable `_sv` filter names and `pink` remain unsupported warnings rather than silent approximations. Static pitch envelope conversion currently covers only the probe-verified simple `pitcheg_*` subset. `pitcheg_depth` is clamped to `-6000..6000` cents, converted to HALion `Pitch.EnvAmount` in semitones, and emitted as a required pitch-envelope assignment when nonzero. Zero-attack pitch envelopes start at level `1`; simple attack, hold, and sustain points are written directly. Positive `pitcheg_decay` values use the current probe-selected approximation of `0.30 * pitcheg_decay` with Lua envelope curve `-0.6`; probe `064` is the converter-backed regression awaiting manual same-number audition. Advanced pitch-envelope behavior, including shape, CC, velocity, and flex-EG pitch targets, is reported as `unsupported-pitch-envelope` rather than silently approximated. @@ -281,7 +279,7 @@ cd halion-lua - The compile-time assets are `halion-lua/builder_bootstrap.vstpreset` and `halion-lua/builder.lua`, embedded through the `halionbridge_assets` binary-data target. The checked-in `halion-lua/builder_bootstrap.lua` is the canonical source text for the inline Lua saved inside `builder_bootstrap.vstpreset`; it should call `require("halionbridge_runtime")`. - For builder runs, the bridge writes temporary `halionbridge_runtime.lua` and `halionbridge_builder.lua` files into the HALion user scripts directory under `Documents/Steinberg/HALion/Library/scripts`. HALion Lua does not reliably expose the host process environment or current working directory to `require()`, so this generated runtime module is the authoritative handoff. It sets the Lua global `HALIONBRIDGE_RUNTIME_ROOT`, optional build-slice globals, prepends the build directory to `package.path` if needed, clears cached runtime/builder modules, and then loads the temporary embedded builder module. Previous files with the same names are restored after the run; otherwise the temporary files are deleted. Because these filenames and the working directory/environment handoff are shared process/user state, halionbridge acquires a fail-fast inter-process lock for the whole runtime-staging lifetime and rejects overlapping runs. - Running `halionbridge` without arguments is equivalent to `halionbridge --help` and exits successfully after printing usage. -- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. For statically parseable build files, the host runs the list one script per HALion process by default. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. +- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. `ctx.yield(ms)` wraps HALion's `wait(ms)` when available so long-running build scripts can periodically hand control back to HALion and avoid the script execution watchdog. For statically parseable build files, the host runs the list one script per HALion process by default. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. - Build script modules own all HALion-specific build behavior: program presets, layer presets, zones, samples, and output filenames. Keep the build script API documented in `HALION-LUA.md` when changing builder or build script behavior. - Generic build script modules are arbitrary code, so `halionbridge_build.lua` entries are not treated as expected output preset names. Build completion is reported only through HALion-written `.vstpreset` marker files. - The default build timeout is `0`, which waits forever and emits a startup warning. Set `--timeout-seconds ` only when a finite build timeout is desired. diff --git a/HALION-LUA.md b/HALION-LUA.md index 6735a30..210481e 100644 --- a/HALION-LUA.md +++ b/HALION-LUA.md @@ -80,6 +80,7 @@ ctx.path_join(root, rel) ctx.save_preset(path, object, preset_type) ctx.log(message) ctx.progress(done, total, message) +ctx.yield(ms) ``` - `ctx.script_dir`: runtime root directory passed on the command line; it contains `halionbridge_build.lua` and Lua build script files. @@ -89,6 +90,7 @@ ctx.progress(done, total, message) - `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.yield(ms)`: calls HALion `wait(ms)` when available and returns `true`; outside HALion contexts where `wait` is unavailable, it returns `false`. Long-running build scripts should call this periodically while creating many zones or parameters so HALion can continue processing and avoid its script execution watchdog. `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. diff --git a/converters/sfz/src/SfzConverter.cpp b/converters/sfz/src/SfzConverter.cpp index a5f3eb7..a2f070d 100644 --- a/converters/sfz/src/SfzConverter.cpp +++ b/converters/sfz/src/SfzConverter.cpp @@ -1128,6 +1128,8 @@ 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" + << "local yieldInterval = 10\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" @@ -1147,12 +1149,19 @@ 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 or ((i - 1) % progressInterval) == 0 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" + << " if (i % yieldInterval) == 0 then\n" + << " ctx.yield(1)\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" diff --git a/halion-lua/builder.lua b/halion-lua/builder.lua index 11cf7f4..3717eeb 100644 --- a/halion-lua/builder.lua +++ b/halion-lua/builder.lua @@ -341,6 +341,20 @@ local function makeContext(moduleName) emit("info", line) end + function context.yield(ms) + -- HALion can stop long-running Lua callbacks with "Script time out" + -- even when the script is making valid progress. Build scripts that + -- create many zones or parameters can call ctx.yield() periodically to + -- hand control back to HALion. Outside HALion, or in older contexts + -- without wait(), this is a harmless no-op. + if type(wait) ~= "function" then + return false + end + + wait(tonumber(ms) or 1) + return true + end + return context end diff --git a/tests/Tests.cpp b/tests/Tests.cpp index a45837b..6bfbc89 100644 --- a/tests/Tests.cpp +++ b/tests/Tests.cpp @@ -1533,6 +1533,9 @@ class BridgeTests : public juce::UnitTest expect(firstLua.contains("hb.create_layer(ctx, layerName)")); expect(firstLua.contains("hb.append_sample_zone(ctx, layer, region)")); expect(firstLua.contains("hb.save_layer_preset(ctx, layer, outputFile)")); + expect(firstLua.contains("local progressInterval = 25")); + expect(firstLua.contains("local yieldInterval = 10")); + expect(firstLua.contains("ctx.yield(1)")); expect(!firstLua.contains("local function setNameIfAvailable")); expect(!firstLua.contains("local function setParameterRequired")); expect(!firstLua.contains("local function setParameterIfAvailable")); @@ -1720,6 +1723,11 @@ class BridgeTests : public juce::UnitTest expect(slicedText.find("HALIONBRIDGE_BUILD_SLICE_START = 16") != std::string::npos); expect(slicedText.find("HALIONBRIDGE_BUILD_SLICE_COUNT = 15") != std::string::npos); expect(slicedText.find("HALIONBRIDGE_BUILD_TOTAL = 42") != std::string::npos); + + const auto builderLua = + juce::File::getCurrentWorkingDirectory().getChildFile("halion-lua").getChildFile("builder.lua").loadFileAsString(); + expect(builderLua.contains("function context.yield(ms)")); + expect(builderLua.contains("wait(tonumber(ms) or 1)")); } beginTest("Plugin Location - platform default path"); From 2a0bd84e3a355777109f93d8af68c62971d409f4 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Mon, 15 Jun 2026 00:02:24 +0200 Subject: [PATCH 2/3] halion timing fixes --- DEVELOPMENT.md | 4 +-- HALION-LUA.md | 4 +-- converters/sfz/src/SfzConverter.cpp | 4 --- halion-lua/builder.lua | 49 ++++++++++++++++++++--------- tests/Tests.cpp | 9 +++--- 5 files changed, 44 insertions(+), 26 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8ee18cd..0826a11 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -162,7 +162,7 @@ Converter CLI diagnostics can be streamed through `ConverterRunContext` while st `converters/common` owns reusable converter infrastructure: converter registration, build-directory writing, Lua string escaping, overwrite checks, and deterministic LF output. Format-specific converter modules should pass generated Lua files to this common emitter instead of rewriting build-file logic. Generated Lua files have a role: `buildEntrypoint` files are written and listed in `halionbridge_build.lua`, while `helperModule` files are written but not listed. The emitter accepts only flat relative `.lua` filenames, rejects absolute paths, root paths, `.`/`..`, nested paths, non-`.lua` names, duplicate filenames, duplicate build-entrypoint module names, helper-only output, and reserved helper filenames used as build entrypoints. Validation and overwrite checks run before writing; later filesystem write failures can still leave partial output. -`converters/sfz` owns all sfizz interaction. sfizz is an implementation dependency only; sfizz headers and types must not appear in public converter APIs. The v1 SFZ mapping creates one HALion layer preset per source SFZ and maps sample paths, sample playback range, key ranges, velocity ranges, root key, sustain-loop fields, static amp-envelope fields, static pitch/tuning fields, static gain/velocity/pan fields, and simple tone fields into the tested HALion sample-zone Lua pattern. Generated Lua treats zone type, sample filename, key range, velocity range, root key assignment, and amp-envelope assignment as required; failure returns an unsuccessful build result before saving the preset. Optional naming, sample playback range, sustain-loop parameter assignment, sample oscillator level, amp velocity-to-level, amp pan, filter fields, and static pitch/tuning parameter assignment remain non-fatal and are logged by the generated build script when HALion rejects them. SFZ `offset` maps to HALion `SampleOsc.SampleStart`; SFZ inclusive `end=N` maps to HALion `SampleOsc.SampleEnd=N+1`, and the helper writes sample end before sample start so HALion does not clamp start markers against an unset end marker. For WAV-backed regions without explicit `end=`, the converter emits `sample_playback.natural_end` from the WAV frame count so the helper can initialize `SampleOsc.SampleEnd` before optional start or loop marker writes without treating the file-derived length as an authored SFZ end. SFZ `loop_mode=loop_continuous` maps to HALion sustain loop mode `1`, `loop_mode=loop_sustain` maps to HALion sustain loop mode `4`, and generated loop data writes `loop_end + 1` only to `SampleOsc.SustainLoopEndA`; loop assignment no longer truncates `SampleOsc.SampleEnd` to the loop end. Generated SFZ scripts call `ctx.yield(1)` every ten mapped regions and write region progress only periodically so large SFZ files do not spend the whole HALion Lua callback in a tight zone-creation loop or thousands of progress-marker `savePreset()` calls. Static gain conversion maps SFZ `volume` in dB plus the verified `+7.8 dB` HALion compensation to per-zone `SampleOsc.Level`; generated layers disable velocity-setting inheritance and set `VelocityToLevelCurve=1`, which probe `030` verified as HALion's squared Main velocity curve for SFZ default `amp_veltrack=100`. `amp_veltrack` is clamped to SFZ's -100..100 percent range with a converter warning before being written to HALion `Amp Env.VelocityToLevel`; probe `032` keeps `amp_veltrack=200` as an out-of-range clamp regression. SFZ `pan` maps to zone `Amp.Pan`; probe `033` verified center, half-left/right, and full-left/right against sforzando, and changing HALion pan law made the match worse, so HALion's default pan law is intentionally left unchanged. Static pitch conversion maps `pitch_keycenter` to `SampleOsc.Rootkey`, combines `transpose * 100 + tune` into `SampleOsc.Tune` with a -1200..1200 cent clamp, and maps `pitch_keytrack` to `Pitch.CenterKey` plus `Pitch.KeyFollow` with a -200..200 percent clamp. Static filter conversion is intentionally rough: supported sforzando-recognized LPF, HPF, BPF, BRF, `lsh`, `hsh`, and `peq` families map to HALion Classic filter type/mode/shape/cutoff/resonance values selected in probes `059`/`060`; `lpf_2p` keeps its earlier piecewise calibrated resonance table from probes `056`/`057`. State-variable `_sv` filter names and `pink` remain unsupported warnings rather than silent approximations. +`converters/sfz` owns all sfizz interaction. sfizz is an implementation dependency only; sfizz headers and types must not appear in public converter APIs. The v1 SFZ mapping creates one HALion layer preset per source SFZ and maps sample paths, sample playback range, key ranges, velocity ranges, root key, sustain-loop fields, static amp-envelope fields, static pitch/tuning fields, static gain/velocity/pan fields, and simple tone fields into the tested HALion sample-zone Lua pattern. Generated Lua treats zone type, sample filename, key range, velocity range, root key assignment, and amp-envelope assignment as required; failure returns an unsuccessful build result before saving the preset. Optional naming, sample playback range, sustain-loop parameter assignment, sample oscillator level, amp velocity-to-level, amp pan, filter fields, and static pitch/tuning parameter assignment remain non-fatal and are logged by the generated build script when HALion rejects them. SFZ `offset` maps to HALion `SampleOsc.SampleStart`; SFZ inclusive `end=N` maps to HALion `SampleOsc.SampleEnd=N+1`, and the helper writes sample end before sample start so HALion does not clamp start markers against an unset end marker. For WAV-backed regions without explicit `end=`, the converter emits `sample_playback.natural_end` from the WAV frame count so the helper can initialize `SampleOsc.SampleEnd` before optional start or loop marker writes without treating the file-derived length as an authored SFZ end. SFZ `loop_mode=loop_continuous` maps to HALion sustain loop mode `1`, `loop_mode=loop_sustain` maps to HALion sustain loop mode `4`, and generated loop data writes `loop_end + 1` only to `SampleOsc.SustainLoopEndA`; loop assignment no longer truncates `SampleOsc.SampleEnd` to the loop end. Generated SFZ scripts write region progress only periodically so large SFZ files avoid thousands of progress-marker `savePreset()` calls. Static gain conversion maps SFZ `volume` in dB plus the verified `+7.8 dB` HALion compensation to per-zone `SampleOsc.Level`; generated layers disable velocity-setting inheritance and set `VelocityToLevelCurve=1`, which probe `030` verified as HALion's squared Main velocity curve for SFZ default `amp_veltrack=100`. `amp_veltrack` is clamped to SFZ's -100..100 percent range with a converter warning before being written to HALion `Amp Env.VelocityToLevel`; probe `032` keeps `amp_veltrack=200` as an out-of-range clamp regression. SFZ `pan` maps to zone `Amp.Pan`; probe `033` verified center, half-left/right, and full-left/right against sforzando, and changing HALion pan law made the match worse, so HALion's default pan law is intentionally left unchanged. Static pitch conversion maps `pitch_keycenter` to `SampleOsc.Rootkey`, combines `transpose * 100 + tune` into `SampleOsc.Tune` with a -1200..1200 cent clamp, and maps `pitch_keytrack` to `Pitch.CenterKey` plus `Pitch.KeyFollow` with a -200..200 percent clamp. Static filter conversion is intentionally rough: supported sforzando-recognized LPF, HPF, BPF, BRF, `lsh`, `hsh`, and `peq` families map to HALion Classic filter type/mode/shape/cutoff/resonance values selected in probes `059`/`060`; `lpf_2p` keeps its earlier piecewise calibrated resonance table from probes `056`/`057`. State-variable `_sv` filter names and `pink` remain unsupported warnings rather than silent approximations. Static pitch envelope conversion currently covers only the probe-verified simple `pitcheg_*` subset. `pitcheg_depth` is clamped to `-6000..6000` cents, converted to HALion `Pitch.EnvAmount` in semitones, and emitted as a required pitch-envelope assignment when nonzero. Zero-attack pitch envelopes start at level `1`; simple attack, hold, and sustain points are written directly. Positive `pitcheg_decay` values use the current probe-selected approximation of `0.30 * pitcheg_decay` with Lua envelope curve `-0.6`; probe `064` is the converter-backed regression awaiting manual same-number audition. Advanced pitch-envelope behavior, including shape, CC, velocity, and flex-EG pitch targets, is reported as `unsupported-pitch-envelope` rather than silently approximated. @@ -279,7 +279,7 @@ cd halion-lua - The compile-time assets are `halion-lua/builder_bootstrap.vstpreset` and `halion-lua/builder.lua`, embedded through the `halionbridge_assets` binary-data target. The checked-in `halion-lua/builder_bootstrap.lua` is the canonical source text for the inline Lua saved inside `builder_bootstrap.vstpreset`; it should call `require("halionbridge_runtime")`. - For builder runs, the bridge writes temporary `halionbridge_runtime.lua` and `halionbridge_builder.lua` files into the HALion user scripts directory under `Documents/Steinberg/HALion/Library/scripts`. HALion Lua does not reliably expose the host process environment or current working directory to `require()`, so this generated runtime module is the authoritative handoff. It sets the Lua global `HALIONBRIDGE_RUNTIME_ROOT`, optional build-slice globals, prepends the build directory to `package.path` if needed, clears cached runtime/builder modules, and then loads the temporary embedded builder module. Previous files with the same names are restored after the run; otherwise the temporary files are deleted. Because these filenames and the working directory/environment handoff are shared process/user state, halionbridge acquires a fail-fast inter-process lock for the whole runtime-staging lifetime and rejects overlapping runs. - Running `halionbridge` without arguments is equivalent to `halionbridge --help` and exits successfully after printing usage. -- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. `ctx.yield(ms)` wraps HALion's `wait(ms)` when available so long-running build scripts can periodically hand control back to HALion and avoid the script execution watchdog. For statically parseable build files, the host runs the list one script per HALion process by default. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. +- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms with `setScriptExecTimeOut()` when the API is available, then restores the previous value after writing the final status marker. This is used instead of HALion `wait()`, because `wait()` is only valid inside callbacks while build script entrypoints run as controller global/module code. For statically parseable build files, the host runs the list one script per HALion process by default. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. - Build script modules own all HALion-specific build behavior: program presets, layer presets, zones, samples, and output filenames. Keep the build script API documented in `HALION-LUA.md` when changing builder or build script behavior. - Generic build script modules are arbitrary code, so `halionbridge_build.lua` entries are not treated as expected output preset names. Build completion is reported only through HALion-written `.vstpreset` marker files. - The default build timeout is `0`, which waits forever and emits a startup warning. Set `--timeout-seconds ` only when a finite build timeout is desired. diff --git a/HALION-LUA.md b/HALION-LUA.md index 210481e..ca5269f 100644 --- a/HALION-LUA.md +++ b/HALION-LUA.md @@ -80,7 +80,6 @@ ctx.path_join(root, rel) ctx.save_preset(path, object, preset_type) ctx.log(message) ctx.progress(done, total, message) -ctx.yield(ms) ``` - `ctx.script_dir`: runtime root directory passed on the command line; it contains `halionbridge_build.lua` and Lua build script files. @@ -90,10 +89,11 @@ ctx.yield(ms) - `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.yield(ms)`: calls HALion `wait(ms)` when available and returns `true`; outside HALion contexts where `wait` is unavailable, it returns `false`. Long-running build scripts should call this periodically while creating many zones or parameters so HALion can continue processing and avoid its script execution watchdog. `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: diff --git a/converters/sfz/src/SfzConverter.cpp b/converters/sfz/src/SfzConverter.cpp index a2f070d..0388a2e 100644 --- a/converters/sfz/src/SfzConverter.cpp +++ b/converters/sfz/src/SfzConverter.cpp @@ -1129,7 +1129,6 @@ std::string buildLuaSource(const ConvertedSfz& converted) << "local layerName = " << luaQuotedString(converted.layerName) << "\n" << "local outputFile = " << luaQuotedString(converted.presetFileName) << "\n" << "local progressInterval = 25\n" - << "local yieldInterval = 10\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" @@ -1159,9 +1158,6 @@ std::string buildLuaSource(const ConvertedSfz& converted) << " if i == #regions or (i % progressInterval) == 0 then\n" << " ctx.progress(i, #regions, \"Mapped \" .. label)\n" << " end\n" - << " if (i % yieldInterval) == 0 then\n" - << " ctx.yield(1)\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" diff --git a/halion-lua/builder.lua b/halion-lua/builder.lua index 3717eeb..94ae0da 100644 --- a/halion-lua/builder.lua +++ b/halion-lua/builder.lua @@ -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]) @@ -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 @@ -341,20 +374,6 @@ local function makeContext(moduleName) emit("info", line) end - function context.yield(ms) - -- HALion can stop long-running Lua callbacks with "Script time out" - -- even when the script is making valid progress. Build scripts that - -- create many zones or parameters can call ctx.yield() periodically to - -- hand control back to HALion. Outside HALion, or in older contexts - -- without wait(), this is a harmless no-op. - if type(wait) ~= "function" then - return false - end - - wait(tonumber(ms) or 1) - return true - end - return context end @@ -487,6 +506,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 = { @@ -505,4 +525,5 @@ if RUN_BUILD then end writeStatus(status) + restoreScriptExecutionTimeout(previousScriptTimeout) end diff --git a/tests/Tests.cpp b/tests/Tests.cpp index 6bfbc89..8df6cb6 100644 --- a/tests/Tests.cpp +++ b/tests/Tests.cpp @@ -1534,8 +1534,8 @@ class BridgeTests : public juce::UnitTest expect(firstLua.contains("hb.append_sample_zone(ctx, layer, region)")); expect(firstLua.contains("hb.save_layer_preset(ctx, layer, outputFile)")); expect(firstLua.contains("local progressInterval = 25")); - expect(firstLua.contains("local yieldInterval = 10")); - expect(firstLua.contains("ctx.yield(1)")); + expect(!firstLua.contains("ctx.yield")); + expect(!firstLua.contains("wait(")); expect(!firstLua.contains("local function setNameIfAvailable")); expect(!firstLua.contains("local function setParameterRequired")); expect(!firstLua.contains("local function setParameterIfAvailable")); @@ -1726,8 +1726,9 @@ class BridgeTests : public juce::UnitTest const auto builderLua = juce::File::getCurrentWorkingDirectory().getChildFile("halion-lua").getChildFile("builder.lua").loadFileAsString(); - expect(builderLua.contains("function context.yield(ms)")); - expect(builderLua.contains("wait(tonumber(ms) or 1)")); + expect(builderLua.contains("BUILD_SCRIPT_TIMEOUT_MS = 600000")); + expect(builderLua.contains("setScriptExecTimeOut")); + expect(!builderLua.contains("function context.yield")); } beginTest("Plugin Location - platform default path"); From 9c2e7cbbab8f81d36b846fab9443952201763000 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Mon, 15 Jun 2026 00:35:05 +0200 Subject: [PATCH 3/3] timing fixes work. --- DEVELOPMENT.md | 5 ++- HALION-LUA.md | 6 ++- README.md | 8 ++-- cli/Main.cpp | 17 +++++--- .../halionbridge_converters/Converter.h | 6 +++ converters/sfz/src/SfzConverter.cpp | 42 ++++++++++++++++--- halion-lua/builder.lua | 20 +++++---- include/halionbridge/Bridge.h | 2 +- src/Bridge.cpp | 38 +++++++++++++---- tests/Tests.cpp | 32 +++++++++++++- 10 files changed, 137 insertions(+), 39 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0826a11..99b8294 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -226,7 +226,7 @@ The default log level is `info`. Users can set `HALIONBRIDGE_LOGLEVEL` to `trace [YYYY-MM-DD HH:mm:ss.mmm] [level] message ``` -`trace`, `debug`, and `info` are routed to stdout. `warn` and `error` are routed to stderr. Every emitted log record is flushed immediately so long conversions and HALion builds are observable from calling shells. Host internals such as temporary runtime files, VST3 preset details, plugin scan details, marker paths, and cleanup are `debug`. Build script progress and batch summaries remain `info` so normal users can see build progress without enabling diagnostics. HALion Lua `print()` output is not treated as a reliable stdout transport; the embedded builder writes temporary `hbp_*.vstpreset` marker presets in the build directory, and the host polls those markers at a throttled interval to forward runner, `ctx.log`, and `ctx.progress` messages to the console. Marker filenames contain hex-encoded message bytes, so punctuation, paths, and percent signs round-trip in console output. Marker messages use an 88-byte maximum budget on short paths and a smaller dynamic budget on long build-directory paths so progress marker paths stay below a conservative Windows path-length boundary; shortened messages include `...` inside the available budget. Each consumed progress marker is deleted immediately after it is logged so long builds do not accumulate marker files. When a terminal OK or failed status marker appears, the host drains progress markers for a short quiet period, then retries deletion again after HALion plugin resources are released. If stale progress markers cannot be deleted before a run, their filenames are suppressed from current-run logging and cleanup is retried later. The host also cleans up and parses legacy `halionbridge_progress_*.vstpreset` markers from earlier builds. +`trace`, `debug`, and `info` are routed to stdout. `warn` and `error` are routed to stderr. Every emitted log record is flushed immediately so long conversions and HALion builds are observable from calling shells. Host internals such as temporary runtime files, VST3 preset details, plugin scan details, marker paths, and cleanup are `debug`. Build script progress and batch summaries remain `info` so normal users can see build progress without enabling diagnostics. HALion Lua `print()` output is not treated as a reliable stdout transport; the embedded builder writes temporary `hbp_*.vstpreset` marker presets in the build directory, and the host polls those markers at a throttled interval to forward runner, `ctx.log`, and `ctx.progress` messages to the console. Marker filenames contain hex-encoded message bytes, so punctuation, paths, and percent signs round-trip in console output. Marker messages use an 88-byte maximum budget on short paths and a smaller dynamic budget on long build-directory paths so progress marker paths stay below a conservative Windows path-length boundary; shortened messages include `...` inside the available budget. Each consumed progress marker is deleted immediately after it is logged so long builds do not accumulate marker files. When a terminal OK or failed status marker appears, the host drains progress markers for a short quiet period, then retries deletion again after HALion plugin resources are released. If stale progress markers cannot be deleted before a run, their filenames are suppressed from current-run logging and cleanup is retried later. The host also cleans up and parses legacy `halionbridge_progress_*.vstpreset` markers from earlier builds. While build script entrypoints run as synchronous HALion global/module code, marker output may arrive in bursts after HALion returns control, so `ctx.progress()` currently forwards only the author-supplied message text and keeps the old numeric prefix code commented in `builder.lua` for a later callback/onIdle progress design. ## Diagnostic Builds @@ -279,7 +279,8 @@ cd halion-lua - The compile-time assets are `halion-lua/builder_bootstrap.vstpreset` and `halion-lua/builder.lua`, embedded through the `halionbridge_assets` binary-data target. The checked-in `halion-lua/builder_bootstrap.lua` is the canonical source text for the inline Lua saved inside `builder_bootstrap.vstpreset`; it should call `require("halionbridge_runtime")`. - For builder runs, the bridge writes temporary `halionbridge_runtime.lua` and `halionbridge_builder.lua` files into the HALion user scripts directory under `Documents/Steinberg/HALion/Library/scripts`. HALion Lua does not reliably expose the host process environment or current working directory to `require()`, so this generated runtime module is the authoritative handoff. It sets the Lua global `HALIONBRIDGE_RUNTIME_ROOT`, optional build-slice globals, prepends the build directory to `package.path` if needed, clears cached runtime/builder modules, and then loads the temporary embedded builder module. Previous files with the same names are restored after the run; otherwise the temporary files are deleted. Because these filenames and the working directory/environment handoff are shared process/user state, halionbridge acquires a fail-fast inter-process lock for the whole runtime-staging lifetime and rejects overlapping runs. - Running `halionbridge` without arguments is equivalent to `halionbridge --help` and exits successfully after printing usage. -- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms with `setScriptExecTimeOut()` when the API is available, then restores the previous value after writing the final status marker. This is used instead of HALion `wait()`, because `wait()` is only valid inside callbacks while build script entrypoints run as controller global/module code. For statically parseable build files, the host runs the list one script per HALion process by default. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. +- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms with `setScriptExecTimeOut()` when the API is available, then restores the previous value after writing the final status marker. This is used instead of HALion `wait()`, because `wait()` is only valid inside callbacks while build script entrypoints run as controller global/module code. For statically parseable build files, the host runs the list in chunks of up to 15 scripts per HALion process by default. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. Because current build script entrypoints run as synchronous HALion global/module code, the host may observe progress markers in batches after HALion returns control rather than exactly when `ctx.progress()` was called inside a long script. +- The CLI installs Ctrl+C/termination handlers before init, convert, scan-worker, and build commands. Handlers set a shared stop flag. SFZ conversion checks the flag during directory traversal, between file conversions, and before writing generated files. HALion builds check the flag while waiting for markers and between chunks; a synchronous global Lua build script cannot be preempted mid-function, so stop is observed when HALion returns control to the host. - Build script modules own all HALion-specific build behavior: program presets, layer presets, zones, samples, and output filenames. Keep the build script API documented in `HALION-LUA.md` when changing builder or build script behavior. - Generic build script modules are arbitrary code, so `halionbridge_build.lua` entries are not treated as expected output preset names. Build completion is reported only through HALion-written `.vstpreset` marker files. - The default build timeout is `0`, which waits forever and emits a startup warning. Set `--timeout-seconds ` only when a finite build timeout is desired. diff --git a/HALION-LUA.md b/HALION-LUA.md index ca5269f..399bc22 100644 --- a/HALION-LUA.md +++ b/HALION-LUA.md @@ -88,7 +88,7 @@ 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. @@ -120,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 diff --git a/README.md b/README.md index 5d8d25a..371fc49 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 `. 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. diff --git a/cli/Main.cpp b/cli/Main.cpp index 9ef7a2c..45fb981 100644 --- a/cli/Main.cpp +++ b/cli/Main.cpp @@ -86,7 +86,7 @@ void printHelp() << "\n" << "Runtime options:\n" << " --timeout-seconds Build completion timeout. Defaults to 0, which waits forever.\n" - << " --build-chunk-size Number of Lua build scripts per HALion run. Defaults to 1.\n" + << " --build-chunk-size 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" @@ -192,7 +192,8 @@ int runConvertCommand(std::span 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; } @@ -270,6 +271,7 @@ int main(int argc, char* argv[]) } halionbridge::log::configureFromEnvironment(); + installStopHandlers(); if (juceArgs.size() > 0 && juceArgs[0] == "init") { @@ -305,8 +307,6 @@ int main(int argc, char* argv[]) #endif halionbridge::installCrashDiagnostics(); - installStopHandlers(); - juce::ScopedJuceInitialiser_GUI juceInitialiser; ConsoleLogger logger; @@ -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; diff --git a/converters/common/include/halionbridge_converters/Converter.h b/converters/common/include/halionbridge_converters/Converter.h index 5105ca6..c87aecf 100644 --- a/converters/common/include/halionbridge_converters/Converter.h +++ b/converters/common/include/halionbridge_converters/Converter.h @@ -34,6 +34,7 @@ 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 @@ -41,6 +42,11 @@ struct ConverterRunContext if (this->diagnostic != nullptr) this->diagnostic(entry, userData); } + + bool shouldStop() const + { + return stopRequested != nullptr && stopRequested(userData); + } }; struct ConverterDefinition diff --git a/converters/sfz/src/SfzConverter.cpp b/converters/sfz/src/SfzConverter.cpp index 0388a2e..822f8d2 100644 --- a/converters/sfz/src/SfzConverter.cpp +++ b/converters/sfz/src/SfzConverter.cpp @@ -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 -void collectSfzFiles(Iterator iterator, const Iterator end, std::vector& files, std::vector& diagnostics) +void collectSfzFiles(Iterator iterator, const Iterator end, const ConverterRunContext* context, std::vector& files, + std::vector& 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) @@ -480,7 +492,7 @@ void collectSfzFiles(Iterator iterator, const Iterator end, std::vector scripts; scripts.reserve(convertedFiles.size() + 1); scripts.push_back( diff --git a/halion-lua/builder.lua b/halion-lua/builder.lua index 94ae0da..ae465c7 100644 --- a/halion-lua/builder.lua +++ b/halion-lua/builder.lua @@ -321,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) @@ -363,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 @@ -454,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 diff --git a/include/halionbridge/Bridge.h b/include/halionbridge/Bridge.h index 3844e6c..0ee26a5 100644 --- a/include/halionbridge/Bridge.h +++ b/include/halionbridge/Bridge.h @@ -20,7 +20,7 @@ struct AppOptions std::optional pluginPathOverride; std::optional executableFile; int timeoutSeconds = 0; - int buildChunkSize = 1; + int buildChunkSize = 15; bool showGui = false; bool noKill = false; bool forceScan = false; diff --git a/src/Bridge.cpp b/src/Bridge.cpp index ebfb5a4..fbe0e16 100644 --- a/src/Bridge.cpp +++ b/src/Bridge.cpp @@ -42,7 +42,7 @@ constexpr int kTerminalProgressDrainMaxMs = 1500; constexpr int kTerminalProgressDrainQuietMs = 300; constexpr double kBuildWaitProgressLogIntervalSeconds = 5.0; constexpr double kNoKillHeartbeatIntervalSeconds = 30.0; -constexpr int kDefaultBuildChunkSize = 1; +constexpr int kDefaultBuildChunkSize = 15; constexpr const char* kBuildStatusOkPresetFileName = "halionbridge_status_ok.vstpreset"; constexpr const char* kBuildStatusFailedPresetFileName = "halionbridge_status_failed.vstpreset"; constexpr const char* kPresetDirEnvironmentVariable = "HALIONBRIDGE_PRESET_DIR"; @@ -640,6 +640,13 @@ BuildWaitResult waitForBuildCompletion(juce::AudioPluginInstance& plugin, const plugin.processBlock(buffer, midi); auto elapsed = (juce::Time::getMillisecondCounterHiRes() - startTime) / 1000.0; + if (isStopRequested()) + { + detail::logNewProgressMarkers(markers.builderRoot, seenProgressMarkers); + log::warn("HALion Lua build stopped by user request."); + return {.failureResult = RunResult::stopped}; + } + if (elapsed - lastMarkerPoll >= kMarkerPollIntervalSeconds) { detail::logNewProgressMarkers(markers.builderRoot, seenProgressMarkers); @@ -677,12 +684,6 @@ BuildWaitResult waitForBuildCompletion(juce::AudioPluginInstance& plugin, const lastProgressLog = elapsed; } - if (isStopRequested()) - { - log::warn("HALion Lua build stopped by user request."); - return {.failureResult = RunResult::stopped}; - } - if (options.timeoutSeconds > 0 && elapsed >= static_cast(options.timeoutSeconds)) { log::error("Timed out waiting for HALion build completion after {} seconds.", options.timeoutSeconds); @@ -718,6 +719,8 @@ bool cleanupPostReleaseMarkers(const BuildMarkerSet& markers, const RunResult re if (result == RunResult::success && !cleanupSuccessfulBuildMarkers(markers)) cleanupOk = false; + else if (result == RunResult::stopped && !deleteFileIfExists(markers.okMarkerFile, "OK build status marker from stopped run")) + cleanupOk = false; return cleanupOk; } @@ -1036,20 +1039,34 @@ RunResult Bridge::Impl::runDetailed(const AppOptions& options) for (size_t i = 0; i < slices.size(); ++i) { + if (isStopRequested()) + { + log::warn("HALion Lua build stopped by user request before starting build chunk {}/{}.", static_cast(i + 1), + static_cast(slices.size())); + return RunResult::stopped; + } + const auto& slice = slices[i]; - log::info("Starting build chunk {}/{}: scripts {}-{} of {}.", static_cast(i + 1), static_cast(slices.size()), slice.start, + log::info("Starting build chunk {}/{}: entries {}-{} of {}.", static_cast(i + 1), static_cast(slices.size()), slice.start, slice.end(), slice.total); const auto result = runSingleInvocation(options, runtimeRoot, slice); if (result == RunResult::success) { + if (isStopRequested()) + { + log::warn("HALion Lua build stopped by user request after build chunk {}/{}.", static_cast(i + 1), + static_cast(slices.size())); + return RunResult::stopped; + } + log::info("Build chunk {}/{} completed.", static_cast(i + 1), static_cast(slices.size())); continue; } ++failedChunks; lastFailure = result; - log::error("Build chunk {}/{} failed; scripts {}-{} were not completed successfully.", static_cast(i + 1), + log::error("Build chunk {}/{} failed; entries {}-{} were not completed successfully.", static_cast(i + 1), static_cast(slices.size()), slice.start, slice.end()); if (options.failFast || !isRecoverableChunkFailure(result)) @@ -1340,6 +1357,9 @@ RunResult Bridge::Impl::runProcessingLoop(const AppOptions& options, const juce: if (markersToClean != nullptr && !cleanupPostReleaseMarkers(*markersToClean, result) && result == RunResult::success) return RunResult::cleanupFailed; + log::debug("Unloading plugin instance..."); + pluginInstance = nullptr; + return result; }; diff --git a/tests/Tests.cpp b/tests/Tests.cpp index 8df6cb6..3257f54 100644 --- a/tests/Tests.cpp +++ b/tests/Tests.cpp @@ -287,7 +287,7 @@ class BridgeTests : public juce::UnitTest expect(options.has_value()); if (options) { - expectEquals(options->buildChunkSize, 1); + expectEquals(options->buildChunkSize, 15); expect(!options->failFast); } } @@ -808,7 +808,7 @@ class BridgeTests : public juce::UnitTest auto* diagnostics = static_cast*>(userData); diagnostics->push_back(diagnostic); }, - &streamedDiagnostics}; + nullptr, &streamedDiagnostics}; auto streamArgs = std::vector{sourceDir.getFullPathName().toStdString(), "--overwrite"}; result = converter->runWithContext(std::span{streamArgs.data(), streamArgs.size()}, context); expectEquals(result.exitCode, 0); @@ -877,6 +877,28 @@ class BridgeTests : public juce::UnitTest sourceDir.deleteRecursively(); } + beginTest("SFZ Converter - stop request aborts before writing build files"); + { + auto sourceDir = cleanTempDirectory("halionbridge_sfz_stopped_source"); + expect(sourceDir.createDirectory()); + expect(sourceDir.getChildFile("sample.wav").replaceWithText("")); + expect(sourceDir.getChildFile("instrument.sfz") + .replaceWithText(" sample=sample.wav lokey=60 hikey=60 lovel=0 hivel=127 pitch_keycenter=60\n")); + + auto context = halionbridge::converters::ConverterRunContext{nullptr, [](void*) { return true; }, nullptr}; + auto options = halionbridge::converters::sfz::ConversionOptions{}; + options.sourceDirectory = halionbridge::detail::toStdPath(sourceDir); + options.outputDirectory = options.sourceDirectory; + options.context = &context; + + const auto result = halionbridge::converters::sfz::convertDirectory(options); + expect(!result.succeeded); + expect(containsDiagnosticCode(result.diagnostics, "stopped")); + expect(!sourceDir.getChildFile("halionbridge_build.lua").existsAsFile()); + + sourceDir.deleteRecursively(); + } + beginTest("SFZ Converter - rejects duplicate preset output names"); { auto sourceDir = cleanTempDirectory("halionbridge_sfz_duplicate_presets"); @@ -1534,6 +1556,8 @@ class BridgeTests : public juce::UnitTest expect(firstLua.contains("hb.append_sample_zone(ctx, layer, region)")); expect(firstLua.contains("hb.save_layer_preset(ctx, layer, outputFile)")); expect(firstLua.contains("local progressInterval = 25")); + expect(firstLua.contains("if i == 1 then")); + expect(!firstLua.contains("((i - 1) % progressInterval)")); expect(!firstLua.contains("ctx.yield")); expect(!firstLua.contains("wait(")); expect(!firstLua.contains("local function setNameIfAvailable")); @@ -1728,6 +1752,10 @@ class BridgeTests : public juce::UnitTest juce::File::getCurrentWorkingDirectory().getChildFile("halion-lua").getChildFile("builder.lua").loadFileAsString(); expect(builderLua.contains("BUILD_SCRIPT_TIMEOUT_MS = 600000")); expect(builderLua.contains("setScriptExecTimeOut")); + expect(builderLua.contains("Completed %d/%d files")); + expect(builderLua.contains("Processing %d/%d: %s")); + expect(builderLua.contains("TODO: Re-enable numeric progress")); + expect(builderLua.contains("emit(\"info\", tostring(message))")); expect(!builderLua.contains("function context.yield")); }