Skip to content

Commit 459cce5

Browse files
committed
Acceptance testing: Added playhead support
1 parent 278c11b commit 459cce5

15 files changed

Lines changed: 379 additions & 28 deletions

CLAUDE.md

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ pluginval/
114114
│ ├── AddPluginvalTests.cmake # CMake module for CTest integration
115115
│ ├── test_plugins/ # Test plugin files
116116
│ │ ├── tone_generator/ # Deterministic dogfood generator (PLUGINVAL_BUILD_TEST_PLUGINS)
117-
│ │ └── gain/ # Deterministic dogfood gain effect (for the input.audio path)
117+
│ │ ├── gain/ # Deterministic dogfood gain effect (for the input.audio path)
118+
│ │ └── playhead_probe/ # Writes the host transport to output (for the playhead path)
118119
│ ├── acceptance/ # Acceptance self-tests: configs (*.json.in), inputs/, checked-in refs/ WAVs
119120
│ ├── mac_tests/ # macOS-specific tests
120121
│ └── windows_tests.bat # Windows test scripts
@@ -286,25 +287,27 @@ spec: `tests/acceptance/Acceptance testing design.md`; end-user guide:
286287
layering: the test config is a positional argument loaded standalone.
287288
- **Flow** (`AcceptanceTest.cpp`): load plugin → apply `state.file` then
288289
`state.parameters` (normalised, matched by index / case-insensitive name or
289-
paramID) → feed `input.audio`/`input.midi` or silence → render a fixed
290-
duration block-by-block (reusing the `AudioProcessingTest` shape + the
291-
VST3-safe helpers in `TestUtilities.h`). If no reference exists it **records**
292-
one (32-bit float WAV + `<name>.wav.json` sidecar manifest); otherwise it
293-
**compares** and writes a diff WAV on failure. Exit `0`/`1`.
290+
paramID) → feed `input.audio`/`input.midi` or silence → if a `playhead` is
291+
configured, point a fixed-tempo transport (`FixedPlayHead`, position advances
292+
per block) at the plugin → render a fixed duration block-by-block (reusing the
293+
`AudioProcessingTest` shape + the VST3-safe helpers in `TestUtilities.h`). If
294+
no reference exists it **records** one (32-bit float WAV + `<name>.wav.json`
295+
sidecar manifest); otherwise it **compares** and writes a diff WAV on failure.
296+
Exit `0`/`1`.
294297
- **Comparators** (`ReferenceComparator.cpp/h`): pluggable `Comparator` +
295298
`createComparator(name)` registry. v1 ships only `sample` (per-sample abs-diff
296299
tolerance, default one 16-bit LSB = `1/32768`; `0` = bit-exact). Adding
297300
`spectrum`/`crosscorr`/etc. is one registry entry, no config/runner changes.
298-
- **Dogfood + self-tests**: two minimal deterministic `juce_add_plugin` targets
301+
- **Dogfood + self-tests**: three minimal deterministic `juce_add_plugin` targets
299302
behind `PLUGINVAL_BUILD_TEST_PLUGINS``tests/test_plugins/tone_generator/`
300-
(closed-form sine/square generator, phase resets on `prepareToPlay`) and
301-
`tests/test_plugins/gain/` (a gain effect, used to dogfood the `input.audio`
302-
path: it gains a checked-in full-height sine and is compared bit-exact).
303-
`tests/acceptance/` holds checked-in configs (`*.json.in`, the plugin paths +
304-
input dir substituted at configure time), `inputs/` and reference WAVs, run via
305-
CTest (`pluginval.acceptance.*`: sine-440, square-220, square-state, gain-half).
306-
Phase 2 items (config-array multiplexing,
307-
automation, playhead, extra comparators, child-process isolation) are notes
303+
(closed-form sine/square generator, phase resets on `prepareToPlay`),
304+
`tests/test_plugins/gain/` (a gain effect, dogfoods the `input.audio` path) and
305+
`tests/test_plugins/playhead_probe/` (writes the host transport to its output,
306+
dogfoods the `playhead` path). `tests/acceptance/` holds checked-in configs
307+
(`*.json.in`, the plugin paths + input dir substituted at configure time),
308+
`inputs/` and reference WAVs, run via CTest (`pluginval.acceptance.*`: sine-440,
309+
square-220, square-state, gain-half, playhead-120). Phase 2 items (config-array
310+
multiplexing, automation, extra comparators, child-process isolation) are notes
308311
only.
309312

