From 0bc75f3aec6c440e41300e4716206d9bcbba21c6 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:00:27 +0200 Subject: [PATCH 01/18] test: scaffold pedal multidelay audition runner --- .../pedal_multidelay_audition/CMakeLists.txt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 DaisyHost/tools/pedal_multidelay_audition/CMakeLists.txt diff --git a/DaisyHost/tools/pedal_multidelay_audition/CMakeLists.txt b/DaisyHost/tools/pedal_multidelay_audition/CMakeLists.txt new file mode 100644 index 000000000..4acfe2757 --- /dev/null +++ b/DaisyHost/tools/pedal_multidelay_audition/CMakeLists.txt @@ -0,0 +1,46 @@ +cmake_minimum_required(VERSION 3.20) + +project(PedalMultiDelayAudition LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_executable(pedal_multidelay_audition + main.cpp + ../../src/PedalDelayEngine.cpp +) + +target_include_directories(pedal_multidelay_audition + PRIVATE + ../../include +) + +if(MSVC) + target_compile_options(pedal_multidelay_audition PRIVATE /W4 /permissive-) +else() + target_compile_options(pedal_multidelay_audition PRIVATE -Wall -Wextra -Wpedantic) +endif() + +enable_testing() + +set(PEDAL_MULTIDELAY_AUDITION_MATRIX + "${CMAKE_CURRENT_LIST_DIR}/../../training/examples/pedal_multidelay_audition_matrix.csv" +) +set(PEDAL_MULTIDELAY_AUDITION_OUTPUT + "${CMAKE_BINARY_DIR}/audition_artifacts" +) + +add_test( + NAME PedalMultiDelayAuditionMatrix + COMMAND + pedal_multidelay_audition + --matrix "${PEDAL_MULTIDELAY_AUDITION_MATRIX}" + --output-dir "${PEDAL_MULTIDELAY_AUDITION_OUTPUT}" +) +set_tests_properties( + PedalMultiDelayAuditionMatrix + PROPERTIES + TIMEOUT 300 + LABELS "pedal_multidelay;audition;host-only" +) From 5b0102a3bfaba95d7f7e66dd876ac1895ead40f6 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:00:42 +0200 Subject: [PATCH 02/18] test: add fifteen pedal multidelay audition cells --- .../pedal_multidelay_audition_matrix.csv | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv diff --git a/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv b/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv new file mode 100644 index 000000000..fbddb277c --- /dev/null +++ b/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv @@ -0,0 +1,16 @@ +scenario,mode,cell,source,duration_s,time,feedback,mix,color,motion,events,expect_tail +pedal_multidelay_audition_digi_reference,DIGI,reference,pluck_train,4.0,0.45,0.35,0.50,0.78,0.00,,0 +pedal_multidelay_audition_digi_character,DIGI,character,transient_train,4.0,0.30,0.82,0.72,1.00,0.00,,0 +pedal_multidelay_audition_digi_stress,DIGI,stress,sine,4.0,0.05,0.92,0.80,0.95,0.00,1.000:SLOT:TIME:0.95|2.000:SLOT:TIME:0.10|3.000:SLOT:TIME:0.85,0 +pedal_multidelay_audition_tape_reference,TAPE,reference,pluck_train,4.5,0.42,0.45,0.55,0.20,0.12,,0 +pedal_multidelay_audition_tape_character,TAPE,character,dual_tone,4.5,0.50,0.70,0.70,0.75,0.65,,0 +pedal_multidelay_audition_tape_stress,TAPE,stress,sine,4.5,0.15,0.95,0.85,1.00,1.00,1.200:SLOT:TIME:0.85|2.400:SLOT:TIME:0.08|3.600:SLOT:TIME:0.75,0 +pedal_multidelay_audition_mod_reference,MOD,reference,dual_tone,4.0,0.08,0.70,0.50,0.45,0.18,,0 +pedal_multidelay_audition_mod_character,MOD,character,pluck_train,4.0,0.42,0.50,0.88,0.70,0.45,,0 +pedal_multidelay_audition_mod_stress,MOD,stress,sine,4.0,0.10,1.00,1.00,1.00,1.00,,0 +pedal_multidelay_audition_rev_reference,REV,reference,transient_train,4.5,0.25,0.25,0.70,1.00,0.75,,0 +pedal_multidelay_audition_rev_character,REV,character,pluck_train,5.0,0.65,0.55,0.80,1.00,0.90,,0 +pedal_multidelay_audition_rev_stress,REV,stress,transient_train,4.5,0.05,1.00,1.00,1.00,0.00,1.500:SLOT:TIME:0.90|3.000:SLOT:TIME:0.08,0 +pedal_multidelay_audition_freeze_reference,FREEZE,reference,single_pluck,4.5,0.38,0.96,0.75,0.18,0.05,0.050:FREEZE:CAPTURE,1 +pedal_multidelay_audition_freeze_character,FREEZE,character,single_pluck,5.0,0.45,0.98,0.85,0.65,0.70,0.050:FREEZE:CAPTURE|1.500:FREEZE:HOLD,1 +pedal_multidelay_audition_freeze_stress,FREEZE,stress,pluck_train,5.5,0.25,1.00,0.90,0.85,1.00,0.050:FREEZE:CAPTURE|1.200:FREEZE:ACCUMULATE|2.400:FREEZE:REPLACE|3.800:FREEZE:CLEAR,0 From 8dae867f893091a62826426300371e9b0ee820d4 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:01:14 +0200 Subject: [PATCH 03/18] docs: define pedal audition evidence boundary --- .../tools/pedal_multidelay_audition/README.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 DaisyHost/tools/pedal_multidelay_audition/README.md diff --git a/DaisyHost/tools/pedal_multidelay_audition/README.md b/DaisyHost/tools/pedal_multidelay_audition/README.md new file mode 100644 index 000000000..7ffaae4b6 --- /dev/null +++ b/DaisyHost/tools/pedal_multidelay_audition/README.md @@ -0,0 +1,154 @@ +# Pedal Multi-Delay Audition Runner + +This bounded host tool renders and profiles the current `PedalDelayEngine` +without involving JUCE, the DaisyHost editor, the live plugin processor, or a +Daisy target adapter. + +Its purpose is to create a deterministic **host audition package** for the five +compact pedal modes: + +- `DIGI` +- `TAPE` +- `MOD` +- `REV` +- `FREEZE` + +Each mode has three cells in +`DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv`: + +- `reference` +- `character` +- `stress` + +The generated package contains 15 stereo float WAV files, one JSON evidence +record per cell, `audition_manifest.csv`, `host_profile.csv`, and +`host_profile_environment.json`. + +## Build and run + +From the repository root: + +```sh +cmake -S DaisyHost/tools/pedal_multidelay_audition \ + -B DaisyHost/build-audition \ + -DCMAKE_BUILD_TYPE=Release +cmake --build DaisyHost/build-audition --config Release +ctest --test-dir DaisyHost/build-audition \ + -C Release \ + --output-on-failure +``` + +Or invoke the executable directly: + +```sh +DaisyHost/build-audition/pedal_multidelay_audition \ + --matrix DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv \ + --output-dir DaisyHost/build-audition/audition_artifacts +``` + +The exact executable path is generator-dependent on multi-config platforms. + +## Render evidence + +The sources are deterministic synthetic engineering signals, not licensed +instrument performances and not disguised guitar listening tests: + +- `pluck_train`: repeated decaying noise/harmonic excitation; +- `single_pluck`: one decaying excitation followed by silence; +- `transient_train`: alternating narrow transients; +- `sine`: steady 220 Hz diagnostic tone; +- `dual_tone`: deterministic 165/247.5 Hz diagnostic pair. + +A render cell passes only when: + +- every output sample is finite; +- output is non-silent when non-silence is expected; +- peak magnitude remains below the bounded host sanity limit; +- freeze reference/character cells retain measurable tail energy; +- WAV and machine-readable evidence are emitted. + +These checks can reject broken output. They do not certify musical quality. + +## Host profiling contract + +The profiler measures the engine-only block call using +`std::chrono::steady_clock` in Release configuration. + +```text +block_budget_us = + 1'000'000 * block_frames_per_channel / sample_rate_hz + +margin_p99_9_us = + block_budget_us - p99_9_us + +tail_utilization_ratio = + p99_9_us / block_budget_us + +deadline_miss_count = + count(observed_block_us > block_budget_us) +``` + +Per mode it records: + +- arithmetic mean; +- p99; +- p99.9; +- observed maximum, explicitly not WCET; +- p99.9 margin and utilization; +- observed deadline misses and maximum lateness; +- warm-up count, measured count, measured audio duration; +- timer-bracket overhead and clock identity; +- non-finite processing failures. + +The bounded capture uses 1,000 warm-up blocks and 50,000 measured blocks. +Percentiles are computed after collection with `std::nth_element`; no sorting, +formatting, file I/O, logging, or container growth occurs inside the measured +engine call. + +Allowed labels are: + +- `HOST_OBSERVED_WITHIN_BUDGET` +- `HOST_OBSERVED_TAIL_VIOLATION` +- `INSUFFICIENT_SAMPLE_COUNT` +- `HOST_MEASUREMENT_INVALID` + +None means `SAFE_REAL_TIME`. + +## Evidence boundary + +A successful run may report: + +```text +HOST_AUDITION_PACKAGE_READY +HOST_LISTENING_PENDING +TARGET_TIMING_NOT_RUN +``` + +It may not report: + +- `HOST_LISTENED_ACCEPTED` without a recorded human listening session; +- Cortex-M7 timing or callback margin; +- target SDRAM/cache behavior; +- target-build or firmware-toolchain compatibility; +- physical audio, controls, electrical, mechanical, or production readiness. + +`PedalDelayEngine` remains a DaisyHost audition/reference implementation. +Current portable product and target authority remains in the originating +portable-DSP/firmware repositories. + +## Current real-time safety hold + +This runner calls `PedalDelayEngine` directly and therefore does not exercise +the `PedalDelayCore` host wrapper. At the inspected baseline, the wrapper has a +scratch-buffer grow fallback in `Process()` when `frameCount` exceeds the +prepared capacity. Until that shared wrapper path is coordinated, repaired, +and tested, use: + +```text +ENGINE_PROCESS_ALLOCATION_FREE: VERIFIED_SOURCE +CORE_WRAPPER_ALLOCATION_GUARANTEE: HOLD +``` + +The runner does not launder that known wrapper gap into an allocation-free +claim for the complete live DaisyHost path. A spreadsheet could probably be +persuaded to do so, but this tool has standards. From 2833eacc9ce2c68028d404b815736b84335ed324 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:01:59 +0200 Subject: [PATCH 04/18] docs: apply corrected multidelay assembly contract --- ...l-multidelay-audition-work-package-v0.2.md | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 DaisyHost/docs/pedal-multidelay-audition-work-package-v0.2.md diff --git a/DaisyHost/docs/pedal-multidelay-audition-work-package-v0.2.md b/DaisyHost/docs/pedal-multidelay-audition-work-package-v0.2.md new file mode 100644 index 000000000..c9d50b5f7 --- /dev/null +++ b/DaisyHost/docs/pedal-multidelay-audition-work-package-v0.2.md @@ -0,0 +1,333 @@ +# Multi-Delay DSP / Algorithm Assembly Work Package + +Version: `0.2-candidate` +Primary lane: `Delay pedal / DaisyHost audition model / DSP behavior characterization` +Secondary handoff: `portable-core and firmware-portability findings only` +Host target: `DaisyHost/PedalDelayEngine` +Target state: `HOST_AUDITION_PACKAGE_READY` + +## Manager summary + +This work package makes the existing five-mode pedal engine reproducibly +renderable and measurable on a desktop. It is intended to expose behavior for +engineering review and human audition without pretending that a Windows or +Linux timing result is a Cortex-M7 result. + +The bounded implementation adds: + +- 15 deterministic audition cells, three per mode; +- headless float-WAV and JSON evidence generation; +- finite/non-silent/tail sanity gates; +- bounded host timing capture with mean, p99, p99.9, maximum, margins and + observed deadline misses; +- explicit host, listening, portability and target evidence boundaries. + +It does not redesign the UI, migrate the product DSP, or claim a physical +pedal result. + +## Source-authority boundary + +`DaisyHost/PedalDelayEngine` is a host audition and behavioral-reference +implementation. + +It is not the current portable product DSP authority and it is not the current +Daisy target runtime. Host behavior must not silently supersede, fork, or claim +parity with the portable implementation in the originating product DSP +repository. + +Classify every useful host result as one of: + +- `HOST_ONLY_REFERENCE` +- `BEHAVIOR_CONTRACT_CANDIDATE` +- `PORTABILITY_RISK` +- `PORTABLE_CORE_HANDOFF_REQUIRED` +- `REJECTED_FOR_TARGET_PATH` + +Do not copy DaisyHost source into the portable repository in this work +package. Produce a bounded handoff instead. + +## Public contract + +Keep stable: + +- app id `pedal_multidelay`; +- mode identities and order: `DIGI`, `TAPE`, `MOD`, `REV`, `FREEZE`; +- physical slot order: `TIME`, `FEEDBACK`, `MIX`, `COLOR`, `MOTION`; +- freeze operations: `capture`, `hold`, `accumulate`, `replace`, `clear`; +- bypass/trails and tap/hold semantics; +- canonical parameter ids; +- `PedalSlotDescriptor` schema and meaning. + +No public interface change is required by this bounded runner. + +## Audition-readiness meaning + +`HOST_AUDITION_PACKAGE_READY` means: + +- deterministic render definitions exist; +- all expected output is finite and non-silent; +- machine-readable metrics and checksums exist; +- output WAVs are ready for a human listening pass; +- listening instructions and evidence boundaries are explicit. + +It does not mean that anyone listened. + +`HOST_LISTENED_ACCEPTED` may be reported only after an actual listening +session records: + +- listener; +- source material and exact rendered files; +- monitoring chain; +- level/configuration; +- observations and disposition. + +Numeric analysis can reject a candidate. It cannot positively certify musical +quality. + +## Audition matrix + +The source-of-run matrix is: + +```text +DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv +``` + +Required cells: + +```text +DIGI reference / character / stress +TAPE reference / character / stress +MOD reference / character / stress +REV reference / character / stress +FREEZE reference / character / stress +``` + +Every row declares: + +- unique `pedal_multidelay_audition_*` scenario id; +- mode and cell; +- deterministic synthetic source; +- duration; +- five normalized slot values; +- bounded parameter/freeze event sequence; +- whether retained freeze-tail energy is expected. + +The runner records normalized and native values in the output manifest. + +## Render acceptance + +Per cell: + +- sample rate: 48 kHz; +- block size: 48 frames per channel; +- output: stereo IEEE-float WAV; +- finite output required; +- non-silent output required where expected; +- bounded host peak sanity check; +- FNV-1a 64-bit deterministic checksum; +- peak, RMS, final-window RMS, maximum adjacent step and stereo-difference RMS; +- final freeze state when applicable. + +The sources are engineering stimuli, not licensed recordings and not synthetic +signals mislabeled as guitar listening evidence. + +## Correct host timing model + +```text +block_budget_us = + 1'000'000.0 * block_frames_per_channel / sample_rate_hz + +mean_us = + arithmetic mean of post-warm-up observed blocks + +p99_us = + observed 99.0th percentile + +p99_9_us = + observed 99.9th percentile + +max_us = + observed maximum, NOT WCET + +margin_p99_9_us = + block_budget_us - p99_9_us + +tail_utilization_ratio = + p99_9_us / block_budget_us + +deadline_miss_count = + count(observed_block_us > block_budget_us) + +deadline_miss_rate = + deadline_miss_count / measured_block_count + +max_lateness_us = + max(0.0, max_us - block_budget_us) +``` + +`tail_utilization_ratio` is not a probability. + +Do not label `p99_9_us < block_budget_us` as `SAFE_REAL_TIME`. Allowed host +labels are: + +- `HOST_OBSERVED_WITHIN_BUDGET` +- `HOST_OBSERVED_TAIL_VIOLATION` +- `INSUFFICIENT_SAMPLE_COUNT` +- `HOST_MEASUREMENT_INVALID` + +A host run is comparative/regression evidence only. + +## Clock and bounded statistics + +Host timing uses `std::chrono::steady_clock` and records: + +- whether the clock reports monotonic behavior; +- nominal period; +- minimum empty timing-bracket overhead; +- compiler and host OS; +- sample rate, block size, warm-up count and measured count. + +Target timing must use the target profiler selected by the authoritative +firmware implementation, such as DWT `CYCCNT`. Target profiling is `NOT_RUN` +in this work package. + +The bounded host capture uses: + +```text +warmup_blocks = 1,000 +measured_blocks = 50,000 per mode +``` + +Percentiles are computed after collection with `std::nth_element`. No +allocation, sorting, formatting, logging or file I/O occurs inside the measured +engine call. + +## WCET and sensitivity language + +No quantity in this host tool is WCET. + +An optional future engineering estimate may be called: + +```text +tail_bound_proxy_us = p99_9_us + configured_tail_margin_us +``` + +It must record the margin source and remain explicitly `NOT_WCET`. + +Cache, contention or scheduling-delay injection, if later added, must be +reported separately as: + +```text +HOST_SENSITIVITY_MODEL +UNCALIBRATED_SCENARIO_PROJECTION +``` + +It must never be merged into measured timing or called embedded simulation. +The current runner leaves this model disabled. + +## Real-time safety exception + +At the inspected baseline, `PedalDelayEngine::Process()` itself has external +storage and no heap-growth path. The live `PedalDelayCore::Process()` wrapper, +however, contains a scratch-vector grow fallback when a callback exceeds the +prepared block capacity. + +This bounded work package does not edit the shared wrapper while other +DaisyHost surfaces may depend on it. Until a coordinated repair and negative +test are complete, report: + +```text +ENGINE_PROCESS_ALLOCATION_FREE: VERIFIED_SOURCE +CORE_WRAPPER_ALLOCATION_GUARANTEE: HOLD +RT_SAFETY_EXCEPTION_REQUIRED: YES +``` + +Smallest safe follow-up: + +1. preallocate wrapper scratch storage before audio starts; +2. store the prepared maximum block size; +3. fail closed for an oversized callback rather than grow a vector; +4. add a negative test proving no allocation/growth occurs in `Process()`; +5. rerun the focused and full DaisyHost host gates. + +## Portability review + +The current host engine uses an embedded-friendly data model in several areas: + +- explicit external long-buffer ownership; +- fixed-size state arrays; +- float sample processing; +- bounded sample loops; +- deterministic reset; +- no JUCE dependency in the engine. + +Record these portability risks separately: + +- DaisyHost builds as C++17 while current Daisy firmware paths may use C++14; +- `std::clamp` and `std::string_view` require toolchain review; +- per-sample `sin`, `cos`, `pow`, `tanh` and `fmod` calls require target-cost + profiling; +- host vectors and maps remain in wrappers outside the engine; +- host RAM behavior does not prove SDRAM/cache suitability. + +Do not perform a speculative migration or sound-changing optimization before +actual target profiling. + +## Generated evidence + +A successful run emits: + +```text +audition_artifacts/ + pedal_multidelay_audition_*.wav # 15 files + pedal_multidelay_audition_*.json # 15 files + audition_manifest.csv + host_profile.csv + host_profile_environment.json +``` + +The program prints: + +```text +HOST_AUDITION_PACKAGE_READY +HOST_LISTENING_PENDING +TARGET_TIMING_NOT_RUN +``` + +## Verification commands + +```sh +cmake -S DaisyHost/tools/pedal_multidelay_audition \ + -B DaisyHost/build-audition \ + -DCMAKE_BUILD_TYPE=Release +cmake --build DaisyHost/build-audition --config Release +ctest --test-dir DaisyHost/build-audition \ + -C Release \ + --output-on-failure +``` + +GitHub Actions runs the same bounded subproject and uploads the evidence +package. + +## Not claimed + +- human listening acceptance; +- complete live-wrapper allocation safety; +- full DaisyHost/JUCE/plugin gate from the bounded subproject alone; +- ARM build or target-toolchain compatibility; +- Cortex-M7 average, p99.9, maximum or overruns; +- SDRAM/cache timing; +- physical audio, controls, analog I/O, power, EMI/EMC or mechanics; +- product or production readiness. + +## Completion disposition + +This work package is `PASS` only for the bounded host audition/profiling tool +when all 15 cells and profiler-integrity checks pass. + +The broader multi-delay workstream remains `PARTIAL` while: + +- human listening is pending; +- the live wrapper allocation guarantee is `HOLD`; +- target profiling and physical audio remain `NOT_RUN`; +- portable-product parity remains unverified. From 3346224dd989542cf0f157b0056dd823948fc3f9 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:02:17 +0200 Subject: [PATCH 05/18] ci: add bounded pedal audition and profiling gate --- .../workflows/daisyhost-pedal-audition.yml | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/daisyhost-pedal-audition.yml diff --git a/.github/workflows/daisyhost-pedal-audition.yml b/.github/workflows/daisyhost-pedal-audition.yml new file mode 100644 index 000000000..4ffb206a9 --- /dev/null +++ b/.github/workflows/daisyhost-pedal-audition.yml @@ -0,0 +1,81 @@ +name: DaisyHost Pedal Multi-Delay Audition + +on: + workflow_dispatch: + push: + branches: + - "codex/pedal-multidelay-audition-*" + paths: + - ".github/workflows/daisyhost-pedal-audition.yml" + - "DaisyHost/include/daisyhost/PedalDelayEngine.h" + - "DaisyHost/src/PedalDelayEngine.cpp" + - "DaisyHost/tools/pedal_multidelay_audition/**" + - "DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv" + - "DaisyHost/docs/pedal-multidelay-audition-work-package-v0.2.md" + pull_request: + paths: + - ".github/workflows/daisyhost-pedal-audition.yml" + - "DaisyHost/include/daisyhost/PedalDelayEngine.h" + - "DaisyHost/src/PedalDelayEngine.cpp" + - "DaisyHost/tools/pedal_multidelay_audition/**" + - "DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv" + - "DaisyHost/docs/pedal-multidelay-audition-work-package-v0.2.md" + +permissions: + contents: read + +jobs: + host-audition: + name: Host audition package + runs-on: ubuntu-24.04 + timeout-minutes: 12 + + steps: + - name: Checkout exact revision + uses: actions/checkout@v4 + + - name: Configure bounded runner + run: >- + cmake + -S DaisyHost/tools/pedal_multidelay_audition + -B DaisyHost/build-audition + -DCMAKE_BUILD_TYPE=Release + + - name: Build bounded runner + run: cmake --build DaisyHost/build-audition --config Release --parallel 2 + + - name: Execute audition and host profiling + run: >- + ctest + --test-dir DaisyHost/build-audition + -C Release + --output-on-failure + --verbose + + - name: Verify evidence inventory + shell: bash + run: | + set -euo pipefail + root="DaisyHost/build-audition/audition_artifacts" + test -f "$root/audition_manifest.csv" + test -f "$root/host_profile.csv" + test -f "$root/host_profile_environment.json" + + wav_count="$(find "$root" -maxdepth 1 -type f -name 'pedal_multidelay_audition_*.wav' | wc -l)" + json_count="$(find "$root" -maxdepth 1 -type f -name 'pedal_multidelay_audition_*.json' | wc -l)" + pass_count="$(awk -F, 'NR > 1 && $NF == "PASS" { count++ } END { print count + 0 }' "$root/audition_manifest.csv")" + + test "$wav_count" -eq 15 + test "$json_count" -eq 15 + test "$pass_count" -eq 15 + + grep -q 'p99_9_us' "$root/host_profile.csv" + grep -q 'target_timing_claim.*NOT_RUN' "$root/host_profile_environment.json" + + - name: Upload host audition evidence + uses: actions/upload-artifact@v4 + with: + name: pedal-multidelay-host-audition + path: DaisyHost/build-audition/audition_artifacts/ + if-no-files-found: error + retention-days: 14 From 2b058427e3b6f4ddc8361a84d071a15239466f4a Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:05:02 +0200 Subject: [PATCH 06/18] feat: implement deterministic pedal audition and host profiler --- .../tools/pedal_multidelay_audition/main.cpp | 1144 +++++++++++++++++ 1 file changed, 1144 insertions(+) create mode 100644 DaisyHost/tools/pedal_multidelay_audition/main.cpp diff --git a/DaisyHost/tools/pedal_multidelay_audition/main.cpp b/DaisyHost/tools/pedal_multidelay_audition/main.cpp new file mode 100644 index 000000000..a6060f7cc --- /dev/null +++ b/DaisyHost/tools/pedal_multidelay_audition/main.cpp @@ -0,0 +1,1144 @@ +#include "daisyhost/PedalDelayEngine.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace +{ +using daisyhost::PedalDelayEngine; +using daisyhost::PedalDelayMode; +using daisyhost::PedalFreezeState; +using daisyhost::PedalSlot; + +constexpr double kSampleRate = 48000.0; +constexpr std::size_t kBlockSize = 48; +constexpr std::size_t kProfileWarmupBlocks = 1000; +constexpr std::size_t kProfileMeasuredBlocks = 50000; +constexpr double kMinimumAudibleRms = 1.0e-6; +constexpr double kMinimumTailRms = 1.0e-6; +constexpr double kMaximumAllowedPeak = 20.0; +constexpr double kPi = 3.14159265358979323846; + +std::string Trim(std::string value) +{ + const auto first = value.find_first_not_of(" \t\r\n"); + if(first == std::string::npos) + { + return {}; + } + const auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); +} + +std::string ToUpper(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::toupper(c)); + }); + return value; +} + +std::vector Split(const std::string& value, char delimiter) +{ + std::vector fields; + std::string field; + std::istringstream stream(value); + while(std::getline(stream, field, delimiter)) + { + fields.push_back(Trim(field)); + } + if(!value.empty() && value.back() == delimiter) + { + fields.emplace_back(); + } + return fields; +} + +float ParseFloat(const std::string& text, const std::string& fieldName) +{ + std::size_t consumed = 0; + const float value = std::stof(text, &consumed); + if(consumed != text.size() || !std::isfinite(value)) + { + throw std::runtime_error("Invalid " + fieldName + ": " + text); + } + return value; +} + +double ParseDouble(const std::string& text, const std::string& fieldName) +{ + std::size_t consumed = 0; + const double value = std::stod(text, &consumed); + if(consumed != text.size() || !std::isfinite(value)) + { + throw std::runtime_error("Invalid " + fieldName + ": " + text); + } + return value; +} + +PedalDelayMode ParseMode(const std::string& text) +{ + const std::string value = ToUpper(text); + if(value == "DIGI") + { + return PedalDelayMode::kDigi; + } + if(value == "TAPE") + { + return PedalDelayMode::kTape; + } + if(value == "MOD") + { + return PedalDelayMode::kMod; + } + if(value == "REV") + { + return PedalDelayMode::kRev; + } + if(value == "FREEZE") + { + return PedalDelayMode::kFreeze; + } + throw std::runtime_error("Unknown mode: " + text); +} + +PedalSlot ParseSlot(const std::string& text) +{ + const std::string value = ToUpper(text); + if(value == "TIME") + { + return PedalSlot::kTime; + } + if(value == "FEEDBACK") + { + return PedalSlot::kFeedback; + } + if(value == "MIX") + { + return PedalSlot::kMix; + } + if(value == "COLOR") + { + return PedalSlot::kColor; + } + if(value == "MOTION") + { + return PedalSlot::kMotion; + } + throw std::runtime_error("Unknown slot: " + text); +} + +PedalFreezeState ParseFreezeState(const std::string& text) +{ + const std::string value = ToUpper(text); + if(value == "CAPTURE") + { + return PedalFreezeState::kCapture; + } + if(value == "HOLD") + { + return PedalFreezeState::kHold; + } + if(value == "ACCUMULATE") + { + return PedalFreezeState::kAccumulate; + } + if(value == "REPLACE") + { + return PedalFreezeState::kReplace; + } + if(value == "IDLE") + { + return PedalFreezeState::kIdle; + } + throw std::runtime_error("Unknown freeze state: " + text); +} + +struct Event +{ + enum class Kind + { + kSlot, + kFreezeState, + kFreezeClear, + }; + + std::size_t frame = 0; + Kind kind = Kind::kSlot; + PedalSlot slot = PedalSlot::kTime; + float value = 0.0f; + PedalFreezeState freezeState = PedalFreezeState::kIdle; +}; + +struct Scenario +{ + std::string name; + PedalDelayMode mode = PedalDelayMode::kDigi; + std::string cell; + std::string source; + double durationSeconds = 0.0; + std::array slots{}; + std::string eventText; + std::vector events; + bool expectTail = false; +}; + +std::vector ParseEvents(const std::string& eventText) +{ + std::vector events; + if(Trim(eventText).empty()) + { + return events; + } + + for(const std::string& token : Split(eventText, '|')) + { + const auto parts = Split(token, ':'); + if(parts.size() < 3) + { + throw std::runtime_error("Malformed event: " + token); + } + + const double timeSeconds = ParseDouble(parts[0], "event time"); + Event event; + const double exactFrame = timeSeconds * kSampleRate; + event.frame = static_cast( + std::llround(exactFrame / static_cast(kBlockSize))) + * kBlockSize; + + const std::string kind = ToUpper(parts[1]); + if(kind == "SLOT") + { + if(parts.size() != 4) + { + throw std::runtime_error("Malformed slot event: " + token); + } + event.kind = Event::Kind::kSlot; + event.slot = ParseSlot(parts[2]); + event.value = std::clamp(ParseFloat(parts[3], "slot value"), 0.0f, 1.0f); + } + else if(kind == "FREEZE") + { + const std::string operation = ToUpper(parts[2]); + if(operation == "CLEAR") + { + event.kind = Event::Kind::kFreezeClear; + } + else + { + event.kind = Event::Kind::kFreezeState; + event.freezeState = ParseFreezeState(operation); + } + } + else + { + throw std::runtime_error("Unknown event kind: " + parts[1]); + } + events.push_back(event); + } + + std::sort(events.begin(), events.end(), [](const Event& a, const Event& b) { + return a.frame < b.frame; + }); + return events; +} + +std::vector ReadMatrix(const fs::path& path) +{ + std::ifstream input(path); + if(!input) + { + throw std::runtime_error("Unable to open matrix: " + path.string()); + } + + std::vector scenarios; + std::string line; + std::size_t lineNumber = 0; + while(std::getline(input, line)) + { + ++lineNumber; + const std::string trimmed = Trim(line); + if(lineNumber == 1 || trimmed.empty() || trimmed.front() == '#') + { + continue; + } + + const auto fields = Split(line, ','); + if(fields.size() != 12) + { + throw std::runtime_error("Matrix line " + std::to_string(lineNumber) + + " has " + std::to_string(fields.size()) + + " fields; expected 12"); + } + + Scenario scenario; + scenario.name = fields[0]; + scenario.mode = ParseMode(fields[1]); + scenario.cell = fields[2]; + scenario.source = fields[3]; + scenario.durationSeconds = ParseDouble(fields[4], "duration"); + for(std::size_t slot = 0; slot < scenario.slots.size(); ++slot) + { + scenario.slots[slot] + = std::clamp(ParseFloat(fields[5 + slot], "normalized slot"), + 0.0f, + 1.0f); + } + scenario.eventText = fields[10]; + scenario.events = ParseEvents(scenario.eventText); + scenario.expectTail = ParseFloat(fields[11], "expect_tail") >= 0.5f; + + if(scenario.name.empty() || scenario.durationSeconds <= 0.0) + { + throw std::runtime_error("Invalid scenario at line " + + std::to_string(lineNumber)); + } + scenarios.push_back(std::move(scenario)); + } + + if(scenarios.size() != 15) + { + throw std::runtime_error("Audition matrix must contain exactly 15 cells; got " + + std::to_string(scenarios.size())); + } + return scenarios; +} + +class SourceGenerator +{ + public: + SourceGenerator(std::string source, std::uint32_t seed) + : source_(std::move(source)), state_(seed == 0 ? 1U : seed) + { + } + + float Next(std::size_t sampleIndex) + { + const double t = static_cast(sampleIndex) / kSampleRate; + if(source_ == "sine") + { + return 0.45f * static_cast(std::sin(2.0 * kPi * 220.0 * t)); + } + if(source_ == "dual_tone") + { + return 0.28f * static_cast(std::sin(2.0 * kPi * 165.0 * t)) + + 0.14f + * static_cast(std::sin(2.0 * kPi * 247.5 * t)); + } + if(source_ == "transient_train") + { + const double local = std::fmod(t, 0.40); + const float sign = (static_cast(t / 0.40) & 1U) == 0U + ? 1.0f + : -1.0f; + return sign * 0.9f * static_cast(std::exp(-180.0 * local)); + } + if(source_ == "single_pluck") + { + return t < 0.18 ? Pluck(t) : 0.0f; + } + if(source_ == "pluck_train") + { + const double local = std::fmod(t, 0.80); + return local < 0.18 ? Pluck(local) : 0.0f; + } + throw std::runtime_error("Unknown source: " + source_); + } + + private: + float RandomSigned() + { + state_ = state_ * 1664525U + 1013904223U; + const float unit = static_cast((state_ >> 8U) & 0x00FFFFFFU) + / static_cast(0x01000000U); + return unit * 2.0f - 1.0f; + } + + float Pluck(double local) + { + const float envelope = static_cast(std::exp(-24.0 * local)); + const float harmonic + = 0.34f * static_cast(std::sin(2.0 * kPi * 110.0 * local)) + + 0.18f + * static_cast(std::sin(2.0 * kPi * 220.0 * local)); + return envelope * (0.48f * RandomSigned() + harmonic); + } + + std::string source_; + std::uint32_t state_; +}; + +struct EngineFixture +{ + PedalDelayEngine engine; + std::vector history; + std::vector freeze; + + EngineFixture() + { + const std::size_t historySamples + = PedalDelayEngine::HistorySamplesForRate(kSampleRate); + const std::size_t freezeSamples + = PedalDelayEngine::FreezeSamplesForRate(kSampleRate); + history.assign(historySamples * daisyhost::kPedalChannelCount, 0.0f); + freeze.assign(freezeSamples * daisyhost::kPedalChannelCount, 0.0f); + engine.AttachStorage( + history.data(), historySamples, freeze.data(), freezeSamples); + engine.Prepare(kSampleRate, kBlockSize); + } +}; + +void ApplyScenarioState(EngineFixture& fixture, const Scenario& scenario) +{ + fixture.engine.SetMode(scenario.mode); + for(std::size_t slot = 0; slot < scenario.slots.size(); ++slot) + { + if(!fixture.engine.SetSlotNormalized(static_cast(slot), + scenario.slots[slot])) + { + throw std::runtime_error("Failed to set slot for " + scenario.name); + } + } + fixture.engine.Reset(); +} + +void ApplyEvent(PedalDelayEngine& engine, const Event& event) +{ + switch(event.kind) + { + case Event::Kind::kSlot: + engine.SetSlotNormalized(event.slot, event.value); + break; + case Event::Kind::kFreezeState: + engine.SetFreezeState(event.freezeState); + break; + case Event::Kind::kFreezeClear: + engine.FreezeClear(); + break; + } +} + +void WriteLe16(std::ostream& output, std::uint16_t value) +{ + const char bytes[2] = {static_cast(value & 0xFFU), + static_cast((value >> 8U) & 0xFFU)}; + output.write(bytes, 2); +} + +void WriteLe32(std::ostream& output, std::uint32_t value) +{ + const char bytes[4] = {static_cast(value & 0xFFU), + static_cast((value >> 8U) & 0xFFU), + static_cast((value >> 16U) & 0xFFU), + static_cast((value >> 24U) & 0xFFU)}; + output.write(bytes, 4); +} + +void WriteFloat32Wav(const fs::path& path, + const std::vector& interleaved, + std::uint32_t sampleRate, + std::uint16_t channels) +{ + std::ofstream output(path, std::ios::binary); + if(!output) + { + throw std::runtime_error("Unable to create WAV: " + path.string()); + } + + const std::uint32_t dataBytes + = static_cast(interleaved.size() * sizeof(float)); + output.write("RIFF", 4); + WriteLe32(output, 36U + dataBytes); + output.write("WAVE", 4); + output.write("fmt ", 4); + WriteLe32(output, 16U); + WriteLe16(output, 3U); + WriteLe16(output, channels); + WriteLe32(output, sampleRate); + WriteLe32(output, sampleRate * channels * sizeof(float)); + WriteLe16(output, static_cast(channels * sizeof(float))); + WriteLe16(output, 32U); + output.write("data", 4); + WriteLe32(output, dataBytes); + + for(float sample : interleaved) + { + std::uint32_t bits = 0; + static_assert(sizeof(bits) == sizeof(sample), "float32 required"); + std::memcpy(&bits, &sample, sizeof(bits)); + WriteLe32(output, bits); + } +} + +std::string Fnv1a64(const std::vector& samples) +{ + std::uint64_t hash = 1469598103934665603ULL; + for(float sample : samples) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &sample, sizeof(bits)); + for(unsigned shift = 0; shift < 32; shift += 8) + { + hash ^= static_cast((bits >> shift) & 0xFFU); + hash *= 1099511628211ULL; + } + } + std::ostringstream text; + text << std::hex << std::setfill('0') << std::setw(16) << hash; + return text.str(); +} + +std::string JsonEscape(std::string_view value) +{ + std::ostringstream output; + for(char character : value) + { + switch(character) + { + case '\\': output << "\\\\"; break; + case '"': output << "\\\""; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: output << character; break; + } + } + return output.str(); +} + +struct RenderMetrics +{ + double peak = 0.0; + double rms = 0.0; + double tailRms = 0.0; + double maxStep = 0.0; + double stereoDifference = 0.0; + std::size_t nonFinite = 0; + std::size_t nonZero = 0; + std::string checksum; + bool passed = false; +}; + +RenderMetrics MeasureRender(const std::vector& interleaved, + std::size_t tailFrames, + bool expectTail) +{ + RenderMetrics metrics; + if(interleaved.empty()) + { + return metrics; + } + + double totalSquares = 0.0; + double tailSquares = 0.0; + double stereoDiff = 0.0; + const std::size_t frames = interleaved.size() / 2U; + const std::size_t tailStart = frames > tailFrames ? frames - tailFrames : 0U; + float previousLeft = 0.0f; + + for(std::size_t frame = 0; frame < frames; ++frame) + { + const float left = interleaved[frame * 2U]; + const float right = interleaved[frame * 2U + 1U]; + if(!std::isfinite(left) || !std::isfinite(right)) + { + ++metrics.nonFinite; + continue; + } + metrics.peak = std::max( + metrics.peak, + static_cast(std::max(std::abs(left), std::abs(right)))); + totalSquares += static_cast(left) * left + + static_cast(right) * right; + if(frame >= tailStart) + { + tailSquares += static_cast(left) * left + + static_cast(right) * right; + } + if(frame > 0) + { + metrics.maxStep + = std::max(metrics.maxStep, + static_cast(std::abs(left - previousLeft))); + } + previousLeft = left; + stereoDiff += static_cast(left - right) * (left - right); + if(std::abs(left) > 1.0e-9f || std::abs(right) > 1.0e-9f) + { + ++metrics.nonZero; + } + } + + const double sampleCount = static_cast(frames * 2U); + const double tailSampleCount + = static_cast((frames - tailStart) * 2U); + metrics.rms = sampleCount > 0.0 ? std::sqrt(totalSquares / sampleCount) : 0.0; + metrics.tailRms + = tailSampleCount > 0.0 ? std::sqrt(tailSquares / tailSampleCount) : 0.0; + metrics.stereoDifference + = frames > 0 ? std::sqrt(stereoDiff / static_cast(frames)) : 0.0; + metrics.checksum = Fnv1a64(interleaved); + metrics.passed = metrics.nonFinite == 0 && metrics.nonZero > 0 + && metrics.rms > kMinimumAudibleRms + && metrics.peak < kMaximumAllowedPeak + && (!expectTail || metrics.tailRms > kMinimumTailRms); + return metrics; +} + +struct RenderResult +{ + Scenario scenario; + std::array nativeSlots{}; + RenderMetrics metrics; + PedalFreezeState finalFreezeState = PedalFreezeState::kIdle; + fs::path wavPath; + fs::path manifestPath; +}; + +RenderResult RenderScenario(const Scenario& scenario, + const fs::path& outputDirectory, + std::uint32_t seed) +{ + EngineFixture fixture; + ApplyScenarioState(fixture, scenario); + std::array initialNativeSlots{}; + for(std::size_t slot = 0; slot < initialNativeSlots.size(); ++slot) + { + initialNativeSlots[slot] + = fixture.engine.GetSlotNative(static_cast(slot)); + } + + const std::size_t frames = static_cast( + std::llround(scenario.durationSeconds * kSampleRate)); + std::vector interleaved(frames * 2U, 0.0f); + std::array inputLeft{}; + std::array inputRight{}; + std::array outputLeft{}; + std::array outputRight{}; + SourceGenerator source(scenario.source, seed); + std::size_t eventIndex = 0; + + for(std::size_t offset = 0; offset < frames; offset += kBlockSize) + { + while(eventIndex < scenario.events.size() + && scenario.events[eventIndex].frame <= offset) + { + ApplyEvent(fixture.engine, scenario.events[eventIndex]); + ++eventIndex; + } + + const std::size_t blockFrames = std::min(kBlockSize, frames - offset); + for(std::size_t frame = 0; frame < blockFrames; ++frame) + { + inputLeft[frame] = source.Next(offset + frame); + inputRight[frame] = inputLeft[frame]; + } + fixture.engine.Process(inputLeft.data(), + inputRight.data(), + outputLeft.data(), + outputRight.data(), + blockFrames); + for(std::size_t frame = 0; frame < blockFrames; ++frame) + { + interleaved[(offset + frame) * 2U] = outputLeft[frame]; + interleaved[(offset + frame) * 2U + 1U] = outputRight[frame]; + } + } + + RenderResult result; + result.scenario = scenario; + result.finalFreezeState = fixture.engine.GetFreezeState(); + result.nativeSlots = initialNativeSlots; + result.metrics = MeasureRender( + interleaved, static_cast(0.5 * kSampleRate), scenario.expectTail); + result.wavPath = outputDirectory / (scenario.name + ".wav"); + result.manifestPath = outputDirectory / (scenario.name + ".json"); + WriteFloat32Wav(result.wavPath, + interleaved, + static_cast(kSampleRate), + 2U); + + std::ofstream manifest(result.manifestPath); + if(!manifest) + { + throw std::runtime_error("Unable to create manifest: " + + result.manifestPath.string()); + } + manifest << std::setprecision(12); + manifest << "{\n" + << " \"scenario\": \"" << JsonEscape(scenario.name) << "\",\n" + << " \"mode\": \"" << daisyhost::PedalDelayModeName(scenario.mode) + << "\",\n" + << " \"cell\": \"" << JsonEscape(scenario.cell) << "\",\n" + << " \"source\": \"" << JsonEscape(scenario.source) << "\",\n" + << " \"sample_rate_hz\": " << kSampleRate << ",\n" + << " \"block_frames\": " << kBlockSize << ",\n" + << " \"duration_seconds\": " << scenario.durationSeconds << ",\n" + << " \"slot_normalized\": ["; + for(std::size_t slot = 0; slot < 5; ++slot) + { + manifest << (slot == 0 ? "" : ", ") << scenario.slots[slot]; + } + manifest << "],\n \"slot_native\": ["; + for(std::size_t slot = 0; slot < 5; ++slot) + { + manifest << (slot == 0 ? "" : ", ") << result.nativeSlots[slot]; + } + manifest << "],\n" + << " \"events\": \"" << JsonEscape(scenario.eventText) << "\",\n" + << " \"expect_tail\": " << (scenario.expectTail ? "true" : "false") + << ",\n" + << " \"final_freeze_state\": \"" + << daisyhost::PedalFreezeStateName(result.finalFreezeState) << "\",\n" + << " \"metrics\": {\n" + << " \"peak\": " << result.metrics.peak << ",\n" + << " \"rms\": " << result.metrics.rms << ",\n" + << " \"tail_rms\": " << result.metrics.tailRms << ",\n" + << " \"max_step\": " << result.metrics.maxStep << ",\n" + << " \"stereo_difference_rms\": " + << result.metrics.stereoDifference << ",\n" + << " \"non_finite_samples\": " << result.metrics.nonFinite << ",\n" + << " \"non_zero_frames\": " << result.metrics.nonZero << ",\n" + << " \"checksum_fnv1a64\": \"" << result.metrics.checksum + << "\"\n" + << " },\n" + << " \"status\": \"" + << (result.metrics.passed ? "PASS" : "FAIL") << "\"\n" + << "}\n"; + return result; +} + +double Percentile(const std::vector& values, double quantile) +{ + if(values.empty()) + { + return 0.0; + } + std::vector scratch(values); + const std::size_t rank = static_cast( + std::ceil(quantile * static_cast(scratch.size()))); + const std::size_t index = std::min( + scratch.size() - 1U, rank == 0U ? 0U : rank - 1U); + std::nth_element( + scratch.begin(), scratch.begin() + static_cast(index), scratch.end()); + return scratch[index]; +} + +double MeasureClockOverheadUs() +{ + using Clock = std::chrono::steady_clock; + double minimum = std::numeric_limits::infinity(); + for(std::size_t iteration = 0; iteration < 10000; ++iteration) + { + const auto start = Clock::now(); + const auto stop = Clock::now(); + const double duration + = std::chrono::duration(stop - start).count(); + minimum = std::min(minimum, duration); + } + return std::isfinite(minimum) ? minimum : 0.0; +} + +struct TimingSummary +{ + std::string mode; + std::size_t warmupBlocks = 0; + std::size_t measuredBlocks = 0; + double measuredDurationS = 0.0; + double blockBudgetUs = 0.0; + double meanUs = 0.0; + double p99Us = 0.0; + double p999Us = 0.0; + double maxUs = 0.0; + double marginP999Us = 0.0; + double tailUtilization = 0.0; + std::size_t deadlineMisses = 0; + double deadlineMissRate = 0.0; + double maxLatenessUs = 0.0; + std::size_t processingFailures = 0; + double timerOverheadUs = 0.0; + std::string status; +}; + +void ConfigureProfileMode(EngineFixture& fixture, PedalDelayMode mode) +{ + fixture.engine.SetMode(mode); + std::array values = {0.75f, 0.95f, 0.85f, 0.90f, 0.90f}; + if(mode == PedalDelayMode::kMod) + { + values = {0.10f, 1.0f, 1.0f, 1.0f, 1.0f}; + } + else if(mode == PedalDelayMode::kRev) + { + values = {0.12f, 1.0f, 1.0f, 1.0f, 0.10f}; + } + else if(mode == PedalDelayMode::kFreeze) + { + values = {0.18f, 1.0f, 0.90f, 0.85f, 1.0f}; + } + for(std::size_t slot = 0; slot < values.size(); ++slot) + { + fixture.engine.SetSlotNormalized(static_cast(slot), values[slot]); + } + fixture.engine.Reset(); +} + +bool BlockIsFinite(const std::array& left, + const std::array& right) +{ + for(std::size_t frame = 0; frame < kBlockSize; ++frame) + { + if(!std::isfinite(left[frame]) || !std::isfinite(right[frame])) + { + return false; + } + } + return true; +} + +TimingSummary ProfileMode(PedalDelayMode mode, double timerOverheadUs) +{ + using Clock = std::chrono::steady_clock; + EngineFixture fixture; + ConfigureProfileMode(fixture, mode); + + std::array inputLeft{}; + std::array inputRight{}; + std::array outputLeft{}; + std::array outputRight{}; + for(std::size_t frame = 0; frame < kBlockSize; ++frame) + { + inputLeft[frame] + = 0.35f * static_cast(std::sin(2.0 * kPi * 317.0 + * static_cast(frame) + / kSampleRate)); + inputRight[frame] = inputLeft[frame]; + } + + if(mode == PedalDelayMode::kFreeze) + { + fixture.engine.SetFreezeState(PedalFreezeState::kCapture); + const std::size_t captureBlocks + = static_cast(kSampleRate / kBlockSize); + for(std::size_t block = 0; block < captureBlocks; ++block) + { + fixture.engine.Process(inputLeft.data(), + inputRight.data(), + outputLeft.data(), + outputRight.data(), + kBlockSize); + } + fixture.engine.SetFreezeState(PedalFreezeState::kAccumulate); + } + + for(std::size_t block = 0; block < kProfileWarmupBlocks; ++block) + { + if((mode == PedalDelayMode::kDigi || mode == PedalDelayMode::kTape) + && block % 64U == 0U) + { + const float value = (block / 64U) % 2U == 0U ? 0.12f : 0.88f; + fixture.engine.SetSlotNormalized(PedalSlot::kTime, value); + } + fixture.engine.Process(inputLeft.data(), + inputRight.data(), + outputLeft.data(), + outputRight.data(), + kBlockSize); + } + + std::vector durations(kProfileMeasuredBlocks, 0.0); + std::size_t processingFailures = 0; + for(std::size_t block = 0; block < kProfileMeasuredBlocks; ++block) + { + if((mode == PedalDelayMode::kDigi || mode == PedalDelayMode::kTape) + && block % 64U == 0U) + { + const float value = (block / 64U) % 2U == 0U ? 0.12f : 0.88f; + fixture.engine.SetSlotNormalized(PedalSlot::kTime, value); + } + const auto start = Clock::now(); + fixture.engine.Process(inputLeft.data(), + inputRight.data(), + outputLeft.data(), + outputRight.data(), + kBlockSize); + const auto stop = Clock::now(); + durations[block] + = std::chrono::duration(stop - start).count(); + if(!std::isfinite(durations[block]) + || !BlockIsFinite(outputLeft, outputRight)) + { + ++processingFailures; + } + } + + TimingSummary summary; + summary.mode = daisyhost::PedalDelayModeName(mode); + summary.warmupBlocks = kProfileWarmupBlocks; + summary.measuredBlocks = kProfileMeasuredBlocks; + summary.measuredDurationS = static_cast(kProfileMeasuredBlocks * kBlockSize) + / kSampleRate; + summary.blockBudgetUs = 1000000.0 * static_cast(kBlockSize) / kSampleRate; + summary.meanUs = std::accumulate(durations.begin(), durations.end(), 0.0) + / static_cast(durations.size()); + summary.p99Us = Percentile(durations, 0.99); + summary.p999Us = Percentile(durations, 0.999); + summary.maxUs = *std::max_element(durations.begin(), durations.end()); + summary.marginP999Us = summary.blockBudgetUs - summary.p999Us; + summary.tailUtilization = summary.p999Us / summary.blockBudgetUs; + summary.deadlineMisses = static_cast(std::count_if( + durations.begin(), durations.end(), [&](double duration) { + return duration > summary.blockBudgetUs; + })); + summary.deadlineMissRate + = static_cast(summary.deadlineMisses) + / static_cast(summary.measuredBlocks); + summary.maxLatenessUs + = std::max(0.0, summary.maxUs - summary.blockBudgetUs); + summary.processingFailures = processingFailures; + summary.timerOverheadUs = timerOverheadUs; + if(!Clock::is_steady || processingFailures != 0U) + { + summary.status = "HOST_MEASUREMENT_INVALID"; + } + else if(summary.measuredBlocks < kProfileMeasuredBlocks) + { + summary.status = "INSUFFICIENT_SAMPLE_COUNT"; + } + else if(summary.p999Us <= summary.blockBudgetUs + && summary.deadlineMisses == 0U) + { + summary.status = "HOST_OBSERVED_WITHIN_BUDGET"; + } + else + { + summary.status = "HOST_OBSERVED_TAIL_VIOLATION"; + } + return summary; +} + +std::string CompilerIdentity() +{ +#if defined(__clang__) + return std::string("Clang ") + __clang_version__; +#elif defined(__GNUC__) + return std::string("GCC ") + __VERSION__; +#elif defined(_MSC_VER) + return std::string("MSVC ") + std::to_string(_MSC_VER); +#else + return "unknown"; +#endif +} + +std::string OsIdentity() +{ +#if defined(_WIN32) + return "Windows"; +#elif defined(__APPLE__) + return "macOS"; +#elif defined(__linux__) + return "Linux"; +#else + return "unknown"; +#endif +} + +void WriteTimingReports(const fs::path& outputDirectory, + const std::vector& summaries) +{ + std::ofstream csv(outputDirectory / "host_profile.csv"); + csv << "mode,warmup_blocks,measured_blocks,measured_duration_s,block_budget_us," + "mean_us,p99_us,p99_9_us,max_us,margin_p99_9_us,tail_utilization_ratio," + "deadline_miss_count,deadline_miss_rate,max_lateness_us," + "processing_failure_count,timer_overhead_us,status\n"; + csv << std::setprecision(12); + for(const auto& summary : summaries) + { + csv << summary.mode << ',' << summary.warmupBlocks << ',' + << summary.measuredBlocks << ',' << summary.measuredDurationS << ',' + << summary.blockBudgetUs << ',' << summary.meanUs << ',' + << summary.p99Us << ',' << summary.p999Us << ',' << summary.maxUs + << ',' << summary.marginP999Us << ',' << summary.tailUtilization + << ',' << summary.deadlineMisses << ',' << summary.deadlineMissRate + << ',' << summary.maxLatenessUs << ',' << summary.processingFailures + << ',' << summary.timerOverheadUs << ',' << summary.status << '\n'; + } + + std::ofstream environment(outputDirectory / "host_profile_environment.json"); + environment << "{\n" + << " \"clock\": \"std::chrono::steady_clock\",\n" + << " \"clock_is_steady\": " + << (std::chrono::steady_clock::is_steady ? "true" : "false") + << ",\n" + << " \"clock_period_num\": " + << std::chrono::steady_clock::period::num << ",\n" + << " \"clock_period_den\": " + << std::chrono::steady_clock::period::den << ",\n" + << " \"compiler\": \"" << JsonEscape(CompilerIdentity()) + << "\",\n" + << " \"os\": \"" << JsonEscape(OsIdentity()) << "\",\n" + << " \"sample_rate_hz\": " << kSampleRate << ",\n" + << " \"block_frames_per_channel\": " << kBlockSize << ",\n" + << " \"warmup_blocks\": " << kProfileWarmupBlocks << ",\n" + << " \"measured_blocks\": " << kProfileMeasuredBlocks << ",\n" + << " \"sensitivity_model_enabled\": false,\n" + << " \"target_timing_claim\": \"NOT_RUN\"\n" + << "}\n"; +} + +void WriteAggregateManifest(const fs::path& outputDirectory, + const std::vector& results) +{ + std::ofstream csv(outputDirectory / "audition_manifest.csv"); + csv << "scenario,mode,cell,source,duration_s,time_norm,feedback_norm,mix_norm," + "color_norm,motion_norm,time_native,feedback_native,mix_native,color_native," + "motion_native,wav,peak,rms,tail_rms,max_step,stereo_difference_rms," + "non_finite_samples,checksum_fnv1a64,final_freeze_state,status\n"; + csv << std::setprecision(12); + for(const auto& result : results) + { + csv << result.scenario.name << ',' + << daisyhost::PedalDelayModeName(result.scenario.mode) << ',' + << result.scenario.cell << ',' << result.scenario.source << ',' + << result.scenario.durationSeconds; + for(float value : result.scenario.slots) + { + csv << ',' << value; + } + for(float value : result.nativeSlots) + { + csv << ',' << value; + } + csv << ',' << result.wavPath.filename().string() << ',' + << result.metrics.peak << ',' << result.metrics.rms << ',' + << result.metrics.tailRms << ',' << result.metrics.maxStep << ',' + << result.metrics.stereoDifference << ',' << result.metrics.nonFinite + << ',' << result.metrics.checksum << ',' + << daisyhost::PedalFreezeStateName(result.finalFreezeState) << ',' + << (result.metrics.passed ? "PASS" : "FAIL") << '\n'; + } +} + +struct Arguments +{ + fs::path matrix; + fs::path outputDirectory; +}; + +Arguments ParseArguments(int argc, char** argv) +{ + Arguments arguments; + for(int index = 1; index < argc; ++index) + { + const std::string option = argv[index]; + if(option == "--matrix" && index + 1 < argc) + { + arguments.matrix = argv[++index]; + } + else if(option == "--output-dir" && index + 1 < argc) + { + arguments.outputDirectory = argv[++index]; + } + else + { + throw std::runtime_error("Unknown or incomplete option: " + option); + } + } + if(arguments.matrix.empty() || arguments.outputDirectory.empty()) + { + throw std::runtime_error( + "Usage: pedal_multidelay_audition --matrix --output-dir "); + } + return arguments; +} +} // namespace + +int main(int argc, char** argv) +{ + try + { + const Arguments arguments = ParseArguments(argc, argv); + fs::create_directories(arguments.outputDirectory); + const auto scenarios = ReadMatrix(arguments.matrix); + + std::vector results; + results.reserve(scenarios.size()); + bool allRendersPassed = true; + for(std::size_t index = 0; index < scenarios.size(); ++index) + { + auto result = RenderScenario(scenarios[index], + arguments.outputDirectory, + static_cast(17U + index)); + std::cout << result.scenario.name << ' ' + << (result.metrics.passed ? "PASS" : "FAIL") + << " peak=" << result.metrics.peak + << " rms=" << result.metrics.rms + << " tail_rms=" << result.metrics.tailRms + << " checksum=" << result.metrics.checksum << '\n'; + allRendersPassed = allRendersPassed && result.metrics.passed; + results.push_back(std::move(result)); + } + WriteAggregateManifest(arguments.outputDirectory, results); + + const double timerOverheadUs = MeasureClockOverheadUs(); + std::vector timing; + for(PedalDelayMode mode : {PedalDelayMode::kDigi, + PedalDelayMode::kTape, + PedalDelayMode::kMod, + PedalDelayMode::kRev, + PedalDelayMode::kFreeze}) + { + timing.push_back(ProfileMode(mode, timerOverheadUs)); + const auto& summary = timing.back(); + std::cout << "PROFILE " << summary.mode + << " mean_us=" << summary.meanUs + << " p99_9_us=" << summary.p999Us + << " max_us=" << summary.maxUs + << " misses=" << summary.deadlineMisses + << " status=" << summary.status << '\n'; + } + WriteTimingReports(arguments.outputDirectory, timing); + + const bool profileValid = std::all_of( + timing.begin(), timing.end(), [](const TimingSummary& summary) { + return summary.status != "HOST_MEASUREMENT_INVALID" + && summary.status != "INSUFFICIENT_SAMPLE_COUNT" + && summary.processingFailures == 0U; + }); + + if(!allRendersPassed || !profileValid) + { + std::cerr << "Audition/profiling gate failed\n"; + return 2; + } + std::cout << "HOST_AUDITION_PACKAGE_READY\n"; + std::cout << "HOST_LISTENING_PENDING\n"; + std::cout << "TARGET_TIMING_NOT_RUN\n"; + return 0; + } + catch(const std::exception& error) + { + std::cerr << "ERROR: " << error.what() << '\n'; + return 1; + } +} From 1ef3f44b1f558e0a482232da0b00aefe04b9eaa8 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:06:27 +0200 Subject: [PATCH 07/18] test: keep freeze character audible through the evidence window --- .../training/examples/pedal_multidelay_audition_matrix.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv b/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv index fbddb277c..046203bbb 100644 --- a/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv +++ b/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv @@ -12,5 +12,5 @@ pedal_multidelay_audition_rev_reference,REV,reference,transient_train,4.5,0.25,0 pedal_multidelay_audition_rev_character,REV,character,pluck_train,5.0,0.65,0.55,0.80,1.00,0.90,,0 pedal_multidelay_audition_rev_stress,REV,stress,transient_train,4.5,0.05,1.00,1.00,1.00,0.00,1.500:SLOT:TIME:0.90|3.000:SLOT:TIME:0.08,0 pedal_multidelay_audition_freeze_reference,FREEZE,reference,single_pluck,4.5,0.38,0.96,0.75,0.18,0.05,0.050:FREEZE:CAPTURE,1 -pedal_multidelay_audition_freeze_character,FREEZE,character,single_pluck,5.0,0.45,0.98,0.85,0.65,0.70,0.050:FREEZE:CAPTURE|1.500:FREEZE:HOLD,1 +pedal_multidelay_audition_freeze_character,FREEZE,character,single_pluck,4.0,0.45,1.00,0.85,0.38,0.70,0.050:FREEZE:CAPTURE|1.500:FREEZE:HOLD,1 pedal_multidelay_audition_freeze_stress,FREEZE,stress,pluck_train,5.5,0.25,1.00,0.90,0.85,1.00,0.050:FREEZE:CAPTURE|1.200:FREEZE:ACCUMULATE|2.400:FREEZE:REPLACE|3.800:FREEZE:CLEAR,0 From 4ddcbc2afad1cf70dd53d56e4358372b2ce0f093 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:07:43 +0200 Subject: [PATCH 08/18] test: retain freeze tail while exposing evolve character --- .../training/examples/pedal_multidelay_audition_matrix.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv b/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv index 046203bbb..8b7b30f0b 100644 --- a/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv +++ b/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv @@ -12,5 +12,5 @@ pedal_multidelay_audition_rev_reference,REV,reference,transient_train,4.5,0.25,0 pedal_multidelay_audition_rev_character,REV,character,pluck_train,5.0,0.65,0.55,0.80,1.00,0.90,,0 pedal_multidelay_audition_rev_stress,REV,stress,transient_train,4.5,0.05,1.00,1.00,1.00,0.00,1.500:SLOT:TIME:0.90|3.000:SLOT:TIME:0.08,0 pedal_multidelay_audition_freeze_reference,FREEZE,reference,single_pluck,4.5,0.38,0.96,0.75,0.18,0.05,0.050:FREEZE:CAPTURE,1 -pedal_multidelay_audition_freeze_character,FREEZE,character,single_pluck,4.0,0.45,1.00,0.85,0.38,0.70,0.050:FREEZE:CAPTURE|1.500:FREEZE:HOLD,1 +pedal_multidelay_audition_freeze_character,FREEZE,character,single_pluck,4.0,0.45,1.00,0.85,0.22,0.85,0.050:FREEZE:CAPTURE|1.500:FREEZE:HOLD,1 pedal_multidelay_audition_freeze_stress,FREEZE,stress,pluck_train,5.5,0.25,1.00,0.90,0.85,1.00,0.050:FREEZE:CAPTURE|1.200:FREEZE:ACCUMULATE|2.400:FREEZE:REPLACE|3.800:FREEZE:CLEAR,0 From 0b648e2b089ccc9a57168e172114fa14778ee0dd Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:10:18 +0200 Subject: [PATCH 09/18] test: make freeze character cell exercise a sustained capture --- .../training/examples/pedal_multidelay_audition_matrix.csv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv b/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv index 8b7b30f0b..7d1e57eb8 100644 --- a/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv +++ b/DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv @@ -1,16 +1,16 @@ scenario,mode,cell,source,duration_s,time,feedback,mix,color,motion,events,expect_tail pedal_multidelay_audition_digi_reference,DIGI,reference,pluck_train,4.0,0.45,0.35,0.50,0.78,0.00,,0 pedal_multidelay_audition_digi_character,DIGI,character,transient_train,4.0,0.30,0.82,0.72,1.00,0.00,,0 -pedal_multidelay_audition_digi_stress,DIGI,stress,sine,4.0,0.05,0.92,0.80,0.95,0.00,1.000:SLOT:TIME:0.95|2.000:SLOT:TIME:0.10|3.000:SLOT:TIME:0.85,0 +pedal_multidelay_audition_digi_stress,DIGI,stress,sine,4.0,0.05,0.82,0.80,0.95,0.00,1.000:SLOT:TIME:0.95|2.000:SLOT:TIME:0.10|3.000:SLOT:TIME:0.85,0 pedal_multidelay_audition_tape_reference,TAPE,reference,pluck_train,4.5,0.42,0.45,0.55,0.20,0.12,,0 pedal_multidelay_audition_tape_character,TAPE,character,dual_tone,4.5,0.50,0.70,0.70,0.75,0.65,,0 pedal_multidelay_audition_tape_stress,TAPE,stress,sine,4.5,0.15,0.95,0.85,1.00,1.00,1.200:SLOT:TIME:0.85|2.400:SLOT:TIME:0.08|3.600:SLOT:TIME:0.75,0 pedal_multidelay_audition_mod_reference,MOD,reference,dual_tone,4.0,0.08,0.70,0.50,0.45,0.18,,0 pedal_multidelay_audition_mod_character,MOD,character,pluck_train,4.0,0.42,0.50,0.88,0.70,0.45,,0 -pedal_multidelay_audition_mod_stress,MOD,stress,sine,4.0,0.10,1.00,1.00,1.00,1.00,,0 +pedal_multidelay_audition_mod_stress,MOD,stress,sine,4.0,0.10,0.90,0.95,1.00,1.00,,0 pedal_multidelay_audition_rev_reference,REV,reference,transient_train,4.5,0.25,0.25,0.70,1.00,0.75,,0 pedal_multidelay_audition_rev_character,REV,character,pluck_train,5.0,0.65,0.55,0.80,1.00,0.90,,0 pedal_multidelay_audition_rev_stress,REV,stress,transient_train,4.5,0.05,1.00,1.00,1.00,0.00,1.500:SLOT:TIME:0.90|3.000:SLOT:TIME:0.08,0 pedal_multidelay_audition_freeze_reference,FREEZE,reference,single_pluck,4.5,0.38,0.96,0.75,0.18,0.05,0.050:FREEZE:CAPTURE,1 -pedal_multidelay_audition_freeze_character,FREEZE,character,single_pluck,4.0,0.45,1.00,0.85,0.22,0.85,0.050:FREEZE:CAPTURE|1.500:FREEZE:HOLD,1 +pedal_multidelay_audition_freeze_character,FREEZE,character,dual_tone,3.0,0.45,1.00,0.85,0.18,0.85,0.050:FREEZE:CAPTURE|1.500:FREEZE:HOLD,1 pedal_multidelay_audition_freeze_stress,FREEZE,stress,pluck_train,5.5,0.25,1.00,0.90,0.85,1.00,0.050:FREEZE:CAPTURE|1.200:FREEZE:ACCUMULATE|2.400:FREEZE:REPLACE|3.800:FREEZE:CLEAR,0 From 07ec3b3a2e540e41cd724a13898cfcaa3abd6313 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:13:06 +0200 Subject: [PATCH 10/18] ci: run focused PedalDelay tests before audition evidence --- .../workflows/daisyhost-pedal-audition.yml | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/daisyhost-pedal-audition.yml b/.github/workflows/daisyhost-pedal-audition.yml index 4ffb206a9..a6c6834ce 100644 --- a/.github/workflows/daisyhost-pedal-audition.yml +++ b/.github/workflows/daisyhost-pedal-audition.yml @@ -9,6 +9,9 @@ on: - ".github/workflows/daisyhost-pedal-audition.yml" - "DaisyHost/include/daisyhost/PedalDelayEngine.h" - "DaisyHost/src/PedalDelayEngine.cpp" + - "DaisyHost/include/daisyhost/apps/PedalDelayCore.h" + - "DaisyHost/src/apps/PedalDelayCore.cpp" + - "DaisyHost/tests/test_pedal_delay_core.cpp" - "DaisyHost/tools/pedal_multidelay_audition/**" - "DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv" - "DaisyHost/docs/pedal-multidelay-audition-work-package-v0.2.md" @@ -17,6 +20,9 @@ on: - ".github/workflows/daisyhost-pedal-audition.yml" - "DaisyHost/include/daisyhost/PedalDelayEngine.h" - "DaisyHost/src/PedalDelayEngine.cpp" + - "DaisyHost/include/daisyhost/apps/PedalDelayCore.h" + - "DaisyHost/src/apps/PedalDelayCore.cpp" + - "DaisyHost/tests/test_pedal_delay_core.cpp" - "DaisyHost/tools/pedal_multidelay_audition/**" - "DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv" - "DaisyHost/docs/pedal-multidelay-audition-work-package-v0.2.md" @@ -28,12 +34,36 @@ jobs: host-audition: name: Host audition package runs-on: ubuntu-24.04 - timeout-minutes: 12 + timeout-minutes: 20 steps: - name: Checkout exact revision uses: actions/checkout@v4 + - name: Configure focused DaisyHost tests + run: >- + cmake + -S DaisyHost + -B DaisyHost/build-algorithms + -DCMAKE_BUILD_TYPE=Release + -DDAISYHOST_UNIT_TEST_RUN_TAG=pedal-audition-ci + + - name: Build DaisyHost unit tests + run: >- + cmake + --build DaisyHost/build-algorithms + --config Release + --target unit_tests + --parallel 2 + + - name: Run focused PedalDelay tests + run: >- + ctest + --test-dir DaisyHost/build-algorithms + -C Release + --output-on-failure + -R PedalDelay + - name: Configure bounded runner run: >- cmake From f1acd36380e6f3fddfc689e6f81097bfa63850e3 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:15:28 +0200 Subject: [PATCH 11/18] ci: run focused DaisyHost tests on Windows --- .../workflows/daisyhost-pedal-audition.yml | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/daisyhost-pedal-audition.yml b/.github/workflows/daisyhost-pedal-audition.yml index a6c6834ce..acd995341 100644 --- a/.github/workflows/daisyhost-pedal-audition.yml +++ b/.github/workflows/daisyhost-pedal-audition.yml @@ -31,24 +31,25 @@ permissions: contents: read jobs: - host-audition: - name: Host audition package - runs-on: ubuntu-24.04 + focused-pedal-tests: + name: Focused PedalDelay tests + runs-on: windows-2022 timeout-minutes: 20 steps: - name: Checkout exact revision uses: actions/checkout@v4 - - name: Configure focused DaisyHost tests + - name: Configure DaisyHost test build + shell: pwsh run: >- cmake -S DaisyHost -B DaisyHost/build-algorithms - -DCMAKE_BUILD_TYPE=Release -DDAISYHOST_UNIT_TEST_RUN_TAG=pedal-audition-ci - name: Build DaisyHost unit tests + shell: pwsh run: >- cmake --build DaisyHost/build-algorithms @@ -57,6 +58,7 @@ jobs: --parallel 2 - name: Run focused PedalDelay tests + shell: pwsh run: >- ctest --test-dir DaisyHost/build-algorithms @@ -64,6 +66,16 @@ jobs: --output-on-failure -R PedalDelay + host-audition: + name: Host audition package + needs: focused-pedal-tests + runs-on: ubuntu-24.04 + timeout-minutes: 12 + + steps: + - name: Checkout exact revision + uses: actions/checkout@v4 + - name: Configure bounded runner run: >- cmake From ba7a88a05ddf8af7cbf7af15d7dfe3c4376380c4 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:18:13 +0200 Subject: [PATCH 12/18] docs: add corrected real-time DSP profiling specification --- .../docs/real-time-dsp-profiling-spec-v1.1.md | 595 ++++++++++++++++++ 1 file changed, 595 insertions(+) create mode 100644 DaisyHost/docs/real-time-dsp-profiling-spec-v1.1.md diff --git a/DaisyHost/docs/real-time-dsp-profiling-spec-v1.1.md b/DaisyHost/docs/real-time-dsp-profiling-spec-v1.1.md new file mode 100644 index 000000000..f4ff952ea --- /dev/null +++ b/DaisyHost/docs/real-time-dsp-profiling-spec-v1.1.md @@ -0,0 +1,595 @@ +# Real-Time DSP Profiling System — Technical Specification + +Version: `1.1-candidate` +Status: `IMPLEMENTED_HOST_SUBSET / TARGET_EVIDENCE_NOT_RUN` +Primary use: embedded-audio callback envelope characterization +Current host implementation: `DaisyHost/tools/pedal_multidelay_audition/` + +## 0. Executive contract + +This specification defines a bounded execution-time measurement and +interpretation system for real-time audio DSP. + +It separates four things that must not be collapsed into one reassuringly +vague percentage: + +1. measured host execution time; +2. measured target execution time; +3. observed deadline violations; +4. explicitly modeled sensitivity scenarios. + +The system estimates an **observed execution envelope** for a declared build, +graph, mode, input and stress profile. It does not prove WCET, cycle-accurate +MCU equivalence, audio quality, or production readiness. + +## 0.1 Canonical units and names + +```text +sample_rate_hz +block_frames_per_channel +block_budget_us +mean_exec_us +p99_exec_us +p99_9_exec_us +observed_max_exec_us +p99_9_margin_us +observed_max_margin_us +p99_9_load +observed_max_load +exec_deadline_miss_count +exec_deadline_miss_rate +max_lateness_us +``` + +`buffer_size` is prohibited in timing formulas because it is ambiguous between +bytes, aggregate samples and frames per channel. + +## 0.2 Callback budget + +```text +block_period_s = + block_frames_per_channel / sample_rate_hz + +block_budget_us = + 1'000'000.0 * block_frames_per_channel / sample_rate_hz +``` + +The channel count does not multiply the available time. Stereo increases the +work, not the callback period. + +Example: + +```text +sample_rate_hz = 48'000 +block_frames_per_channel = 48 +block_budget_us = 1'000 +``` + +## 0.3 Primary statistics + +```text +mean_exec_us = + arithmetic mean of all valid post-warm-up observations + +p99_exec_us = + observed 99.0th percentile + +p99_9_exec_us = + observed 99.9th percentile + +observed_max_exec_us = + largest valid observation in the bounded campaign +``` + +`observed_max_exec_us` is explicitly **not WCET**. It remains important: +when it exceeds the budget, an actual deadline violation was observed. + +## 0.4 Margins and loads + +```text +p99_9_margin_us = + block_budget_us - p99_9_exec_us + +observed_max_margin_us = + block_budget_us - observed_max_exec_us + +p99_9_load = + p99_9_exec_us / block_budget_us + +observed_max_load = + observed_max_exec_us / block_budget_us +``` + +`p99_9_load` is a utilization ratio. It is not a probability and must not be +named `deadline_risk`. + +## 0.5 Deadline observations + +```text +exec_deadline_miss_count = + count(exec_us > block_budget_us) + +exec_deadline_miss_rate = + exec_deadline_miss_count / valid_observation_count + +max_lateness_us = + max(0.0, observed_max_exec_us - block_budget_us) +``` + +Where a target/driver surface exposes them, record separately: + +```text +callback_interval_overrun_count +reported_driver_xrun_count +processing_failure_count +``` + +These counters describe different failure mechanisms and may not be merged. + +## 0.6 Allowed interpretation labels + +Measured host results may use: + +- `HOST_OBSERVED_WITHIN_BUDGET` +- `HOST_OBSERVED_TAIL_VIOLATION` +- `HOST_REGRESSION_PASS` +- `HOST_REGRESSION_FAIL` +- `INSUFFICIENT_SAMPLE_COUNT` +- `HOST_MEASUREMENT_INVALID` + +Measured target results may use: + +- `TARGET_OBSERVED_WITHIN_CAMPAIGN_GATE` +- `TARGET_OBSERVED_GATE_VIOLATION` +- `TARGET_MEASUREMENT_INVALID` +- `TARGET_NOT_RUN` + +Prohibited conclusions from a finite campaign: + +- `SAFE_REAL_TIME` +- `WCET_PROVEN` +- `DETERMINISTICALLY_SAFE` +- `TARGET_READY` from host evidence + +## 1. Scope + +The profiler supports: + +- block-based real-time audio DSP; +- host regression and relative-cost comparison; +- physical-target callback measurement; +- mode/state/transition-aware campaigns; +- bounded report generation; +- optional, separately labeled sensitivity projections. + +It evaluates: + +- execution-time distribution for the declared campaign; +- tail proximity to the callback budget; +- observed deadline misses; +- sensitivity to declared stress conditions; +- regressions between comparable revisions. + +## 2. Timing sources + +### 2.1 Host + +Host measurement shall use: + +```text +std::chrono::steady_clock +``` + +Record: + +- `steady_clock::is_steady`; +- nominal clock period; +- measured empty-bracket overhead; +- operating system; +- CPU identity where available; +- compiler and version; +- build configuration and optimization flags; +- process/thread-priority policy or default status. + +`std::chrono::high_resolution_clock` is prohibited unless the implementation +first proves that it aliases a monotonic clock on the measured platform. The +portable default remains `steady_clock`. + +### 2.2 Cortex-M and other embedded targets + +Target measurement shall use a qualified monotonic hardware source, such as: + +- ARM DWT `CYCCNT`; +- a verified general-purpose hardware timer; +- a target RTOS trace/timestamp source with documented resolution. + +Record: + +- counter source and width; +- core/timer frequency; +- wrap handling; +- read overhead; +- cache state; +- memory placement; +- interrupt and measurement scope. + +The target shall not be forced through `std::chrono` merely to make host and +firmware code look aesthetically symmetrical. + +## 3. Measurement scope + +Every campaign shall state exactly what the timing bracket includes. + +Possible scopes include: + +- DSP engine call only; +- complete product graph; +- complete audio callback body; +- callback response time including observed scheduling delay; +- driver-reported period/xrun behavior. + +A DSP-engine bracket does not include ISR entry/exit, DMA/driver work, +concurrent interrupts or board services unless explicitly placed inside the +bracket. + +## 4. Warm-up and capture + +Use callback blocks, not audio frames, as the warm-up unit. + +Default host campaign: + +```text +warmup_blocks = 1'000 +measured_blocks = 50'000 +``` + +Minimum accepted warm-up: + +```text +warmup_blocks >= 200 +``` + +The campaign may use a longer warm-up or a declared stabilization criterion. +The report shall include: + +- warm-up blocks; +- measured blocks; +- valid/invalid observations; +- measured audio duration; +- elapsed wall duration where useful; +- dropped/overwritten sample count. + +A reported p99.9 requires at least 50,000 valid observations unless the report +explicitly returns `INSUFFICIENT_SAMPLE_COUNT`. At 50,000 observations, the +upper 0.1% still contains only approximately 50 samples; p99.9 remains an +observed statistic, not a worst-case theorem. + +## 5. Bounded acquisition + +The measured real-time path may perform only bounded work required to capture +timestamps and counters. + +Allowed in the measured path: + +- read monotonic timer/counter; +- process audio; +- read timer/counter; +- store one value into preallocated storage; +- update bounded counters/sum/maximum. + +Prohibited in the measured path: + +- allocation or deallocation; +- container growth; +- sorting or percentile computation; +- string construction or formatting; +- file/network/device I/O; +- logging; +- locks or blocking calls; +- unbounded loops. + +Capture storage may be: + +- one fixed-capacity finite campaign buffer; or +- a fixed-capacity ring buffer with explicit overwrite accounting. + +Unbounded accumulation is prohibited. + +## 6. Statistical computation + +### 6.1 Mean + +The report mean shall be an arithmetic mean derived from exact sum/count or a +numerically stable online equivalent. + +An EMA may be exposed separately for live telemetry only when its alpha and +initialization are recorded. An EMA may not silently replace the campaign +mean. + +### 6.2 Percentiles + +The canonical percentile convention is nearest rank: + +```text +index(q, N) = ceil(q * N) - 1 +``` + +with zero-based clamping to `[0, N - 1]`. + +Compute p99 and p99.9 after capture using `std::nth_element` or an equivalent +bounded partial-selection method. Full sorting is permitted outside the audio +path but is not required. + +The report shall state: + +- quantile; +- rank convention; +- valid sample count; +- samples above the selected quantile. + +## 7. Decision model + +### 7.1 Observed failure + +Return an observed failure when any applicable condition is true: + +```text +processing_failure_count > 0 +OR exec_deadline_miss_count > 0 +OR callback_interval_overrun_count > 0 +OR reported_driver_xrun_count > 0 +OR invalid/non-finite timing observations > 0 +``` + +A system may also fail a stricter, predeclared margin gate before any deadline +miss occurs. + +### 7.2 Campaign pass + +A campaign may return an observed pass only when: + +- exact build/graph/input/stress identity is recorded; +- observation count and duration satisfy the declared campaign; +- timing source and overhead are valid; +- zero applicable failure counters are observed; +- p99.9 and maximum satisfy the predeclared campaign limits; +- every required mode/state/transition cell is executed. + +The result is scoped to that campaign. + +### 7.3 Project provisional target gate + +Where the active product campaign has not superseded it, the provisional +Daisy gate is: + +```text +p99_9_exec_us <= 0.70 * block_budget_us +observed_max_exec_us <= 0.85 * block_budget_us +all observed overrun/xrun/failure counts == 0 +``` + +This gate is a product-program policy, not a universal real-time theorem. + +## 8. WCET boundary + +Do not infer WCET from: + +- one maximum observation; +- p99 or p99.9; +- p99.9 plus an arbitrary constant; +- host-to-target multipliers. + +An optional policy estimate may be reported as: + +```text +tail_bound_proxy_us = + p99_9_exec_us + configured_tail_margin_us +``` + +or: + +```text +tail_bound_proxy_us = + p99_9_exec_us * configured_tail_factor +``` + +Required metadata: + +- policy id; +- absolute margin/factor; +- calibration source; +- applicable target and stress profile. + +Required label: + +```text +TAIL_BOUND_PROXY_NOT_WCET +``` + +## 9. Host sensitivity model + +A modeled layer is optional and disabled by default. + +Its name is: + +```text +HOST_SENSITIVITY_MODEL +``` + +It must not be called embedded simulation or target emulation. + +Measured and modeled fields shall remain separate: + +```text +measured_exec_us +modeled_exec_us +modeled_scheduling_delay_us +modeled_available_budget_us +model_configuration +model_provenance +``` + +Example structure: + +```text +modeled_exec_us = + measured_exec_us * calibrated_execution_factor + +modeled_available_budget_us = + block_budget_us + - max(0, modeled_scheduling_delay_us) + - reserved_system_budget_us +``` + +Every non-default model requires: + +- explicit factor/range; +- event probability and distribution; +- burst/correlation model; +- deterministic seed; +- calibration source; +- applicable memory/cache/ISR condition. + +All uncalibrated output shall be labeled: + +```text +UNCALIBRATED_SCENARIO_PROJECTION +``` + +Cache and memory-contention penalties shall not be multiplied as independent +random variables when evidence indicates they are correlated. + +## 10. Mode- and state-aware campaigns + +Profile product behavior by mode and by transient state. For the compact +multi-delay product, minimum target-facing coverage eventually includes: + +```text +DIGI steady state +DIGI active TIME transition +TAPE steady state and maximum transport modulation +MOD maximum rate/depth/resonance +REV overlapping grain/window stress +FREEZE capture +FREEZE hold/evolve +FREEZE accumulate/replace/clear transitions +mode transition +bypass/trails transition +representative background control/MIDI/UI load +``` + +The highest cost may occur during a bounded transition where two policies or +read heads execute together. A per-mode steady-state average is therefore +insufficient. + +## 11. Required report metadata + +Every report shall include: + +### Identity + +- repository and commit; +- dirty/clean state where observable; +- build id; +- compiler/version/flags; +- board/host identity; +- graph and mode/state; +- source/input vector and seed; +- scenario id. + +### Runtime + +- sample rate; +- block frames per channel; +- channel count; +- callback budget; +- clock/counter source and overhead; +- memory placement and cache state where applicable; +- warm-up/measured blocks; +- measured duration. + +### Metrics + +- mean; +- p99; +- p99.9; +- observed maximum; +- p99.9 and maximum margins/loads; +- execution deadline misses/rate/lateness; +- callback interval overruns; +- driver xruns; +- processing failures; +- invalid/non-finite observations. + +### Model boundary + +- sensitivity model enabled/disabled; +- complete model configuration if enabled; +- target profiling performed/not performed; +- tests and measurements not run. + +## 12. Current DaisyHost implementation mapping + +`DaisyHost/tools/pedal_multidelay_audition/` implements the host subset: + +- `std::chrono::steady_clock`; +- timer-overhead record; +- 1,000 warm-up blocks; +- 50,000 measured blocks per mode; +- preallocated bounded duration buffer; +- arithmetic mean; +- nearest-rank p99 and p99.9 via `std::nth_element`; +- observed maximum; +- execution deadline-miss count/rate/lateness; +- non-finite processing-failure count; +- per-mode CSV plus environment JSON; +- sensitivity model disabled; +- explicit `TARGET_TIMING_NOT_RUN`. + +Not implemented in that host runner: + +- callback-start interval timing; +- driver xrun reporting; +- CPU affinity or priority control; +- physical target counters; +- SDRAM/cache measurement; +- calibrated host sensitivity projection; +- WCET analysis. + +## 13. Non-goals + +The profiling system shall not: + +- estimate audio quality; +- replace listening or calibrated audio measurement; +- replace target profiling tools; +- assume cycle-accurate MCU simulation; +- use CPU percentage as the primary metric; +- treat p99.9 or observed maximum as WCET; +- hide observed misses behind a percentile; +- perform unbounded statistical accumulation; +- convert host nanoseconds into Daisy callback margin; +- authorize product/production readiness. + +## 14. Completion gate + +A profiler implementation is acceptable for its declared surface only when: + +- formulas and units match this specification; +- timing source is monotonic and recorded; +- acquisition is bounded and real-time safe for the measured scope; +- percentile convention and sample sufficiency are explicit; +- deadline misses and invalid observations are counted; +- measured and modeled results remain separate; +- host and target evidence are not conflated; +- deterministic tests validate metric calculation and invalid-input behavior; +- performed and unperformed checks are visible. + +Current disposition: + +```text +HOST_SUBSET: IMPLEMENTED_AND_CI_EXECUTED +TARGET_PROFILER: SEPARATE_EXISTING_CANDIDATE +PHYSICAL_TARGET_RUN: NOT_RUN_IN_THIS_WORK_PACKAGE +WCET: UNKNOWN +``` From 13bed0938980da1278adab3fa9c52b85cc42d148 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:18:41 +0200 Subject: [PATCH 13/18] ci: include profiling specification in audition gate scope --- .github/workflows/daisyhost-pedal-audition.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/daisyhost-pedal-audition.yml b/.github/workflows/daisyhost-pedal-audition.yml index acd995341..e8a0bc5c6 100644 --- a/.github/workflows/daisyhost-pedal-audition.yml +++ b/.github/workflows/daisyhost-pedal-audition.yml @@ -15,6 +15,7 @@ on: - "DaisyHost/tools/pedal_multidelay_audition/**" - "DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv" - "DaisyHost/docs/pedal-multidelay-audition-work-package-v0.2.md" + - "DaisyHost/docs/real-time-dsp-profiling-spec-v1.1.md" pull_request: paths: - ".github/workflows/daisyhost-pedal-audition.yml" @@ -26,6 +27,7 @@ on: - "DaisyHost/tools/pedal_multidelay_audition/**" - "DaisyHost/training/examples/pedal_multidelay_audition_matrix.csv" - "DaisyHost/docs/pedal-multidelay-audition-work-package-v0.2.md" + - "DaisyHost/docs/real-time-dsp-profiling-spec-v1.1.md" permissions: contents: read From 08ad40e6109fe0daf509d05f339121002db97c45 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:19:45 +0200 Subject: [PATCH 14/18] ci: initialize only DaisyHost test dependencies --- .github/workflows/daisyhost-pedal-audition.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/daisyhost-pedal-audition.yml b/.github/workflows/daisyhost-pedal-audition.yml index e8a0bc5c6..2e96434f8 100644 --- a/.github/workflows/daisyhost-pedal-audition.yml +++ b/.github/workflows/daisyhost-pedal-audition.yml @@ -42,6 +42,16 @@ jobs: - name: Checkout exact revision uses: actions/checkout@v4 + - name: Initialize DaisyHost test dependencies only + shell: pwsh + run: >- + git submodule update + --init + --depth 1 + DaisySP + stmlib + third_party/CloudSeedCore + - name: Configure DaisyHost test build shell: pwsh run: >- From 9a41789f28b6a5959da674d26c3fba6d63398bb5 Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:32:29 +0200 Subject: [PATCH 15/18] ci: generate clang-format 10 output for pedal audition runner --- .../temporary-format-pedal-audition.yml | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/temporary-format-pedal-audition.yml diff --git a/.github/workflows/temporary-format-pedal-audition.yml b/.github/workflows/temporary-format-pedal-audition.yml new file mode 100644 index 000000000..fcb768209 --- /dev/null +++ b/.github/workflows/temporary-format-pedal-audition.yml @@ -0,0 +1,33 @@ +name: Temporary Format Pedal Audition Runner + +on: + push: + branches: + - "codex/pedal-multidelay-audition-profiler-2026-08-13" + paths: + - ".github/workflows/temporary-format-pedal-audition.yml" + +permissions: + contents: read + +jobs: + format: + runs-on: ubuntu-24.04 + timeout-minutes: 8 + steps: + - uses: actions/checkout@v4 + - name: Apply repository clang-format 10 + uses: DoozyX/clang-format-lint-action@v0.20 + with: + source: DaisyHost/tools/pedal_multidelay_audition/main.cpp + extensions: h,cpp + clangFormatVersion: 10 + inplace: true + style: file + - name: Upload formatted source + uses: actions/upload-artifact@v4 + with: + name: pedal-audition-main-cpp-clang-format-10 + path: DaisyHost/tools/pedal_multidelay_audition/main.cpp + if-no-files-found: error + retention-days: 1 From f0bbf916f5c6972710a2cccae7fb5b9231079f7a Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:34:09 +0200 Subject: [PATCH 16/18] ci: commit generated clang-format 10 output --- .../temporary-format-pedal-audition.yml | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/temporary-format-pedal-audition.yml b/.github/workflows/temporary-format-pedal-audition.yml index fcb768209..1977f175d 100644 --- a/.github/workflows/temporary-format-pedal-audition.yml +++ b/.github/workflows/temporary-format-pedal-audition.yml @@ -8,7 +8,7 @@ on: - ".github/workflows/temporary-format-pedal-audition.yml" permissions: - contents: read + contents: write jobs: format: @@ -16,6 +16,8 @@ jobs: timeout-minutes: 8 steps: - uses: actions/checkout@v4 + with: + ref: codex/pedal-multidelay-audition-profiler-2026-08-13 - name: Apply repository clang-format 10 uses: DoozyX/clang-format-lint-action@v0.20 with: @@ -24,10 +26,16 @@ jobs: clangFormatVersion: 10 inplace: true style: file - - name: Upload formatted source - uses: actions/upload-artifact@v4 - with: - name: pedal-audition-main-cpp-clang-format-10 - path: DaisyHost/tools/pedal_multidelay_audition/main.cpp - if-no-files-found: error - retention-days: 1 + - name: Commit formatted source + shell: bash + run: | + set -euo pipefail + git add DaisyHost/tools/pedal_multidelay_audition/main.cpp + if git diff --cached --quiet; then + echo "No formatting change required" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "style: apply clang-format 10 to audition runner" + git push origin HEAD:codex/pedal-multidelay-audition-profiler-2026-08-13 From 78e11f643b1401a497ef1acae38dd5de5db21825 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:34:37 +0000 Subject: [PATCH 17/18] style: apply clang-format 10 to audition runner --- .../tools/pedal_multidelay_audition/main.cpp | 244 ++++++++++-------- 1 file changed, 130 insertions(+), 114 deletions(-) diff --git a/DaisyHost/tools/pedal_multidelay_audition/main.cpp b/DaisyHost/tools/pedal_multidelay_audition/main.cpp index a6060f7cc..1fe684a78 100644 --- a/DaisyHost/tools/pedal_multidelay_audition/main.cpp +++ b/DaisyHost/tools/pedal_multidelay_audition/main.cpp @@ -34,10 +34,10 @@ constexpr double kSampleRate = 48000.0; constexpr std::size_t kBlockSize = 48; constexpr std::size_t kProfileWarmupBlocks = 1000; constexpr std::size_t kProfileMeasuredBlocks = 50000; -constexpr double kMinimumAudibleRms = 1.0e-6; -constexpr double kMinimumTailRms = 1.0e-6; -constexpr double kMaximumAllowedPeak = 20.0; -constexpr double kPi = 3.14159265358979323846; +constexpr double kMinimumAudibleRms = 1.0e-6; +constexpr double kMinimumTailRms = 1.0e-6; +constexpr double kMaximumAllowedPeak = 20.0; +constexpr double kPi = 3.14159265358979323846; std::string Trim(std::string value) { @@ -52,9 +52,10 @@ std::string Trim(std::string value) std::string ToUpper(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { - return static_cast(std::toupper(c)); - }); + std::transform( + value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::toupper(c)); + }); return value; } @@ -87,8 +88,8 @@ float ParseFloat(const std::string& text, const std::string& fieldName) double ParseDouble(const std::string& text, const std::string& fieldName) { - std::size_t consumed = 0; - const double value = std::stod(text, &consumed); + std::size_t consumed = 0; + const double value = std::stod(text, &consumed); if(consumed != text.size() || !std::isfinite(value)) { throw std::runtime_error("Invalid " + fieldName + ": " + text); @@ -183,10 +184,10 @@ struct Event kFreezeClear, }; - std::size_t frame = 0; - Kind kind = Kind::kSlot; - PedalSlot slot = PedalSlot::kTime; - float value = 0.0f; + std::size_t frame = 0; + Kind kind = Kind::kSlot; + PedalSlot slot = PedalSlot::kTime; + float value = 0.0f; PedalFreezeState freezeState = PedalFreezeState::kIdle; }; @@ -222,8 +223,8 @@ std::vector ParseEvents(const std::string& eventText) const double timeSeconds = ParseDouble(parts[0], "event time"); Event event; const double exactFrame = timeSeconds * kSampleRate; - event.frame = static_cast( - std::llround(exactFrame / static_cast(kBlockSize))) + event.frame = static_cast(std::llround( + exactFrame / static_cast(kBlockSize))) * kBlockSize; const std::string kind = ToUpper(parts[1]); @@ -233,9 +234,10 @@ std::vector ParseEvents(const std::string& eventText) { throw std::runtime_error("Malformed slot event: " + token); } - event.kind = Event::Kind::kSlot; - event.slot = ParseSlot(parts[2]); - event.value = std::clamp(ParseFloat(parts[3], "slot value"), 0.0f, 1.0f); + event.kind = Event::Kind::kSlot; + event.slot = ParseSlot(parts[2]); + event.value + = std::clamp(ParseFloat(parts[3], "slot value"), 0.0f, 1.0f); } else if(kind == "FREEZE") { @@ -299,10 +301,8 @@ std::vector ReadMatrix(const fs::path& path) scenario.durationSeconds = ParseDouble(fields[4], "duration"); for(std::size_t slot = 0; slot < scenario.slots.size(); ++slot) { - scenario.slots[slot] - = std::clamp(ParseFloat(fields[5 + slot], "normalized slot"), - 0.0f, - 1.0f); + scenario.slots[slot] = std::clamp( + ParseFloat(fields[5 + slot], "normalized slot"), 0.0f, 1.0f); } scenario.eventText = fields[10]; scenario.events = ParseEvents(scenario.eventText); @@ -318,8 +318,9 @@ std::vector ReadMatrix(const fs::path& path) if(scenarios.size() != 15) { - throw std::runtime_error("Audition matrix must contain exactly 15 cells; got " - + std::to_string(scenarios.size())); + throw std::runtime_error( + "Audition matrix must contain exactly 15 cells; got " + + std::to_string(scenarios.size())); } return scenarios; } @@ -349,8 +350,8 @@ class SourceGenerator { const double local = std::fmod(t, 0.40); const float sign = (static_cast(t / 0.40) & 1U) == 0U - ? 1.0f - : -1.0f; + ? 1.0f + : -1.0f; return sign * 0.9f * static_cast(std::exp(-180.0 * local)); } if(source_ == "single_pluck") @@ -368,7 +369,7 @@ class SourceGenerator private: float RandomSigned() { - state_ = state_ * 1664525U + 1013904223U; + state_ = state_ * 1664525U + 1013904223U; const float unit = static_cast((state_ >> 8U) & 0x00FFFFFFU) / static_cast(0x01000000U); return unit * 2.0f - 1.0f; @@ -379,8 +380,7 @@ class SourceGenerator const float envelope = static_cast(std::exp(-24.0 * local)); const float harmonic = 0.34f * static_cast(std::sin(2.0 * kPi * 110.0 * local)) - + 0.18f - * static_cast(std::sin(2.0 * kPi * 220.0 * local)); + + 0.18f * static_cast(std::sin(2.0 * kPi * 220.0 * local)); return envelope * (0.48f * RandomSigned() + harmonic); } @@ -432,9 +432,7 @@ void ApplyEvent(PedalDelayEngine& engine, const Event& event) case Event::Kind::kFreezeState: engine.SetFreezeState(event.freezeState); break; - case Event::Kind::kFreezeClear: - engine.FreezeClear(); - break; + case Event::Kind::kFreezeClear: engine.FreezeClear(); break; } } @@ -454,7 +452,7 @@ void WriteLe32(std::ostream& output, std::uint32_t value) output.write(bytes, 4); } -void WriteFloat32Wav(const fs::path& path, +void WriteFloat32Wav(const fs::path& path, const std::vector& interleaved, std::uint32_t sampleRate, std::uint16_t channels) @@ -549,11 +547,12 @@ RenderMetrics MeasureRender(const std::vector& interleaved, return metrics; } - double totalSquares = 0.0; - double tailSquares = 0.0; - double stereoDiff = 0.0; - const std::size_t frames = interleaved.size() / 2U; - const std::size_t tailStart = frames > tailFrames ? frames - tailFrames : 0U; + double totalSquares = 0.0; + double tailSquares = 0.0; + double stereoDiff = 0.0; + const std::size_t frames = interleaved.size() / 2U; + const std::size_t tailStart + = frames > tailFrames ? frames - tailFrames : 0U; float previousLeft = 0.0f; for(std::size_t frame = 0; frame < frames; ++frame) @@ -592,13 +591,16 @@ RenderMetrics MeasureRender(const std::vector& interleaved, const double sampleCount = static_cast(frames * 2U); const double tailSampleCount = static_cast((frames - tailStart) * 2U); - metrics.rms = sampleCount > 0.0 ? std::sqrt(totalSquares / sampleCount) : 0.0; - metrics.tailRms - = tailSampleCount > 0.0 ? std::sqrt(tailSquares / tailSampleCount) : 0.0; + metrics.rms + = sampleCount > 0.0 ? std::sqrt(totalSquares / sampleCount) : 0.0; + metrics.tailRms = tailSampleCount > 0.0 + ? std::sqrt(tailSquares / tailSampleCount) + : 0.0; metrics.stereoDifference - = frames > 0 ? std::sqrt(stereoDiff / static_cast(frames)) : 0.0; + = frames > 0 ? std::sqrt(stereoDiff / static_cast(frames)) + : 0.0; metrics.checksum = Fnv1a64(interleaved); - metrics.passed = metrics.nonFinite == 0 && metrics.nonZero > 0 + metrics.passed = metrics.nonFinite == 0 && metrics.nonZero > 0 && metrics.rms > kMinimumAudibleRms && metrics.peak < kMaximumAllowedPeak && (!expectTail || metrics.tailRms > kMinimumTailRms); @@ -616,8 +618,8 @@ struct RenderResult }; RenderResult RenderScenario(const Scenario& scenario, - const fs::path& outputDirectory, - std::uint32_t seed) + const fs::path& outputDirectory, + std::uint32_t seed) { EngineFixture fixture; ApplyScenarioState(fixture, scenario); @@ -630,7 +632,7 @@ RenderResult RenderScenario(const Scenario& scenario, const std::size_t frames = static_cast( std::llround(scenario.durationSeconds * kSampleRate)); - std::vector interleaved(frames * 2U, 0.0f); + std::vector interleaved(frames * 2U, 0.0f); std::array inputLeft{}; std::array inputRight{}; std::array outputLeft{}; @@ -669,10 +671,11 @@ RenderResult RenderScenario(const Scenario& scenario, result.scenario = scenario; result.finalFreezeState = fixture.engine.GetFreezeState(); result.nativeSlots = initialNativeSlots; - result.metrics = MeasureRender( - interleaved, static_cast(0.5 * kSampleRate), scenario.expectTail); - result.wavPath = outputDirectory / (scenario.name + ".wav"); - result.manifestPath = outputDirectory / (scenario.name + ".json"); + result.metrics = MeasureRender(interleaved, + static_cast(0.5 * kSampleRate), + scenario.expectTail); + result.wavPath = outputDirectory / (scenario.name + ".wav"); + result.manifestPath = outputDirectory / (scenario.name + ".json"); WriteFloat32Wav(result.wavPath, interleaved, static_cast(kSampleRate), @@ -706,10 +709,11 @@ RenderResult RenderScenario(const Scenario& scenario, } manifest << "],\n" << " \"events\": \"" << JsonEscape(scenario.eventText) << "\",\n" - << " \"expect_tail\": " << (scenario.expectTail ? "true" : "false") - << ",\n" + << " \"expect_tail\": " + << (scenario.expectTail ? "true" : "false") << ",\n" << " \"final_freeze_state\": \"" - << daisyhost::PedalFreezeStateName(result.finalFreezeState) << "\",\n" + << daisyhost::PedalFreezeStateName(result.finalFreezeState) + << "\",\n" << " \"metrics\": {\n" << " \"peak\": " << result.metrics.peak << ",\n" << " \"rms\": " << result.metrics.rms << ",\n" @@ -717,13 +721,14 @@ RenderResult RenderScenario(const Scenario& scenario, << " \"max_step\": " << result.metrics.maxStep << ",\n" << " \"stereo_difference_rms\": " << result.metrics.stereoDifference << ",\n" - << " \"non_finite_samples\": " << result.metrics.nonFinite << ",\n" + << " \"non_finite_samples\": " << result.metrics.nonFinite + << ",\n" << " \"non_zero_frames\": " << result.metrics.nonZero << ",\n" << " \"checksum_fnv1a64\": \"" << result.metrics.checksum << "\"\n" << " },\n" - << " \"status\": \"" - << (result.metrics.passed ? "PASS" : "FAIL") << "\"\n" + << " \"status\": \"" << (result.metrics.passed ? "PASS" : "FAIL") + << "\"\n" << "}\n"; return result; } @@ -735,23 +740,24 @@ double Percentile(const std::vector& values, double quantile) return 0.0; } std::vector scratch(values); - const std::size_t rank = static_cast( + const std::size_t rank = static_cast( std::ceil(quantile * static_cast(scratch.size()))); - const std::size_t index = std::min( - scratch.size() - 1U, rank == 0U ? 0U : rank - 1U); - std::nth_element( - scratch.begin(), scratch.begin() + static_cast(index), scratch.end()); + const std::size_t index + = std::min(scratch.size() - 1U, rank == 0U ? 0U : rank - 1U); + std::nth_element(scratch.begin(), + scratch.begin() + static_cast(index), + scratch.end()); return scratch[index]; } double MeasureClockOverheadUs() { - using Clock = std::chrono::steady_clock; + using Clock = std::chrono::steady_clock; double minimum = std::numeric_limits::infinity(); for(std::size_t iteration = 0; iteration < 10000; ++iteration) { - const auto start = Clock::now(); - const auto stop = Clock::now(); + const auto start = Clock::now(); + const auto stop = Clock::now(); const double duration = std::chrono::duration(stop - start).count(); minimum = std::min(minimum, duration); @@ -762,21 +768,21 @@ double MeasureClockOverheadUs() struct TimingSummary { std::string mode; - std::size_t warmupBlocks = 0; - std::size_t measuredBlocks = 0; - double measuredDurationS = 0.0; - double blockBudgetUs = 0.0; - double meanUs = 0.0; - double p99Us = 0.0; - double p999Us = 0.0; - double maxUs = 0.0; - double marginP999Us = 0.0; - double tailUtilization = 0.0; - std::size_t deadlineMisses = 0; - double deadlineMissRate = 0.0; - double maxLatenessUs = 0.0; - std::size_t processingFailures = 0; - double timerOverheadUs = 0.0; + std::size_t warmupBlocks = 0; + std::size_t measuredBlocks = 0; + double measuredDurationS = 0.0; + double blockBudgetUs = 0.0; + double meanUs = 0.0; + double p99Us = 0.0; + double p999Us = 0.0; + double maxUs = 0.0; + double marginP999Us = 0.0; + double tailUtilization = 0.0; + std::size_t deadlineMisses = 0; + double deadlineMissRate = 0.0; + double maxLatenessUs = 0.0; + std::size_t processingFailures = 0; + double timerOverheadUs = 0.0; std::string status; }; @@ -798,7 +804,8 @@ void ConfigureProfileMode(EngineFixture& fixture, PedalDelayMode mode) } for(std::size_t slot = 0; slot < values.size(); ++slot) { - fixture.engine.SetSlotNormalized(static_cast(slot), values[slot]); + fixture.engine.SetSlotNormalized(static_cast(slot), + values[slot]); } fixture.engine.Reset(); } @@ -828,10 +835,10 @@ TimingSummary ProfileMode(PedalDelayMode mode, double timerOverheadUs) std::array outputRight{}; for(std::size_t frame = 0; frame < kBlockSize; ++frame) { - inputLeft[frame] - = 0.35f * static_cast(std::sin(2.0 * kPi * 317.0 - * static_cast(frame) - / kSampleRate)); + inputLeft[frame] = 0.35f + * static_cast(std::sin( + 2.0 * kPi * 317.0 * static_cast(frame) + / kSampleRate)); inputRight[frame] = inputLeft[frame]; } @@ -893,12 +900,14 @@ TimingSummary ProfileMode(PedalDelayMode mode, double timerOverheadUs) } TimingSummary summary; - summary.mode = daisyhost::PedalDelayModeName(mode); - summary.warmupBlocks = kProfileWarmupBlocks; - summary.measuredBlocks = kProfileMeasuredBlocks; - summary.measuredDurationS = static_cast(kProfileMeasuredBlocks * kBlockSize) - / kSampleRate; - summary.blockBudgetUs = 1000000.0 * static_cast(kBlockSize) / kSampleRate; + summary.mode = daisyhost::PedalDelayModeName(mode); + summary.warmupBlocks = kProfileWarmupBlocks; + summary.measuredBlocks = kProfileMeasuredBlocks; + summary.measuredDurationS + = static_cast(kProfileMeasuredBlocks * kBlockSize) + / kSampleRate; + summary.blockBudgetUs + = 1000000.0 * static_cast(kBlockSize) / kSampleRate; summary.meanUs = std::accumulate(durations.begin(), durations.end(), 0.0) / static_cast(durations.size()); summary.p99Us = Percentile(durations, 0.99); @@ -906,17 +915,16 @@ TimingSummary ProfileMode(PedalDelayMode mode, double timerOverheadUs) summary.maxUs = *std::max_element(durations.begin(), durations.end()); summary.marginP999Us = summary.blockBudgetUs - summary.p999Us; summary.tailUtilization = summary.p999Us / summary.blockBudgetUs; - summary.deadlineMisses = static_cast(std::count_if( - durations.begin(), durations.end(), [&](double duration) { + summary.deadlineMisses = static_cast( + std::count_if(durations.begin(), durations.end(), [&](double duration) { return duration > summary.blockBudgetUs; })); - summary.deadlineMissRate - = static_cast(summary.deadlineMisses) - / static_cast(summary.measuredBlocks); + summary.deadlineMissRate = static_cast(summary.deadlineMisses) + / static_cast(summary.measuredBlocks); summary.maxLatenessUs = std::max(0.0, summary.maxUs - summary.blockBudgetUs); summary.processingFailures = processingFailures; - summary.timerOverheadUs = timerOverheadUs; + summary.timerOverheadUs = timerOverheadUs; if(!Clock::is_steady || processingFailures != 0U) { summary.status = "HOST_MEASUREMENT_INVALID"; @@ -963,12 +971,14 @@ std::string OsIdentity() #endif } -void WriteTimingReports(const fs::path& outputDirectory, +void WriteTimingReports(const fs::path& outputDirectory, const std::vector& summaries) { std::ofstream csv(outputDirectory / "host_profile.csv"); - csv << "mode,warmup_blocks,measured_blocks,measured_duration_s,block_budget_us," - "mean_us,p99_us,p99_9_us,max_us,margin_p99_9_us,tail_utilization_ratio," + csv << "mode,warmup_blocks,measured_blocks,measured_duration_s,block_" + "budget_us," + "mean_us,p99_us,p99_9_us,max_us,margin_p99_9_us,tail_utilization_" + "ratio," "deadline_miss_count,deadline_miss_rate,max_lateness_us," "processing_failure_count,timer_overhead_us,status\n"; csv << std::setprecision(12); @@ -984,7 +994,8 @@ void WriteTimingReports(const fs::path& outputDirectory, << ',' << summary.timerOverheadUs << ',' << summary.status << '\n'; } - std::ofstream environment(outputDirectory / "host_profile_environment.json"); + std::ofstream environment(outputDirectory + / "host_profile_environment.json"); environment << "{\n" << " \"clock\": \"std::chrono::steady_clock\",\n" << " \"clock_is_steady\": " @@ -1006,12 +1017,14 @@ void WriteTimingReports(const fs::path& outputDirectory, << "}\n"; } -void WriteAggregateManifest(const fs::path& outputDirectory, +void WriteAggregateManifest(const fs::path& outputDirectory, const std::vector& results) { std::ofstream csv(outputDirectory / "audition_manifest.csv"); - csv << "scenario,mode,cell,source,duration_s,time_norm,feedback_norm,mix_norm," - "color_norm,motion_norm,time_native,feedback_native,mix_native,color_native," + csv << "scenario,mode,cell,source,duration_s,time_norm,feedback_norm,mix_" + "norm," + "color_norm,motion_norm,time_native,feedback_native,mix_native," + "color_native," "motion_native,wav,peak,rms,tail_rms,max_step,stereo_difference_rms," "non_finite_samples,checksum_fnv1a64,final_freeze_state,status\n"; csv << std::setprecision(12); @@ -1032,8 +1045,8 @@ void WriteAggregateManifest(const fs::path& outputDirectory, csv << ',' << result.wavPath.filename().string() << ',' << result.metrics.peak << ',' << result.metrics.rms << ',' << result.metrics.tailRms << ',' << result.metrics.maxStep << ',' - << result.metrics.stereoDifference << ',' << result.metrics.nonFinite - << ',' << result.metrics.checksum << ',' + << result.metrics.stereoDifference << ',' + << result.metrics.nonFinite << ',' << result.metrics.checksum << ',' << daisyhost::PedalFreezeStateName(result.finalFreezeState) << ',' << (result.metrics.passed ? "PASS" : "FAIL") << '\n'; } @@ -1067,7 +1080,8 @@ Arguments ParseArguments(int argc, char** argv) if(arguments.matrix.empty() || arguments.outputDirectory.empty()) { throw std::runtime_error( - "Usage: pedal_multidelay_audition --matrix --output-dir "); + "Usage: pedal_multidelay_audition --matrix --output-dir " + ""); } return arguments; } @@ -1086,9 +1100,10 @@ int main(int argc, char** argv) bool allRendersPassed = true; for(std::size_t index = 0; index < scenarios.size(); ++index) { - auto result = RenderScenario(scenarios[index], - arguments.outputDirectory, - static_cast(17U + index)); + auto result + = RenderScenario(scenarios[index], + arguments.outputDirectory, + static_cast(17U + index)); std::cout << result.scenario.name << ' ' << (result.metrics.passed ? "PASS" : "FAIL") << " peak=" << result.metrics.peak @@ -1100,7 +1115,7 @@ int main(int argc, char** argv) } WriteAggregateManifest(arguments.outputDirectory, results); - const double timerOverheadUs = MeasureClockOverheadUs(); + const double timerOverheadUs = MeasureClockOverheadUs(); std::vector timing; for(PedalDelayMode mode : {PedalDelayMode::kDigi, PedalDelayMode::kTape, @@ -1119,12 +1134,13 @@ int main(int argc, char** argv) } WriteTimingReports(arguments.outputDirectory, timing); - const bool profileValid = std::all_of( - timing.begin(), timing.end(), [](const TimingSummary& summary) { - return summary.status != "HOST_MEASUREMENT_INVALID" + const bool profileValid = std::all_of(timing.begin(), + timing.end(), + [](const TimingSummary& summary) { + return summary.status != "HOST_MEASUREMENT_INVALID" && summary.status != "INSUFFICIENT_SAMPLE_COUNT" && summary.processingFailures == 0U; - }); + }); if(!allRendersPassed || !profileValid) { From f65de8be96b268576e121e65f5423b4852b9c44c Mon Sep 17 00:00:00 2001 From: Denys Date: Thu, 13 Aug 2026 13:35:02 +0200 Subject: [PATCH 18/18] ci: remove temporary formatting workflow --- .../temporary-format-pedal-audition.yml | 41 ------------------- 1 file changed, 41 deletions(-) delete mode 100644 .github/workflows/temporary-format-pedal-audition.yml diff --git a/.github/workflows/temporary-format-pedal-audition.yml b/.github/workflows/temporary-format-pedal-audition.yml deleted file mode 100644 index 1977f175d..000000000 --- a/.github/workflows/temporary-format-pedal-audition.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Temporary Format Pedal Audition Runner - -on: - push: - branches: - - "codex/pedal-multidelay-audition-profiler-2026-08-13" - paths: - - ".github/workflows/temporary-format-pedal-audition.yml" - -permissions: - contents: write - -jobs: - format: - runs-on: ubuntu-24.04 - timeout-minutes: 8 - steps: - - uses: actions/checkout@v4 - with: - ref: codex/pedal-multidelay-audition-profiler-2026-08-13 - - name: Apply repository clang-format 10 - uses: DoozyX/clang-format-lint-action@v0.20 - with: - source: DaisyHost/tools/pedal_multidelay_audition/main.cpp - extensions: h,cpp - clangFormatVersion: 10 - inplace: true - style: file - - name: Commit formatted source - shell: bash - run: | - set -euo pipefail - git add DaisyHost/tools/pedal_multidelay_audition/main.cpp - if git diff --cached --quiet; then - echo "No formatting change required" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "style: apply clang-format 10 to audition runner" - git push origin HEAD:codex/pedal-multidelay-audition-profiler-2026-08-13