310313
### Test Framework

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,5 +274,6 @@ if (PLUGINVAL_BUILD_TEST_PLUGINS)
274274
enable_testing()
275275
add_subdirectory(tests/test_plugins/tone_generator)
276276
add_subdirectory(tests/test_plugins/gain)
277+
add_subdirectory(tests/test_plugins/playhead_probe)
277278
add_subdirectory(tests/acceptance)
278279
endif()

docs/Acceptance testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ JSON keys are `snake_case`. The most useful fields:
4848
| `state.file` | A binary `getStateInformation` blob to restore first (e.g. a captured preset). Applied before `state.parameters`. |
4949
| `reference` | The golden `.wav`. Defaults to `<name>.wav` next to the config. |
5050
| `render_duration` | Seconds to render. If omitted, the input audio's length is used. |
51+
| `playhead` | A fixed transport for tempo-dependent plugins: `{ "bpm": 120, "time_signature": { "numerator": 4, "denominator": 4 } }`. Omit it and the plugin gets no playhead. The position advances with the render. |
5152
| `comparison` | How to compare. `{ "sample": <tolerance> }` is a per-sample absolute-difference tolerance (`0` = bit-exact); the default is one 16-bit LSB. |
5253

5354
### Determinism matters

source/CommandLineTests.cpp

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,55 @@ struct CommandLineTests : public juce::UnitTest
506506
expect (! config.renderDuration.has_value());
507507
expectEquals (config.getComparison()["sample"].get<double>(), 0.0);
508508
}
509+
510+
beginTest ("Acceptance TestConfig playhead (object time signature)");
511+
{
512+
// Absent playhead -> unset (no transport supplied to the plugin).
513+
{
514+
const auto config = nlohmann::json::parse (R"({ "plugin": "P.vst3" })").get<acceptance::TestConfig>();
515+
expect (! config.playhead.has_value());
516+
}
517+
518+
// Present playhead -> parsed, with time_signature as an object.
519+
{
520+
const auto json = R"({
521+
"plugin": "P.vst3",
522+
"playhead": {
523+
"bpm": 90,
524+
"time_signature": { "numerator": 6, "denominator": 8 },
525+
"start_ppq": 4.0
526+
}
527+
})";
528+
529+
const auto config = nlohmann::json::parse (json).get<acceptance::TestConfig>();
530+
expect (config.playhead.has_value());
531+
expectEquals (config.playhead->bpm, 90.0);
532+
expectEquals (config.playhead->timeSigNumerator, 6);
533+
expectEquals (config.playhead->timeSigDenominator, 8);
534+
expectEquals (config.playhead->startPpq, 4.0);
535+
}
536+
537+
// Time signature defaults to 4/4 when omitted.
538+
{
539+
const auto config = nlohmann::json::parse (R"({ "plugin": "P.vst3", "playhead": { "bpm": 100 } })")
540+
.get<acceptance::TestConfig>();
541+
expect (config.playhead.has_value());
542+
expectEquals (config.playhead->timeSigNumerator, 4);
543+
expectEquals (config.playhead->timeSigDenominator, 4);
544+
}
545+
546+
// An invalid (zero) denominator is rejected.
547+
{
548+
bool threw = false;
549+
try
550+
{
551+
nlohmann::json::parse (R"({ "plugin": "P.vst3", "playhead": { "bpm": 120, "time_signature": { "numerator": 4, "denominator": 0 } } })")
552+
.get<acceptance::TestConfig>();
553+
}
554+
catch (const std::exception&) { threw = true; }
555+
expect (threw, "expected a zero denominator to be rejected");
556+
}
557+
}
509558
}
510559
};
511560

source/acceptance/AcceptanceTest.cpp

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,45 @@ namespace
3838
#endif
3939
}
4040

41+
//==============================================================================
42+
/** A fixed-tempo transport whose position advances with the render. Constructed
43+
from the config's playhead block and pointed at the plugin for the duration
44+
of the render; setSamplePosition() is called before each processBlock. */
45+
class FixedPlayHead : public juce::AudioPlayHead
46+
{
47+
public:
48+
FixedPlayHead (const TestConfig::PlayheadConfig& config, double sr)
49+
: cfg (config), sampleRate (sr)
50+
{
51+
}
52+
53+
void setSamplePosition (juce::int64 sample) noexcept { currentSample = sample; }
54+
55+
juce::Optional<PositionInfo> getPosition() const override
56+
{
57+
const double seconds = (double) currentSample / sampleRate;
58+
59+
// PPQ is measured in quarter notes; a bar is numerator * (4/denominator) of them.
60+
const double quarterNotesPerBar = cfg.timeSigNumerator * 4.0 / cfg.timeSigDenominator;
61+
const double ppq = cfg.startPpq + seconds * (cfg.bpm / 60.0);
62+
63+
PositionInfo info;
64+
info.setBpm (cfg.bpm);
65+
info.setTimeSignature (TimeSignature { cfg.timeSigNumerator, cfg.timeSigDenominator });
66+
info.setTimeInSamples (currentSample);
67+
info.setTimeInSeconds (seconds);
68+
info.setPpqPosition (ppq);
69+
info.setPpqPositionOfLastBarStart (std::floor (ppq / quarterNotesPerBar) * quarterNotesPerBar);
70+
info.setIsPlaying (true);
71+
return info;
72+
}
73+
74+
private:
75+
const TestConfig::PlayheadConfig cfg;
76+
const double sampleRate;
77+
juce::int64 currentSample = 0;
78+
};
79+
4180
//==============================================================================
4281
std::unique_ptr<juce::AudioPluginInstance> loadPlugin (juce::AudioPluginFormatManager& formatManager,
4382
const juce::String& pathOrID,
@@ -254,7 +293,15 @@ RenderedAudio renderPlugin (const TestConfig& config)
254293
if (numSamples <= 0)
255294
throw std::runtime_error ("render_duration is required when there is no input.audio");
256295

257-
// 4. Prepare and render block by block.
296+
// 4. Optional fixed transport for time-dependent plugins.
297+
std::unique_ptr<FixedPlayHead> playHead;
298+
if (config.playhead)
299+
{
300+
playHead = std::make_unique<FixedPlayHead> (*config.playhead, sampleRate);
301+
instance->setPlayHead (playHead.get());
302+
}
303+
304+
// 5. Prepare and render block by block.
258305
callPrepareToPlayOnMessageThreadIfVST3 (*instance, sampleRate, blockSize);
259306

260307
const int numInputChannels = instance->getTotalNumInputChannels();
@@ -270,6 +317,9 @@ RenderedAudio renderPlugin (const TestConfig& config)
270317
{
271318
const int thisBlock = juce::jmin (blockSize, numSamples - pos);
272319

320+
if (playHead != nullptr)
321+
playHead->setSamplePosition (pos);
322+
273323
block.clear();
274324

275325
// Copy the input audio slice into the block (silence past its end).
@@ -290,6 +340,7 @@ RenderedAudio renderPlugin (const TestConfig& config)
290340
output.copyFrom (c, pos, proc, c, 0, thisBlock);
291341
}
292342

343+
instance->setPlayHead (nullptr); // playHead is about to be destroyed
293344
callReleaseResourcesOnMessageThreadIfVST3 (*instance);
294345
instance.reset();
295346

source/acceptance/TestConfig.cpp

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ void to_json (nlohmann::json& j, const TestConfig& c)
7373

7474
if (! c.comparison.is_null())
7575
j["comparison"] = c.comparison;
76+
77+
if (c.playhead)
78+
{
79+
j["playhead"] = {
80+
{ "bpm", c.playhead->bpm },
81+
{ "time_signature", { { "numerator", c.playhead->timeSigNumerator },
82+
{ "denominator", c.playhead->timeSigDenominator } } },
83+
{ "start_ppq", c.playhead->startPpq }
84+
};
85+
}
7686
}
7787

7888
void from_json (const nlohmann::json& j, TestConfig& c)
@@ -98,6 +108,24 @@ void from_json (const nlohmann::json& j, TestConfig& c)
98108

99109
if (auto comp = j.find ("comparison"); comp != j.end() && ! comp->is_null())
100110
c.comparison = *comp;
111+
112+
if (auto ph = j.find ("playhead"); ph != j.end() && ph->is_object())
113+
{
114+
TestConfig::PlayheadConfig p;
115+
p.bpm = ph->value ("bpm", 120.0);
116+
p.startPpq = ph->value ("start_ppq", 0.0);
117+
118+
if (auto ts = ph->find ("time_signature"); ts != ph->end() && ts->is_object())
119+
{
120+
p.timeSigNumerator = ts->value ("numerator", 4);
121+
p.timeSigDenominator = ts->value ("denominator", 4);
122+
}
123+
124+
if (p.timeSigNumerator <= 0 || p.timeSigDenominator <= 0)
125+
throw std::runtime_error ("playhead.time_signature numerator and denominator must be positive");
126+
127+
c.playhead = p;
128+
}
101129
}
102130

103131
//==============================================================================

source/acceptance/TestConfig.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ namespace acceptance
4040
*/
4141
struct TestConfig
4242
{
43+
/** A fixed transport supplied to the plugin during the render, for
44+
time-dependent plugins (tempo-synced LFOs, arpeggiators, ...). The tempo
45+
and time signature are constant; the position advances with the render. */
46+
struct PlayheadConfig
47+
{
48+
double bpm = 120.0;
49+
int timeSigNumerator = 4;
50+
int timeSigDenominator = 4;
51+
double startPpq = 0.0; /**< Transport position (in quarter notes) at sample 0. */
52+
};
53+
4354
std::string name; /**< Labels results; derives the default reference path. */
4455
std::string plugin; /**< Plugin path or AU id. Required. */
4556
std::string inputAudio; /**< input.audio: path to an input audio file, or empty for silence. */
@@ -51,6 +62,7 @@ struct TestConfig
5162
int blockSize = 512;
5263
std::optional<double> renderDuration; /**< Seconds. Unset -> derive from the input length. */
5364
nlohmann::json comparison; /**< Map of comparator name -> sub-config. Empty -> default. */
65+
std::optional<PlayheadConfig> playhead; /**< Fixed transport. Unset -> no playhead supplied to the plugin. */
5466

5567
//==============================================================================
5668
/** The directory the config was loaded from. Relative reference / input /

tests/acceptance/Acceptance testing design.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ member names verbatim). Missing keys still fall back to the defaults.
122122
"block_size": 512,
123123
"render_duration": 2.0, // seconds; omitted -> use input length
124124
"comparison": { "sample": 1e-6 }, // omitted -> default 1/32768 (16-bit LSB)
125-
"playhead": { "bpm": 120 }, // future
125+
"playhead": { "bpm": 120, "time_signature": { "numerator": 4, "denominator": 4 } },
126126
"automation": [ /* phase 2 */ ]
127127
}
128128
```
@@ -142,7 +142,7 @@ member names verbatim). Missing keys still fall back to the defaults.
142142
| `block_size` | number | 512 | Single value. |
143143
| `render_duration` | number | input length | Seconds. `num_samples = round(duration * sample_rate)`. Input shorter than the duration is padded with silence; longer is truncated. |
144144
| `comparison` | object | `{ "sample": 0.0000305 }` | Map of comparator name -> its sub-config. See §5. |
145-
| `playhead` | object | none | **Future.** Fixed tempo / time signature for time-dependent plugins. |
145+
| `playhead` | object | none | Fixed transport for time-dependent plugins. `{ "bpm": <number>, "time_signature": { "numerator": N, "denominator": D }, "start_ppq": <number> }`. `time_signature` defaults to 4/4, `start_ppq` to 0. Omitted -> no playhead is set (the plugin sees `getPlayHead() == nullptr`). The tempo / time signature are constant; the position advances with the render. |
146146
| `automation` | array | none | **Phase 2.** Parameter changes scheduled at sample positions. |
147147

148148
### State precedence
@@ -323,6 +323,7 @@ tests/acceptance/
323323
square-220.json.in // tone gen, waveform=square, state.parameters, bit-exact
324324
square-state.json.in // tone gen, state.file blob + a gain parameter override
325325
gain-half.json.in // gain effect, input.audio = a full-height sine, bit-exact
326+
playhead-120.json.in // playhead probe, fixed transport (bpm 120, 4/4), bit-exact
326327
inputs/sine-full.wav // checked-in input for gain-half (±1.0 sine)
327328
refs/<case>.wav (+ .wav.json) // checked-in references + sidecar manifests
328329
refs/square-state.state // checked-in getStateInformation blob
@@ -337,7 +338,7 @@ stable enough to commit — a first smoke test of cross-platform portability for
337338
the `sample` comparator and the baseline for future comparators (`spectrum`,
338339
`crosscorr`, …).
339340

340-
The four cases cover the distinct render paths:
341+
The five cases cover the distinct render paths:
341342

342343
- **`sine-440` / `square-220`** — the `state.parameters` (name/index → normalised
343344
value) path. `square-220` compares bit-exact (`"sample": 0`).
@@ -350,6 +351,11 @@ The four cases cover the distinct render paths:
350351
full-height (±1.0) sine by 0.5 and is compared bit-exact (0.5 is exact in
351352
float). It also omits `render_duration`, so the render length is derived from
352353
the input file.
354+
- **`playhead-120`** — the **`playhead`** path: a third dogfood plugin
355+
(`tests/test_plugins/playhead_probe/`) writes the host transport into its
356+
output (channel 0 = `ppqPosition`, channel 1 = tempo), so the recorded
357+
reference is a direct check that the fixed transport reached the plugin. If the
358+
playhead regressed, the probe would output silence and the compare would fail.
353359

354360
## 9. Execution flow
355361

@@ -358,11 +364,13 @@ The four cases cover the distinct render paths:
358364
`sample_rate` / `block_size`.
359365
3. Apply `state.file` then `state.parameters`.
360366
4. Load `input.audio` and/or `input.midi`; otherwise use silence.
361-
5. `prepareToPlay`, render `render_duration` worth of blocks, accumulating the
367+
5. If a `playhead` is configured, point a fixed-tempo transport at the plugin
368+
(its position advances each block).
369+
6. `prepareToPlay`, render `render_duration` worth of blocks, accumulating the
362370
output into a single buffer.
363-
6. **No reference exists** -> write the float WAV + manifest (record mode);
371+
7. **No reference exists** -> write the float WAV + manifest (record mode);
364372
report "reference created".
365-
7. **Reference exists** -> run each configured comparator; report each verdict
373+
8. **Reference exists** -> run each configured comparator; report each verdict
366374
and the overall pass/fail; on failure write a diff WAV; exit `0` / `1`.
367375

368376
## 10. Phasing
@@ -377,14 +385,14 @@ The four cases cover the distinct render paths:
377385
- Record-or-compare with float-WAV + JSON-sidecar references.
378386
- `sample` comparator only (default tolerance = one 16-bit LSB).
379387
- Text + JSON result reporting; diff WAV on failure.
380-
- **Dogfood tone-generator plugin** (§8) plus a CTest self-test that runs the
381-
full record/compare path against checked-in references.
388+
- Fixed `playhead` (tempo / time signature) for time-dependent plugins.
389+
- **Dogfood test plugins** (§8) plus CTest self-tests that run the full
390+
record/compare path against checked-in references.
382391

383392
**Phase 2 and beyond**
384393

385394
- Multiplexed execution of config arrays.
386395
- Parameter `automation` timelines.
387-
- Fixed `playhead` (tempo / time signature) for time-dependent plugins.
388396
- Additional comparators: `peakrms`, `spectrum`, `crosscorr`, `fingerprint`.
389397
- Synthesised inputs (`input.generator`: noise / sine, with seed).
390398
- Optional child-process isolation mirroring the validate handoff.

0 commit comments

Comments
 (0)