Does Audapter do what experimenters think it does? An audit of the blab-lab version

By
Claude (Anthropic), at the request of Josh Mandel
Code examined
blab-lab/audapter_mex @169cadf and blab-lab/audapter_matlab @e1695f1
Date
2026-09-27

What we looked at

Audapter shifts a speaker's formants or pitch, or plays masking noise, in real time for altered-auditory-feedback experiments. We examined the blab-lab version, its C++ core and MATLAB code, and asked: does it do what experimenters think it does, trial after trial?

How

What we found

The core works: formant shifts deliver what they log, and pitch shifts land within a few cents of what was asked.

No blab experiment we inspected is exposed to trial-to-trial carry-over. We checked the OST files and the way conditions are switched between trials in 10 public blab experiments and in blab's unpublished experiment code (including timeWrap, pitchAdaptPD and pitchComp). None uses the settings that let one trial's state leak into the next, and in simulated mixed sessions with the lab's settings, catch trials received no perturbation whatever came before them (details).

What does touch current experiments:

Pitfalls for future designs. Carry-over is real with other settings: an offset rule placed after a timer or after the RMS-floor rule fires later on every trial (OST-F1); a PCF left loaded overrides a later field experiment (COORD-1); pitch catch trials made by clearing the PCF can be shifted (OST-F5). Each card gives the safe pattern. Other pitfalls include wrongly logged pitch for low voices with time-domain shifting, crashes or hangs when settings change mid-session, and clicks when resetting during speech (all).

Robustness. A few memory errors need a malformed file or array to show up (all).

None of these produce a warning. Most cards include a fix you can try on this page.

Reading a card

Each card says what happens, when it matters, and gives an example with the exact settings it used (from a named blab experiment where one exists), a figure, recordings, the size of the effect, the cause, the safe pattern or fix, and the evidence. Figures show two aligned panels: Expected, what the design should do, and Observed, what Audapter did. Grey boxes are the spoken words, outlined blocks are the intended perturbation, solid blue blocks are the perturbation Audapter applied, and orange marks the difference. Numbers under the words are measured on the audio you can play, not taken from Audapter's logs.

Findings at a glance

Grouped as in the report. Severity is our judgement of realistic reach (how likely a real design is to trigger it) times impact on an experiment.

IDSeverityFindingblab studiesVerifiedSince
What affects blab's current experiments
I-01HighSince b2.4 (April 2026), in runners that load the noise once per session, masking noise drops out for 16 ms every 10 s of trial timeaffectedH≤ 2.1.5
PT-5HightimeWrap and cerebTimeAdapt: from the first phase after pre, the voice is played 2.8–3.5 dBA quieter and 13 ms later (vocoder in warp mode); timeAdapt plays at -0.3 dBAaffectedH F≤ 2.1.5
FMT-LEVELMediumWith gain adaptation off (the MATLAB default, all blab studies), formant shifts change the heard level: lowering F1 by 125 mel raises it by a median 2.9 dBA, a level cue on perturbed trialsaffectedH≤ 2.1.5
F6MediumUndocumented blab change that vsaSentence depends on: the field perturbation re-arms on every vowel; minVowelLen has no effectaffectedHblab fork
FMT-F3MediumThe 2D perturbation field uses the lower-left grid cell, not interpolationaffectedRblab fork
CORPUS-7LowWith pitch shifting on, low frequencies are boosted more than the rest: +7 dB at 30 HzaffectedH≤ 2.1.5
Questions for the lab, from the public experiment code
LAB-2MediumOpen question: as committed, the public vsaGeneralize runner (called by its experiment script) perturbs baseline trials and never scales the field; vsaGeneralize2 is wired correctlyaffectedHblab fork
LAB-1LowRepository note: vsaCentralize ships an unused public runner that looks up the field on the wrong grid; the runner the experiment calls is correctsafe as run; unused public copy mis-wiredHblab fork
LAB-3LowDead line: vsaGeneralize sets p.fb4Gain, which AudapterIO never forwards; the noise plays at the default level, as in every blab fb 4 study (already noted in the lab's code)safe as runHblab fork
Trial-to-trial carry-over in mixed designs
OST-F1HighWith FALL after ELAPSED_TIME, the RMS-floor rule or a ratio rule, the detected offset creeps later every trial and jumps after long ones; perturbations run into the next wordsafe as runH F≤ 2.1.5
COORD-1MediumA PCF from an earlier block survives AudapterIO('init') and overrides a later field experiment; blab's runners clear itsafe as runH≤ 2.1.5
OST-F5MediumPitch catch trials made by clearing the PCF get the full shift when the previous trial ended while shiftedsafe as runH R≤ 2.1.5
OST-F2MediummaxIOI timeout: the next ELAPSED_TIME lasts one frame, and without an OST reload the timeout drifts 0.2 s later per trialn.a.H F≤ 2.1.5
Pitfalls for future designs
OST-NUMMediumA one-state OST rule numbered as if it used two runs twice: delays and durations double, uncheckedn.a.H≤ 2.1.5
FMT-PATCHMediumA 2-D patch sent without F1Min..F2Max shifts formants outside it by the edge cell's amountn.a.Hblab fork
INIT-PERSISTMediumAudapterIO('init') leaves settings from an earlier block in place when p lacks the field (e.g. bPitchShift)safe as runH≤ 2.1.5
WARP-48MediumAt frameLen 48 (24 kHz) a time warp delivers about 73 % of the programmed lagn.a.H≤ 2.1.5
CORPUS-8MediumTime-domain pitch tracking at the default frame settings logs pitch wrongly below about 140 Hz (typical adult men)safe as runH≤ 2.1.5
CORPUS-10MediumChanging frameLen/nDelay at a fixed sample rate within a session can hang runFrame (and the audio callback) at 100 % CPUsafe as runH≤ 2.1.5
LIVE-1 / LIVE-2MediumLoading OST/PCF files or changing DSP parameters while audio runs (always-on designs) crashes MATLAB or reads freed memorysafe as runD≤ 2.1.5
LIVE-3Mediumreset() while the participant is speaking can send a clipped click: +17 to +61 dB, about 1 in 1000–2000 resets during voicingsafe as runD≤ 2.1.5
LIVE-6MediumIf the sound card's buffer size differs from Audapter's frame size, the participant hears the raw microphone an octave up, with gapsn.a.D≤ 2.1.5
CORPUS-11MediumShifted formant targets are unbounded: old mel-unit parameters under today's ratio default give targets up to 130 kHz and clipped outputn.a.H≤ 2.1.5
TDS-ONSETLowThe time-domain pitch schedule starts at the first above-threshold frame, 56-111 ms after the acoustic onsetsafe as runH≤ 2.1.5
PVOC-NOISELowPitch-shifting fricatives with the phase vocoder lowers their level by several dBAn.a.H≤ 2.1.5
I-04LowrunFrame writes its output into the caller's input array, so reprocessing the same frames feeds processed audio back insafe as runH≤ 2.1.5
I-02Lowfb mode 5 applies dScale twice to the speech-modulated componentaffectedHblab fork
I-03LowThe recorder wraps after 30 s without warning; a 35 s trial returns only its last 5 sn.a.H≤ 2.1.5
OST-F8LowAND_RATIO OST rules take the hold from field 5, where the manual puts {}n.a.Hblab fork
PT-2 / I-15MediumThe RMS-clip loudness protection mutes a stale 2 ms slot instead of the loud framen.a.H≤ 2.1.5
PT-3MediumThe time-domain pitch-shift schedule runs on voiced time only, so shift onsets come later after every pausen.a.H≤ 2.1.5
PT-4MediumThe logged pitchShiftRatio is wrong whenever the time-domain shifter is usedn.a.H≤ 2.1.5
PT-6MediumA PCF with both a pitch shift and a time warp silently drops the warpn.a.R≤ 2.1.5
FMT-F4MediumClamped formants are logged during silence, where no filtering happenedn.a.Rblab fork
I-09MediumChanging ndelay, frameLen or nWin does not rebuild the formant trackern.a.R≤ 2.1.5
CORPUS-3MediumThere is no preset for young children: the female preset underestimates their F2 by about 12 %n.a.H≤ 2.1.5
LIVE-4MediumAudapter('start') reports success when the sound card failed to openn.a.D≤ 2.1.5
LIVE-5MediumWith no ASIO driver installed, the first Audapter call of any kind crashes, even for offline processingn.a.D≤ 2.1.5
PB-DSCALELowplayWave plays a stored recording without dScale, so a participant hears it about 2 dBA louder than their own live feedback (a pure gain)n.a.H R≤ 2.1.5
REINIT-PARAMSLowRe-initialising Audapter from saved data.params fails, or, once the names are fixed up, silently drops the time-domain pitch settingsn.a.H≤ 2.1.5
COORD-10LowA zero-length time-warp row anchored on a state that begins in the first ~0.1 s can abort the trial ("predicting the future is impossible")n.a.H≤ 2.1.5
CORPUS-4LowThe male preset can report F3 as F2 for a whole voweln.a.H≤ 2.1.5
LIVE-7LowMissed audio callbacks are not detected: the recording is spliced silently and the logged time axis falls behindn.a.D≤ 2.1.5
LIVE-8LowLive onset and offset ramps are a 500 Hz sawtooth and end on a negative gainn.a.D≤ 2.1.5
Robustness
OST-F4MediumA PCF with fewer rows than OST states reads past its arrays; heap contents decide whether to perturbsafe as runH≤ 2.1.5
FMT-F1MediumClamp trajectory setters copy 2048 values whatever length MATLAB passessafe as runHblab fork
I-06 / I-07MediumAn oversized tone count is accepted and overwrites neighbouring parametersn.a.H≤ 2.1.5
OST-F6MediumReloading an OST file that has no maxIOI section crashes MATLABn.a.D≤ 2.1.5
H2 / PT-1LowThe first output frame of each trial, and one frame every 108 s, is read from outside the delay buffern.a.H F≤ 2.1.5
COORD-5LowA rejected time-warp row terminates the process on Linux, macOS and WebAssembly builds (portability)n.a.H R≤ 2.1.5

What affects blab's current experiments

These findings touch experiments the lab runs now, with the lab's own settings: a masking-noise defect introduced with b2.4 in April 2026, a loudness and latency step between phases in two time-warp studies, and a blab change that one of the lab's designs depends on.

Since b2.4 (April 2026), masking noise drops out for 16 ms about once every 10 s, in about one of every five or six trials of a blab masking phase

Affects blab's current experiments on b2.4 and later.
Relevant blab studies affected. Affected, on data collected with b2.4 or later (since April 2026) only: runners that load the noise once per session, so its position runs on across trials. Public: , , , , , , , (its committed experiment script calls a runner that is not in the repository), , , (each link is the line that loads the noise once). Unpublished: timeAdapt, timeWrap, varMod, sponAdapt, vsaRetention, diphthongAdapt, brut, vin, and the simon and cerebellar studies. Safe as run: runners that reload the noise before every trial, which rewinds it (the MRI and iEEG interleaved runners, noGoAdapt, varModAdapt, compRetest). How audible: in fb 3 (most runners; babble at gain 0.02 under the participant's own voice) a 16 ms dropout is subtle; fb 2 studies (brut, vin, and the masking phases of simonSingleWord v2, uhdapter and vsaSentence) and fb 4 studies (vsaGeneralize, biteblockAdapt, diphthongAdapt, sponAdapt, sponAdapt2, vsaRetention) are more exposed, because the noise is all or most of what is heard.

What happens

In feedback modes 2–5 Audapter plays the loaded noise (datapb) in a loop. It loops at the end of its 480,000-sample (10 s) buffer, not at the end of the noise that was loaded, and the unused end of the buffer is silence. The bundled babble is 479,230 samples, 770 short of the buffer, so once per 10 s of running time the participant hears 16 ms of silence instead of noise. The noise position carries on from trial to trial (reset does not rewind it), so the dropout lands at a different point in each affected trial. A noise file much shorter than 10 s gives a correspondingly longer silence.

When it matters

Matters whenever masking noise is meant to be continuous, most of all in fb 2 masking phases (transfer or noise-baseline phases), where the noise is all the participant hears and a dropout briefly unmasks their own voice. In fb 3 phases the babble is quiet (fb3Gain 0.02) and a 16 ms dropout is less noticeable. Safe if the loaded noise is exactly 480,000 samples, or on versions before b2.4.

Example

The masking phase of blab's simonSingleWord v2 experiment (fb 2, fb2Gain 0.16, 1.8 s trials, AudapterIO('init') every trial, the bundled babble loaded once with get_noiseSource). Over 24 trials the current build plays 4 silences of 16 ms, in trials 6, 12, 17, 23, at 0.98 s, 0.18 s, 1.18 s, 0.38 s into the trial. With the pre-b2.4 buffer the same session plays none.

Settings used

Source
blab's simonSingleWord v2 experiment, masking (transfer) phase: fb 2 at fb2Gain 0.16, 1.8 s trials, init every trial, the bundled babble via free-speech get_noiseSource.
Build
Observed: blab-lab audapter_mex as shipped (@169cadf, buffer 480,000). Expected: the same session with the pre-b2.4 buffer (230,400), which get_noiseSource then truncates the babble to.
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

none

Trials 5–7 of the session, end to end (the gap in trial 6 is widened in the drawing so it is visible). Silence is detected on the audio of each trial.

Expected blab before b2.4 (buffer 230,400 samples): the babble loops seamlessly

Expected: blab before b2.4 (buffer 230,400 samples): the babble loops seamlesslyExpected (blab before b2.4 (buffer 230,400 samples): the babble loops seamlessly). trial 5 (fb 2): Masking babble babble 0.00-1.80 s. trial 6 (fb 2): Masking babble babble 0.00-1.80 s. trial 7 (fb 2): Masking babble babble 0.00-1.80 s.Trial5 · fb 26 · fb 27 · fb 2Masking babbletrial 5: babble from 0.00 to 1.80 sbabbletrial 6: babble from 0.00 to 1.80 sbabbletrial 7: babble from 0.00 to 1.80 sbabbleSilence in the outputno silence91011121314time in the session (s), trials drawn end to end; dashed rules = reset()

Observed blab since b2.4 (buffer 480,000 samples): what Audapter plays now

Observed: blab since b2.4 (buffer 480,000 samples): what Audapter plays nowObserved (blab since b2.4 (buffer 480,000 samples): what Audapter plays now). trial 5 (fb 2): Masking babble babble 0.00-1.80 s. trial 6 (fb 2): Masking babble babble 0.00-0.98 s; babble 1.00-1.80 s. trial 7 (fb 2): Masking babble babble 0.00-1.80 s.Trial5 · fb 26 · fb 27 · fb 2Masking babbletrial 5: babble from 0.00 to 1.80 sbabbletrial 6: babble from 0.00 to 0.98 sbabbletrial 6: babble from 1.00 to 1.80 sbabbletrial 7: babble from 0.00 to 1.80 sbabbleSilence in the output16 ms silent16 ms of silence in trial 6: the participant'svoice is unmasked91011121314time in the session (s), trials drawn end to end; dashed rules = reset()

Expected blab before b2.4 (buffer 230,400 samples): the babble loops seamlessly

Expected: blab before b2.4 (buffer 230,400 samples): the babble loops seamlesslyExpected (blab before b2.4 (buffer 230,400 samples): the babble loops seamlessly). trial 5 (fb 2): Masking babble babble 0.00-1.80 s. trial 6 (fb 2): Masking babble babble 0.00-1.80 s. trial 7 (fb 2): Masking babble babble 0.00-1.80 s.Trial 5 · fb 2babble from 0.00 to 1.80 sbabbleTrial 6 · fb 2 (after reset())babble from 0.00 to 1.80 sbabbleno silenceTrial 7 · fb 2 (after reset())babble from 0.00 to 1.80 sbabble00.511.5time in each trial (s)

Observed blab since b2.4 (buffer 480,000 samples): what Audapter plays now

Observed: blab since b2.4 (buffer 480,000 samples): what Audapter plays nowObserved (blab since b2.4 (buffer 480,000 samples): what Audapter plays now). trial 5 (fb 2): Masking babble babble 0.00-1.80 s. trial 6 (fb 2): Masking babble babble 0.00-0.98 s; babble 1.00-1.80 s. trial 7 (fb 2): Masking babble babble 0.00-1.80 s.Trial 5 · fb 2babble from 0.00 to 1.80 sbabbleTrial 6 · fb 2 (after reset())babble from 0.00 to 0.98 sbabblebabble from 1.00 to 1.80 sbabble16 ms silentTrial 7 · fb 2 (after reset())babble from 0.00 to 1.80 sbabble16 ms of silence in trial 6: the participant'svoice is unmasked00.511.5time in each trial (s)

Listen

  • Expected, trial 6. 0.6–1.4 s, pre-b2.4 buffer: no silence 0.8 s

  • Observed, trial 6. 0.6–1.4 s, current build: 16 ms of silence at 0.98 s 0.8 s

    A 16 ms dropout at 0.38 s into the clip; listen closely.

  • A/B, trial 6. expected, then observed 2.0 s

    No playhead: two clips back to back.

These clips share one playback gain and are the masking noise alone, as heard in fb 2 (the participant also hears their own voice through bone conduction). The dropout is short: listen on headphones. There is no speech in these clips: in fb 2 the participant hears only the masking noise, so a real voice would not change what is heard.

Run it in your browser

The general case, beyond the bundled babble: a noise file shorter than 10 s. This runs a 12 s trial through the real Audapter C++ core compiled to WebAssembly from the pinned commit, once as released and once with the noise looping at the length that was loaded. Both builds keep the shipped 480000-sample playback buffer. Pick the length of the noise file, cut from the bundled babble and loaded as runExperiment.m does it (zero mean, unit RMS). The page ships only every third sample of the babble, the only samples the fb 2–5 loop reads, and rebuilds the 48 kHz buffer from them, so Audapter reads the same values it would read from the full-rate file. The bars come from a noise-only run (fb 2), and Play gives the voice + noise run (fb 3). Both use the 48 kHz output Audapter returns for every frame, which lags signalOut by a few milliseconds. The build used here records only 10 s, so its signalOut would not cover a 12 s trial.

The change in the “fix-i-01” build: report/patches/i-01-datapb-loop-length.patch
Audapter.cpppbLen               = maxPBSize;    /* audit fix I-01 */pbLen = (len > 0) ? len : maxPBSize;    /* audit fix I-01: loop at the loaded length */if (pbCounter >= maxPBSize)pbCounter -= maxPBSize;if (pbCounter >= pbLen) /* audit fix I-01: was maxPBSize */pbCounter -= pbLen;Audapter.hint pbLen;  /* audit fix I-01: length of the loaded datapb (loop point) */

Each run loads the Audapter core in this page, about 150–300 MB of memory per build. On a phone, run one panel at a time. Try your own recordings and settings in the Playground.

How big, how often

simonSingleWord v2Silent gaps in 24 masking trials (current build)4, each 16 ms, in trials 6, 12, 17, 23
simonSingleWord v2Same session, pre-b2.4 buffer0 gaps
real sessions (labrun)modelExpt and vsaGeneralize full sessions run with their own scripts15.4 ms holes every 10.0 s of noise running time (9–12 and 22–34 holes per session with eight AI-generated voices)
simonSingleWord v2How oftenevery 10.0 s of running time: about 1 in 5.6 trials of 1.8 s
general caseA 5 s noise file in a 12 s trialno noise 5.00–10.00 s

Data: data.json from the export script.

Cause

The fb 2–5 loop advances pbCounter and wraps it at the compile-time maxPBSize. Setting datapb zero-fills the rest of the buffer, and only setting datapb resets pbCounter.

pbCounter += p.downFact;if (pbCounter >= maxPBSize)    pbCounter -= maxPBSize;

The wrap uses the buffer capacity.

if (ns == string("datapb")) { /* Zero out the remaining part */    for (int i = len; i < maxPBSize; i++)        *((dtype*)ptr + i) = 0.0;}

Loading a shorter datapb fills the rest of the buffer with zeros.

static const int maxPBSize = 480000;            /* Maximum length (samples) for waveform playback */

The loop length, whatever length was loaded (it was 230400 before the blab-lab fork).

maxPBSize = Audapter('getMaxPBLen');if length(mbw) > maxPBSize    mbw = mbw(1 : maxPBSize);end Audapter(3, 'datapb', mbw, 0);

Callers truncate to the maximum but never pass the actual length to the playback loop.

Only since blab b2.4 (commit 3c90a3a, 6 April 2026), which enlarged the playback buffer from 230,400 to 480,000 samples. Data collected with earlier versions are not affected: the bundled babble was truncated to the old buffer and looped seamlessly.

Workaround and fix

Workarounds for current sessions (any one): (1) reload the noise with Audapter('setParam', 'datapb', w, 1) before each trial, which rewinds it, as blab's per-trial runners do (a trial then never reaches the 10 s loop point); (2) pad or trim the noise to exactly 480,000 samples before loading it, for example by appending the bundled babble's own first 770 samples; (3) apply the loop-at-length patch below. This applies only to data collected with b2.4 or later (since April 2026). Fix: store the loaded length when datapb is set and wrap there (report/patches/i-01-datapb-loop-length.patch), and rewind pbCounter in reset(). Check past data: if signalOut was saved for fb 2–5 trials, look for runs of exact zeros; otherwise the gap's position can be reconstructed from the trial durations, since the noise position runs on across trials.

How we know

  • H interface check: scratch/interface/t_iface.m T1 fb 2: mean |out| 0.100 for 0–4.9 s, 0.000 for 5.1–9.9 s
  • H report export (simonSingleWord v2 masking phase, 24 trials, both buffer sizes): report_i01_session.m
  • H public experiments run with their own scripts (labrun): labrun/README.md LABRUN-3, VOICES-2: 15.4 ms holes every 10.0 s of noise running time in the modelExpt (9–12) and vsaGeneralize (22–34) full sessions with eight AI-generated voices (holes under the voice are not counted)
  • H real session structure: exp_noise_session.m EXP-3: 4 gaps of 16 ms in 24 trials on the current build, none with the pre-b2.4 buffer
  • H report export (generic case, a 5 s noise file): report_i01.m

Reproduce cd audit/harness && ./run-oct.sh report_i01_session.m

In blab's timeWrap and cerebTimeAdapt, participants hear their own voice 2.8–3.5 dBA quieter and 13 ms later from the first phase after pre onward, because the later phases switch the phase vocoder into its time-warp mode

Affects designs whose phases differ in how the phase vocoder runs: off in a baseline and in warp mode later (timeWrap, cerebTimeAdapt), on without a PCF, or with the warp section dropped on some trials.
Relevant blab studies affected. Affected: timeWrap and cerebTimeAdapt (24 kHz, frameLen 32, nDelay 3; pre phase with bPitchShift 0 and no PCF, later phases with bPitchShift 1 and a warp-row PCF; both experiment scripts call the Audapter runner for both phases). Measured at those settings the voice is played 0.0 dBA in the pre phase and -2.8 dBA afterwards on this sentence (-3.5 to -2.8 dBA across 4 voices), and 13.3 ms later. The timeAdapt family (frameLen 48) plays at -0.3 dBA with its warp rows. No blab study we know of uses the vocoder without a PCF (part C).

What happens

With bPitchShift = 1 the phase vocoder processes the voice even at 0 semitones. Without a time-warp section in the PCF, it plays the voice louder than it came in: +3.5 dBA at the default 16 kHz / frameLen 32, and +6.0 dBA at the 24 kHz / frameLen 48 settings blab uses for time warping. With a warp section loaded, even a zero-length one, the vocoder runs in its warp mode for the whole trial, at a different level: -0.3 dBA at 24 kHz / frameLen 48, -2.6 dBA at 16 kHz / frameLen 32. When a pitch shift starts, the level changes again, by an amount that depends on F0 and vowel.

When it matters

Matters when trials or phases differ in whether a warp section is loaded (a loudness cue that tells the participant which trials are perturbed), when the vocoder is on in some phases and off in others (for example a calibration at bPitchShift = 0), and for pitch-shift onsets. Safe if every trial carries a warp section (a zero-length row on control trials), as in blab's timeWrap experiment.

Example

A. timeWrap and cerebTimeAdapt. Their settings on a real sentence: in the pre phase (bPitchShift 0, no PCF) the voice is played at 0.0 dBA; in the later phases (bPitchShift 1, a PCF with a warp row, here zero-length as on a control trial) at -2.8 dBA, with a real warp at -2.8 dBA. On 4 voices the step is -3.5 to -2.8 dBA. The output also comes later: clicks sent through the same path arrive 2.7 ms after the input in the pre phase and 16.0 ms after it in the later phases, a 13.3 ms step at the phase boundary. B. Time-warp designs at timeAdapt settings (24 kHz, frameLen 48): if control trials keep a zero-length warp row, both kinds play at the spoken level (control -0.3 dBA, warp -0.2 dBA); if control trials drop the warp section, they play at +6.0 dBA, 6.2 dBA louder than the warp trials around them. C. A plausible design, not one blab runs: the vocoder on without a PCF after a baseline with it off (24 kHz, frameLen 32, nDelay 3) plays 0.0 dBA then +3.5 dBA. Clearing a warp PCF with Audapter('pcf', '', 0) leaves the vocoder in its warp mode for the rest of the MATLAB session: at the part-C settings a no-PCF trial run after warp trials plays at -2.8 dBA instead of +3.5 dBA, so the level of a later phase depends on what ran earlier in the session.

Settings used

Source
blab's timeWrap and cerebTimeAdapt experiments (settings only): 24 kHz (sRate 48000, downFact 2), frameLen 32 (the default), nDelay 3; pre phase bPitchShift 0 and no PCF, later phases bPitchShift 1 and a PCF with a warp row on every trial (the warp timings here are illustrative). fb 1 so the speech level can be measured without the babble. Parts B and C use the timeAdapt and hypothetical settings in the export's data.json (warp, cereb).
Input
real speech: CMU ARCTIC arctic_clb_a0018 "There was a change now." (female), first 1.75 s; clicks (Hann, 1 ms) for latency
Trials
pre phase: bPitchShift 0, no PCF, later phases: bPitchShift 1 with a warp-row PCF
Between trials
pre phase: bPitchShift 0 and no PCF; later phases: bPitchShift 1 and a PCF with a warp row on every trial (zero-length on control trials)
Key parameters
downFact 2, sr 24000, nDelay 3, bPitchShift 1
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.downFact2
p.sr24000
p.nDelay3
p.bPitchShift1
OST file
# one statermsSlopeWin = 0.030000window for the level-slope estimate n = 11 rules follow0 OST_END NaN NaN {}state 0: final state n = 0no maxIOI timeouts
PCF file
11 time-warp section0.40, 0.5, 0.0, 0.0, 2.0time warp from 0.40 s: slow to ×0.5 for 0.0 s, hold 0.0 s, then ×2.0 (zero length: no audible warp) 11 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0.0, 0, 0, 0state 0: no change

A: blab's timeWrap / cerebTimeAdapt phases. B: four trials of a time-warp design at timeAdapt settings. C: a hypothetical design. Expected: the level the participant should hear; Observed: what Audapter plays. Values are the heard level in dBA (A-weighted), measured on the audio, relative to the input.

A. blab's timeWrap and cerebTimeAdapt: pre phase with the vocoder off, later phases in warp mode (24 kHz, frameLen 32, nDelay 3)

Expected the participant hears their voice at the same level in every phase

Expected: the participant hears their voice at the same level in every phaseExpected (the participant hears their voice at the same level in every phase). trial 1 (pre phase): Phase vocoder same level in every phase; Heard level, dBA 0.0 dBA. trial 2 (later phases): Phase vocoder same level in every phase; Heard level, dBA 0.0 dBA.Trial1 · pre phase2 · later phasesSpeechtrial 1: “There was a change now.” 0.12-1.65 s“There was a change now.”trial 2: “There was a change now.” 0.12-1.65 s“There was a change now.”Phase vocodersame level in every phasesame level in every phaseHeard level, dBA0.0 dBA0.0 dBAphase change0123time in the session (s), trials drawn end to end; dashed rules = reset()

Observed timeWrap / cerebTimeAdapt settings: bPitchShift 0 in the pre phase; bPitchShift 1 and a warp-row PCF afterwards

Observed: timeWrap / cerebTimeAdapt settings: bPitchShift 0 in the pre phase; bPitchShift 1 and a warp-row PCF afterwardsObserved (timeWrap / cerebTimeAdapt settings: bPitchShift 0 in the pre phase; bPitchShift 1 and a warp-row PCF afterwards). trial 1 (pre phase): Phase vocoder vocoder off (bPitchShift 0); Heard level, dBA 0.0 dBA. trial 2 (later phases): Phase vocoder vocoder in warp mode 0.02-1.73 s; Heard level, dBA -2.8 dBA.Trial1 · pre phase2 · later phasesSpeechtrial 1: “There was a change now.” 0.12-1.65 s“There was a change now.”trial 2: “There was a change now.” 0.12-1.65 s“There was a change now.”Phase vocodervocoder off (bPitchShift 0)trial 2: vocoder in warp mode from 0.02 to 1.73 svocoder in warp modeHeard level, dBA0.0 dBA-2.8 dBAphase changeFrom the first phase after pre onward the voice is played2.8 dBA quieter, and 13 ms later0123time in the session (s), trials drawn end to end; dashed rules = reset()

B. Time-warp designs at timeAdapt settings (24 kHz, frameLen 48): control trials with and without a warp section

Expected control trials keep a zero-length warp row, as blab's timeWrap experiment does

Expected: control trials keep a zero-length warp row, as blab's timeWrap experiment doesExpected (control trials keep a zero-length warp row, as blab's timeWrap experiment does). trial 1 (control): Time warp zero-length warp row; Heard level, dBA -0.3 dBA. trial 2 (warp): Time warp warp 0.40-0.70 s; Heard level, dBA -0.2 dBA. trial 3 (control): Time warp zero-length warp row; Heard level, dBA -0.3 dBA. trial 4 (warp): Time warp warp 0.40-0.70 s; Heard level, dBA -0.2 dBA.Trial1 · control2 · warp3 · control4 · warpSpeechtrial 1: “There was a change now.” 0.12-1.65 strial 2: “There was a change now.” 0.12-1.65 strial 3: “There was a change now.” 0.12-1.65 strial 4: “There was a change now.” 0.12-1.65 sTime warpzero-length warp rowtrial 2: warp from 0.40 to 0.70 szero-length warp rowtrial 4: warp from 0.40 to 0.70 sHeard level, dBA-0.3 dBA-0.2 dBA-0.3 dBA-0.2 dBA01234567time in the session (s), trials drawn end to end; dashed rules = reset()

Observed control trials drop the warp section

Observed: control trials drop the warp sectionObserved (control trials drop the warp section). trial 1 (control): Time warp no warp section; Heard level, dBA +6.0 dBA. trial 2 (warp): Time warp warp 0.40-0.70 s; Heard level, dBA -0.2 dBA. trial 3 (control): Time warp no warp section; Heard level, dBA +6.0 dBA. trial 4 (warp): Time warp warp 0.40-0.70 s; Heard level, dBA -0.2 dBA.Trial1 · control2 · warp3 · control4 · warpSpeechtrial 1: “There was a change now.” 0.12-1.65 strial 2: “There was a change now.” 0.12-1.65 strial 3: “There was a change now.” 0.12-1.65 strial 4: “There was a change now.” 0.12-1.65 sTime warpno warp sectiontrial 2: warp from 0.40 to 0.70 sno warp sectiontrial 4: warp from 0.40 to 0.70 sHeard level, dBA+6.0 dBA-0.2 dBA+6.0 dBA-0.2 dBAControl trials play 6 dBA louder than warp trials: a loudness cue to the condition01234567time in the session (s), trials drawn end to end; dashed rules = reset()

C. A plausible design: vocoder switched on, without a PCF, after a baseline phase (24 kHz, frameLen 32, nDelay 3)

Expected the participant hears their voice at the same level in every phase

Expected: the participant hears their voice at the same level in every phaseExpected (the participant hears their voice at the same level in every phase). trial 1 (baseline): Phase vocoder same level in every phase; Heard level, dBA 0.0 dBA. trial 2 (later phases): Phase vocoder same level in every phase; Heard level, dBA 0.0 dBA.Trial1 · baseline2 · later phasesSpeechtrial 1: “There was a change now.” 0.12-1.65 s“There was a change now.”trial 2: “There was a change now.” 0.12-1.65 s“There was a change now.”Phase vocodersame level in every phasesame level in every phaseHeard level, dBA0.0 dBA0.0 dBAphase change0123time in the session (s), trials drawn end to end; dashed rules = reset()

Observed bPitchShift 0 in the baseline, 1 afterwards, no PCF

Observed: bPitchShift 0 in the baseline, 1 afterwards, no PCFObserved (bPitchShift 0 in the baseline, 1 afterwards, no PCF). trial 1 (baseline): Phase vocoder vocoder off (bPitchShift 0); Heard level, dBA 0.0 dBA. trial 2 (later phases): Phase vocoder vocoder on, 0 st 0.02-1.73 s; Heard level, dBA +3.5 dBA.Trial1 · baseline2 · later phasesSpeechtrial 1: “There was a change now.” 0.12-1.65 s“There was a change now.”trial 2: “There was a change now.” 0.12-1.65 s“There was a change now.”Phase vocodervocoder off (bPitchShift 0)trial 2: vocoder on, 0 st from 0.02 to 1.73 svocoder on, 0 stHeard level, dBA0.0 dBA+3.5 dBAphase changeFrom the first vocoder phase on, the voice is played 3.5dBA louder0123time in the session (s), trials drawn end to end; dashed rules = reset()

A. blab's timeWrap and cerebTimeAdapt: pre phase with the vocoder off, later phases in warp mode (24 kHz, frameLen 32, nDelay 3)

Expected the participant hears their voice at the same level in every phase

Expected: the participant hears their voice at the same level in every phaseExpected (the participant hears their voice at the same level in every phase). trial 1 (pre phase): Phase vocoder same level in every phase; Heard level, dBA 0.0 dBA. trial 2 (later phases): Phase vocoder same level in every phase; Heard level, dBA 0.0 dBA.Trial 1 · pre phase“There was a change now.” 0.12-1.65 s“There was a change now.”same level in every phase0.0 dBATrial 2 · later phases (after phase change)“There was a change now.” 0.12-1.65 s“There was a change now.”same level in every phase0.0 dBA00.511.5time in each trial (s)

Observed timeWrap / cerebTimeAdapt settings: bPitchShift 0 in the pre phase; bPitchShift 1 and a warp-row PCF afterwards

Observed: timeWrap / cerebTimeAdapt settings: bPitchShift 0 in the pre phase; bPitchShift 1 and a warp-row PCF afterwardsObserved (timeWrap / cerebTimeAdapt settings: bPitchShift 0 in the pre phase; bPitchShift 1 and a warp-row PCF afterwards). trial 1 (pre phase): Phase vocoder vocoder off (bPitchShift 0); Heard level, dBA 0.0 dBA. trial 2 (later phases): Phase vocoder vocoder in warp mode 0.02-1.73 s; Heard level, dBA -2.8 dBA.Trial 1 · pre phase“There was a change now.” 0.12-1.65 s“There was a change now.”vocoder off (bPitchShift 0)0.0 dBATrial 2 · later phases (after phase change)“There was a change now.” 0.12-1.65 s“There was a change now.”vocoder in warp mode from 0.02 to 1.73 svocoder in warp mode-2.8 dBAFrom the first phase after pre onward the voice isplayed 2.8 dBA quieter, and 13 ms later00.511.5time in each trial (s)

B. Time-warp designs at timeAdapt settings (24 kHz, frameLen 48): control trials with and without a warp section

Expected control trials keep a zero-length warp row, as blab's timeWrap experiment does

Expected: control trials keep a zero-length warp row, as blab's timeWrap experiment doesExpected (control trials keep a zero-length warp row, as blab's timeWrap experiment does). trial 1 (control): Time warp zero-length warp row; Heard level, dBA -0.3 dBA. trial 2 (warp): Time warp warp 0.40-0.70 s; Heard level, dBA -0.2 dBA. trial 3 (control): Time warp zero-length warp row; Heard level, dBA -0.3 dBA. trial 4 (warp): Time warp warp 0.40-0.70 s; Heard level, dBA -0.2 dBA.Trial 1 · control“There was a change now.” 0.12-1.65 s“There was a change now.”zero-length warp row-0.3 dBATrial 2 · warp (after reset())“There was a change now.” 0.12-1.65 s“There was a change now.”warp from 0.40 to 0.70 swarp-0.2 dBATrial 3 · control (after reset())“There was a change now.” 0.12-1.65 s“There was a change now.”zero-length warp row-0.3 dBATrial 4 · warp (after reset())“There was a change now.” 0.12-1.65 s“There was a change now.”warp from 0.40 to 0.70 swarp-0.2 dBA00.511.5time in each trial (s)

Observed control trials drop the warp section

Observed: control trials drop the warp sectionObserved (control trials drop the warp section). trial 1 (control): Time warp no warp section; Heard level, dBA +6.0 dBA. trial 2 (warp): Time warp warp 0.40-0.70 s; Heard level, dBA -0.2 dBA. trial 3 (control): Time warp no warp section; Heard level, dBA +6.0 dBA. trial 4 (warp): Time warp warp 0.40-0.70 s; Heard level, dBA -0.2 dBA.Trial 1 · control“There was a change now.” 0.12-1.65 s“There was a change now.”no warp section+6.0 dBATrial 2 · warp (after reset())“There was a change now.” 0.12-1.65 s“There was a change now.”warp from 0.40 to 0.70 swarp-0.2 dBATrial 3 · control (after reset())“There was a change now.” 0.12-1.65 s“There was a change now.”no warp section+6.0 dBATrial 4 · warp (after reset())“There was a change now.” 0.12-1.65 s“There was a change now.”warp from 0.40 to 0.70 swarp-0.2 dBAControl trials play 6 dBA louder than warp trials:a loudness cue to the condition00.511.5time in each trial (s)

C. A plausible design: vocoder switched on, without a PCF, after a baseline phase (24 kHz, frameLen 32, nDelay 3)

Expected the participant hears their voice at the same level in every phase

Expected: the participant hears their voice at the same level in every phaseExpected (the participant hears their voice at the same level in every phase). trial 1 (baseline): Phase vocoder same level in every phase; Heard level, dBA 0.0 dBA. trial 2 (later phases): Phase vocoder same level in every phase; Heard level, dBA 0.0 dBA.Trial 1 · baseline“There was a change now.” 0.12-1.65 s“There was a change now.”same level in every phase0.0 dBATrial 2 · later phases (after phase change)“There was a change now.” 0.12-1.65 s“There was a change now.”same level in every phase0.0 dBA00.511.5time in each trial (s)

Observed bPitchShift 0 in the baseline, 1 afterwards, no PCF

Observed: bPitchShift 0 in the baseline, 1 afterwards, no PCFObserved (bPitchShift 0 in the baseline, 1 afterwards, no PCF). trial 1 (baseline): Phase vocoder vocoder off (bPitchShift 0); Heard level, dBA 0.0 dBA. trial 2 (later phases): Phase vocoder vocoder on, 0 st 0.02-1.73 s; Heard level, dBA +3.5 dBA.Trial 1 · baseline“There was a change now.” 0.12-1.65 s“There was a change now.”vocoder off (bPitchShift 0)0.0 dBATrial 2 · later phases (after phase change)“There was a change now.” 0.12-1.65 s“There was a change now.”vocoder on, 0 st from 0.02 to 1.73 svocoder on, 0 st+3.5 dBAFrom the first vocoder phase on, the voice isplayed 3.5 dBA louder00.511.5time in each trial (s)

Listen

  • Input, pre phase. a real sentence 1.8 s

  • Expected, pre phase. timeWrap settings, bPitchShift 0: 0.0 dBA 1.8 s

  • Observed, later phases. bPitchShift 1, warp-row PCF: -2.8 dBA, 13 ms later 1.8 s

  • A/B. A: pre phase, then a later phase 3.9 s

    No playhead: two clips back to back.

  • Input, trial 1. B: the same sentence on every trial 1.8 s

  • Expected, trial 1. B: control, zero-length warp row (-0.3 dBA) 1.8 s

  • Observed, trial 1. B: control, no warp section (+6.0 dBA) 1.8 s

  • A/B, trials 1 and 2 as observed. B: control, then warp trial 3.9 s

    No playhead: two clips back to back.

  • Expected, baseline. C: bPitchShift 0: 0.0 dBA 1.8 s

  • Observed, later phases. C: bPitchShift 1, no PCF: +3.5 dBA 1.8 s

These clips share one playback gain, so the level differences you hear are real. 24 kHz, 16-bit, as recorded by Audapter. Real voice: CMU ARCTIC clb a0018, © 2003 Carnegie Mellon University (licence notice), processed by Audapter.

Real speech
  • Input. Input, "Will we ever forget it." 1.3 s

    Source: CMU ARCTIC (Kominek & Black 2003): arctic_a0005.wav (CMU ARCTIC licence). Cut, mono 48 kHz, processed by Audapter (modified). CMU ARCTIC © 2003 Carnegie Mellon University (licence notice).

  • Observed. +2 st from the end of word 1 (example OST and pitch_pert.pcf) 1.3 s

    Source: CMU ARCTIC (Kominek & Black 2003): arctic_a0005.wav (CMU ARCTIC licence). Cut, mono 48 kHz, processed by Audapter (modified). CMU ARCTIC © 2003 Carnegie Mellon University (licence notice).

These clips share one playback gain. Real recordings from the audit’s open corpus; credits at the end of the report.

Run it in your browser

The pitch-onset part of this finding: a sustained /a/ at 120 Hz whose PCF steps from 0 to +2 semitones at 0.6 s (16 kHz defaults). This runs that trial through the real Audapter C++ core compiled to WebAssembly from the pinned commit, once as released and once with the overlap-add normalised. Pick the size of the step. Each trace is the output's RMS level in 20 ms blocks relative to the same input run with bPitchShift = 0, so 0 dB is the level a participant should hear.

The change in the “fix-pt-5” build: report/patches/pt-5-ola-normalise.patch
phase_vocoder.cpp/* audit fix PT-5: Hann analysis x Hann synthesis overlap-added at hop = L/osamp sums to 3*osamp/8, so with the2/(osamp/2) factor below the output is 1.5 x the input (+3.52 dB) for any osamp >= 4. Divide it out in pitch-shiftmode. The time-warp modes scale by 0.5 above (gain 0.75) and are left unchanged. */const dtype olaGain = (mode == PITCH_SHIFT_ONLY) ? 1.5 : 1.0;ftBuf2[2 * i] = 2 * ftBuf2[2 * i] * hWin[i] / (osamp / 2.0);ftBuf2[2 * i] = 2 * ftBuf2[2 * i] * hWin[i] / (osamp / 2.0) / olaGain;

How big, how often

timeWrap / cerebTimeAdaptHeard level re input: pre phase / later phases (warp-row PCF), this sentence0.0 dBA / -2.8 dBA
timeWrap / cerebTimeAdaptStep pre → later phases, 4 voices-3.5 to -2.8 dBA
timeWrap / cerebTimeAdaptOutput delay (clicks): pre phase / later phases2.7 / 16.0 ms
plausible design CHeard level re input: baseline (bPitchShift 0) / later phases (bPitchShift 1)0.0 dBA / +3.5 dBA
timeAdapt settingsHeard level, control trial without a warp section+6.0 dBA
timeAdapt settingsHeard level, control trial with a zero-length warp row / warp trial-0.3 dBA / -0.2 dBA
timeAdapt settingsLevel cue, no-warp control vs warp trial6.2 dBA
blab defaultsHeard level with no warp section / zero-length row / warp (16 kHz, frameLen 32)+3.5 / -2.6 / -2.4 dBA
pitch onsetLevel change 0 → +2 st, synthetic, F0 100–260 Hz, /a/ and /i/0.5 to 8.2 dBA quieter
real speechHeard level at 0 st, no warp section, 70 clips (16 kHz)+3.42 to +3.84 dBA
real speechLevel drop at a mid-utterance pitch onset (0 → +2 st), 9 clips0.5 to 7.4 dBA (median 3.0 dBA)

Level and loudness: for PT-5 A timeWrap/cerebTimeAdapt pre vs later phase, the heard level changes by -2.8 dBA and the loudness level by -2.7 to -2.4 phon (ISO 532-1, 60–85 dB SPL): at least 1 phon at every presentation level, above the ~1 dB just-noticeable difference. Level and loudness agree here: the change is close to a pure gain.

All level and loudness estimates for this card, and the assumptions
Comparison (expected → observed)Level, dBALoudness level, phon (ISO 532-1, 60–85 dB SPL)Plain RMS levelNoticeable?
PT-5 A timeWrap/cerebTimeAdapt pre vs later phase-2.8 dBA-2.7 to -2.4 phon (loudness ratio 0.83–0.85)-2.6 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
PT-5 B timeAdapt control: zero-length warp row vs none+6.3 dBA+5.6 to +6.2 phon (loudness ratio 1.47–1.53)+6.0 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
PT-5 B timeAdapt warp trial vs no-warp control+6.3 dBA+5.9 to +6.4 phon (loudness ratio 1.50–1.56)+6.1 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
PT-5 C no-PCF design: vocoder off vs on+3.5 dBA+3.1 to +3.4 phon (loudness ratio 1.24–1.26)+3.5 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
PT-5 vocoder on at 0 st vs off (synthetic /a/)+3.5 dBA+3.2 to +3.5 phon (loudness ratio 1.25–1.27)+3.5 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
PT-5 pitch onset: 0 st vs +2 st (synthetic /a/, F0 120 Hz)-1.0 dBA-1.0 to -1.0 phon (loudness ratio 0.93–0.93)-1.3 dBnear the ~1 dB just-noticeable difference; depends on presentation level

dBA: the A-weighted level difference, measured on the audio. Phon: model estimates (ISO 532-1, Zwicker) for a normal-hearing listener on headphones with a flat response, the expected clip presented at 60, 70, 80 and 85 dB SPL (A-weighted) and the observed clip at the same gain. Plain RMS: the unweighted level difference, for reference. Older or clinical listeners and real headphones may differ. Part A is a pure gain change (same spectrum), so the model adds little to the level figure there; the pitch onset moves energy up in frequency, where equal-loudness contours differ, so level and loudness can differ. How this is computed.

Data: data.json from the export script.

Cause

Analysis and synthesis both use a Hann window with a hop of a quarter frame. Overlap-adding the squared window sums to 1.5, and the synthesis scaling does not divide this out, so the output is 1.5 times the input at any shift ratio (2 times at frameLen 48, measured; we have not located the extra factor in the code, EXP-11). Once a PCF has a warp section, the vocoder runs in its time-warp mode for the whole trial, and that mode has a different gain (measured, COORD-4). With a shift, each frequency bin is resynthesised with fresh phase, so the level then depends on the signal. The normalisation switch is registered under a misspelt name, and its code divides by a quantity that is always zero.

for (int i = 0; i < pvocFrameLen; ++i) {    X_magn[i] = 2.0 * sqrt(ftBuf1[i * 2] * ftBuf1[i * 2] +                            ftBuf1[i * 2 + 1] * ftBuf1[i * 2 + 1]);

Analysis magnitude.

/* Scaling */for (int i = 0; i < pvocFrameLen; ++i)    ftBuf2[2 * i] = 2 * ftBuf2[2 * i] * hWin[i] / (osamp / 2.0);

Synthesis scaling: 2·w/(osamp/2) with osamp = 4 leaves the Hann² overlap sum of 1.5 in the output.

params.addBoolParam("bpvocmpnorm", "Phase vocoder amplitude normalization switch");

Registered as "bpvocmpnorm" (missing a)...

else if (ns == string("bpvocampnorm")) {    ptr = (void *)&p.bPvocAmpNorm;

...but the setter matches "bpvocampnorm", so both spellings are rejected.

&& (nzCnt == p.pvocFrameLen)) {    out_over_in = ms_out / ms_in;    amp_ratio = sqrt(out_over_in);

If the name is corrected, this divides by ms_in, a local set to 0 at line 1978 and never updated. In our test build the output became NaN.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Safe pattern: in a warp design, give every trial a warp section, a zero-length one on control trials, as blab's timeWrap experiment does; then all trials play at the same level. Calibrate loudness with the vocoder in the mode the experiment uses, not with bPitchShift = 0. Fix: divide the pitch-shift overlap-add by its actual window sum. report/patches/pt-5-ola-normalise.patch divides by 1.5, which gives unity gain at frameLen 32 and 64; at frameLen 48 the sum is 2, and the patch leaves +2.5 dB (checked in the browser build). A shift still changes the level, which needs a running level match. Fixing only the parameter name is not safe (the code then outputs NaN).

How we know

  • H harness test: t_pitch.m FAIL “pvoc: unity gain at 0 st”: gain 3.52 dB
  • H harness test: t_pitch.m FAIL “pvoc: no loudness step 0 -> +2 st”: step -3.15 dB
  • H report export + F0/vowel sweep: report_pt5.m
  • D standalone pvoc driver: scratch/pitch-time/pv_test.cpp gain 1.500 at 0 st on pure tones
  • F exact algebra on the Hann window: formal/ola/ola_exact.py the 0 st overlap-add gain is exactly 3/2 (+3.52 dB) for every hop ratio pvocFrameLen/pvocHop of 3 or more, independent of frame length, hop and sample rate; the unity fix is a factor 2/3 at phase_vocoder.cpp:467 (FORMAL-7)
  • F the real phase_vocoder.cpp, measured: formal/ola/pv_gain.cpp 1.5000 at hop ratios 4 and 8; content below the first bin gets 5/2; at hop ratio 2 the gain swings between 1 and 2 at the hop rate

Reproduce cd audit/harness && ./run-oct.sh t_pitch.m

With gain adaptation off, as in every blab formant experiment, a formant shift changes the heard level: on real voices, lowering F1 by 125 mel raised it by a median +2.9 dBA and raising it changed it by a median -1.4 dBA, so perturbed trials carry a level cue

Affects formant-shift experiments run with bGainAdapt 0, which is what getAudapterDefaultParams sends.
Relevant blab studies affected. Affected: every blab formant experiment, because getAudapterDefaultParams sets p.gainAdapt = 0 and AudapterIO('init') always sends it; no blab runner changes it. The level goes with the condition: attentionComp's shifted trials are louder than its noShift (catch) trials (shiftIH, F1 down, by 0.0 to 2.6 dBA; shiftAE, F1 up and F2 down, by 0.4 to 2.2 dBA, across voices); coAdapt's down-shifted word is louder than its up-shifted word; in simonSingleWord the level changes where the shift direction flips inside "bedhead". The 2-D centralization fields at strength 0.5 (the VSA studies) change the level by -1.8 to +0.7 dBA.

What happens

Audapter's formant shifter moves the two lowest resonances with a pair of filters. Moving a resonance changes how much of the voice's energy passes through, so the output level changes with the shift (in our runs, up when F1 is lowered, with exceptions such as a sustained /i/ with a very low F1; raising F1 changes it less and in either direction). Audapter has a gain-adaptation step that computes the filters' gain change and divides it out (bGainAdapt), but the MATLAB defaults switch it off. With it off, the level of every shifted trial differs from an unshifted trial of the same speech, by an amount that depends on the direction and size of the shift and on the vowel.

When it matters

Matters for designs that compare shifted and unshifted trials (compensation designs with catch trials) or opposite shifts (up vs down), because the level difference goes with the condition, and for any analysis of produced loudness after a shift. For reference, a level change of about 1 dB is noticeable in broadband sounds such as speech (Miller 1947). Natural speech has the opposite tendency (open vowels with high F1 are intrinsically louder), so the cue is also unnatural. Lowering F1 mostly boosts the strong low harmonics, to which the ear is less sensitive, so the heard change is smaller than the unweighted level suggests: on the two ARCTIC voices whose clips are below, the loudness model gives +1.3 to +2.8 phon for F1 −125 mel and -1.8 to -1.2 phon for F1 +125 mel (ISO 532-1, 60–85 dB SPL). With bGainAdapt 1 the cue does not go away but reverses: gain adaptation equalises the unweighted level, so the heard level then drops when F1 is lowered (median -1.4 dBA, -3.2 to -1.4 phon on the two ARCTIC voices) and rises when it is raised (median +2.4 dBA). Switching it on is not a simple fix.

Example

Eight real voices (four ARCTIC sentences, two PVQD speakers' sustained /a/ and /i/), each shifted and unshifted with the same settings (blab defaults, field mode, fb 1 so the voice can be measured). On the ARCTIC bdl sentence: F1 −125 mel +2.9 dBA, F1 +125 mel -0.7 dBA, F1 −20 % +1.2 dBA, F1 +20 % -0.4 dBA, 2-D centralization +0.7 dBA, re the unshifted output over the shifted span. Running the public coAdapt and attentionComp experiments with their own scripts, spoken by 8 AI-generated voices (heard level = signalOut × dScale re input), shows level differences by condition within one phase: coAdapt's down-shifted word is 0.5–5.4 dBA (median 2.9) louder than its up-shifted word, and attentionComp's shiftIH and shiftAE trials are up to 2.6 and 2.2 dBA louder than its catch trials.

Settings used

Source
blab's standard formant settings (getAudapterDefaultParams, field mode as in attentionComp and coAdapt: 125 mel, bMelShift 1; ratio shifts ±20 %; the vsaSentence-style 2-D centralization field at strength 0.5 on male voices), with bGainAdapt 0 (as sent) and 1
Input
real speech: CMU ARCTIC bdl a0005, rms a0018 (male), clb a0030, slt a0036 (female); PVQD LA9015 (male) and SJ7001 (female) sustained /a/ and /i/
Trials
unshifted, F1 +125 mel, F1 -125 mel, F1 +20 % (bRatioShift 1), F1 -20 % (bRatioShift 1), 2-D centralization 0.5 (male voices)
Between trials
each perturbation on the same input; bGainAdapt 0 (getAudapterDefaultParams, sent by AudapterIO) and 1
Key parameters
bShift 1, bRatioShift 0, bMelShift 1
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('male'):

p.bShift1
p.bRatioShift0
p.bMelShift1

Sent with Audapter('setParam', …):

pertAmp125 (all 257 values)
pertPhi0 (F1 up) or pi (F1 down)

One dot per voice and perturbation: the A-weighted level (dBA) of the shifted output relative to the unshifted output of the same input, over the frames Audapter logged as shifted. Solid: bGainAdapt 0 (as blab runs). Hollow: bGainAdapt 1.

F1 +125 melbGainAdapt 1: +3.6 dBAbGainAdapt 1: +5.1 dBAbGainAdapt 1: +1.7 dBAbGainAdapt 1: +2.5 dBAbGainAdapt 1: +3.9 dBAbGainAdapt 1: +2.3 dBAbGainAdapt 1: +1.6 dBAbGainAdapt 1: -2.3 dBAbGainAdapt 0: -0.7 dBAbGainAdapt 0: +1.5 dBAbGainAdapt 0: -2.0 dBAbGainAdapt 0: -1.3 dBAbGainAdapt 0: +0.2 dBAbGainAdapt 0: -3.3 dBAbGainAdapt 0: -1.5 dBAbGainAdapt 0: -8.3 dBA-8.3 to +1.5 dBAF1 −125 melbGainAdapt 1: -2.4 dBAbGainAdapt 1: -3.6 dBAbGainAdapt 1: -1.2 dBAbGainAdapt 1: -1.6 dBAbGainAdapt 1: +0.9 dBAbGainAdapt 1: -0.8 dBAbGainAdapt 1: -1.0 dBAbGainAdapt 1: -11.2 dBAbGainAdapt 0: +2.9 dBAbGainAdapt 0: +1.2 dBAbGainAdapt 0: +3.2 dBAbGainAdapt 0: +2.8 dBAbGainAdapt 0: +5.0 dBAbGainAdapt 0: +6.2 dBAbGainAdapt 0: +2.3 dBAbGainAdapt 0: -3.2 dBA-3.2 to +6.2 dBAF1 +20 %bGainAdapt 1: +2.7 dBAbGainAdapt 1: +5.0 dBAbGainAdapt 1: +1.5 dBAbGainAdapt 1: +1.8 dBAbGainAdapt 1: +2.2 dBAbGainAdapt 1: +2.7 dBAbGainAdapt 1: +1.5 dBAbGainAdapt 1: -3.7 dBAbGainAdapt 0: -0.4 dBAbGainAdapt 0: +2.0 dBAbGainAdapt 0: -1.6 dBAbGainAdapt 0: -1.3 dBAbGainAdapt 0: -0.9 dBAbGainAdapt 0: -0.4 dBAbGainAdapt 0: -1.4 dBAbGainAdapt 0: -6.9 dBA-6.9 to +2.0 dBAF1 −20 %bGainAdapt 1: -2.6 dBAbGainAdapt 1: -3.8 dBAbGainAdapt 1: -1.2 dBAbGainAdapt 1: -1.5 dBAbGainAdapt 1: +0.7 dBAbGainAdapt 1: -1.1 dBAbGainAdapt 1: -1.1 dBAbGainAdapt 1: -1.1 dBAbGainAdapt 0: +1.2 dBAbGainAdapt 0: -0.1 dBAbGainAdapt 0: +2.5 dBAbGainAdapt 0: +2.2 dBAbGainAdapt 0: +4.5 dBAbGainAdapt 0: +2.6 dBAbGainAdapt 0: +2.4 dBAbGainAdapt 0: +2.7 dBA-0.1 to +4.5 dBA2-D centralization, strength 0.5bGainAdapt 1: -0.5 dBAbGainAdapt 1: +3.0 dBAbGainAdapt 1: -1.8 dBAbGainAdapt 1: +2.1 dBAbGainAdapt 0: +0.7 dBAbGainAdapt 0: +0.2 dBAbGainAdapt 0: -1.8 dBAbGainAdapt 0: +0.7 dBA-1.8 to +0.7 dBA-12-8-4+0+4+8heard level vs unshifted, dBA (grey band: ±1 dB)bGainAdapt 0 (as blab runs)bGainAdapt 1 (C++ default)
F1 +125 melbGainAdapt 1: +3.6 dBAbGainAdapt 1: +5.1 dBAbGainAdapt 1: +1.7 dBAbGainAdapt 1: +2.5 dBAbGainAdapt 1: +3.9 dBAbGainAdapt 1: +2.3 dBAbGainAdapt 1: +1.6 dBAbGainAdapt 1: -2.3 dBAbGainAdapt 0: -0.7 dBAbGainAdapt 0: +1.5 dBAbGainAdapt 0: -2.0 dBAbGainAdapt 0: -1.3 dBAbGainAdapt 0: +0.2 dBAbGainAdapt 0: -3.3 dBAbGainAdapt 0: -1.5 dBAbGainAdapt 0: -8.3 dBA-8.3 to +1.5 dBAF1 −125 melbGainAdapt 1: -2.4 dBAbGainAdapt 1: -3.6 dBAbGainAdapt 1: -1.2 dBAbGainAdapt 1: -1.6 dBAbGainAdapt 1: +0.9 dBAbGainAdapt 1: -0.8 dBAbGainAdapt 1: -1.0 dBAbGainAdapt 1: -11.2 dBAbGainAdapt 0: +2.9 dBAbGainAdapt 0: +1.2 dBAbGainAdapt 0: +3.2 dBAbGainAdapt 0: +2.8 dBAbGainAdapt 0: +5.0 dBAbGainAdapt 0: +6.2 dBAbGainAdapt 0: +2.3 dBAbGainAdapt 0: -3.2 dBA-3.2 to +6.2 dBAF1 +20 %bGainAdapt 1: +2.7 dBAbGainAdapt 1: +5.0 dBAbGainAdapt 1: +1.5 dBAbGainAdapt 1: +1.8 dBAbGainAdapt 1: +2.2 dBAbGainAdapt 1: +2.7 dBAbGainAdapt 1: +1.5 dBAbGainAdapt 1: -3.7 dBAbGainAdapt 0: -0.4 dBAbGainAdapt 0: +2.0 dBAbGainAdapt 0: -1.6 dBAbGainAdapt 0: -1.3 dBAbGainAdapt 0: -0.9 dBAbGainAdapt 0: -0.4 dBAbGainAdapt 0: -1.4 dBAbGainAdapt 0: -6.9 dBA-6.9 to +2.0 dBAF1 −20 %bGainAdapt 1: -2.6 dBAbGainAdapt 1: -3.8 dBAbGainAdapt 1: -1.2 dBAbGainAdapt 1: -1.5 dBAbGainAdapt 1: +0.7 dBAbGainAdapt 1: -1.1 dBAbGainAdapt 1: -1.1 dBAbGainAdapt 1: -1.1 dBAbGainAdapt 0: +1.2 dBAbGainAdapt 0: -0.1 dBAbGainAdapt 0: +2.5 dBAbGainAdapt 0: +2.2 dBAbGainAdapt 0: +4.5 dBAbGainAdapt 0: +2.6 dBAbGainAdapt 0: +2.4 dBAbGainAdapt 0: +2.7 dBA-0.1 to +4.5 dBA2-D centralization, strength 0.5bGainAdapt 1: -0.5 dBAbGainAdapt 1: +3.0 dBAbGainAdapt 1: -1.8 dBAbGainAdapt 1: +2.1 dBAbGainAdapt 0: +0.7 dBAbGainAdapt 0: +0.2 dBAbGainAdapt 0: -1.8 dBAbGainAdapt 0: +0.7 dBA-1.8 to +0.7 dBA-12-8-4+0+4+8heard level vs unshifted, dBA (grey band: ±1 dB)bGainAdapt 0 (as blab runs)bGainAdapt 1 (C++ default)

Listen

  • Unshifted. ARCTIC bdl, bGainAdapt 0 1.3 s

    No playhead: this clip is not drawn in the figure.

  • F1 −125 mel. bGainAdapt 0: +2.9 dBA 1.3 s

    No playhead: this clip is not drawn in the figure.

  • F1 +125 mel. bGainAdapt 0: -0.7 dBA 1.3 s

    No playhead: this clip is not drawn in the figure.

  • A/B. F1 −125 mel, then +125 mel (bGainAdapt 0) 3.0 s

    No playhead: two clips back to back.

  • F1 −125 mel, bGainAdapt 1. with gain adaptation 1.3 s

    No playhead: this clip is not drawn in the figure.

All clips share one playback gain, so the level differences you hear are real. CMU ARCTIC © 2003 Carnegie Mellon University (licence notice), processed by Audapter.

How big, how often

coAdapt as runHold phase, heard level re input, 8 AI-generated voices: "bed" F1 +125 mel / "head" unshifted / "ted" F1 −125 mel-3.5 to -2.3 / -2.0 / -1.8 to +2.3 dBA: 0.5–5.4 dBA (median 2.9) between the up- and down-shifted words
attentionComp as runHeard level re input, 8 AI-generated voices: noShift / shiftAE / shiftIH-2.0 / -1.6 to +0.2 / -2.0 to +0.6 dBA: shiftIH +0.0 to +2.6 dBA and shiftAE +0.4 to +2.2 dBA re catch trials
bGainAdapt 0F1 −125 mel: heard level change across 8 voices (median)-3.2 to +6.2 dBA (+2.9)
bGainAdapt 0F1 +125 mel-8.3 to +1.5 dBA (-1.4)
bGainAdapt 0F1 −20 % / +20 % (medians)+2.4 / -1.1 dBA
bGainAdapt 02-D centralization at strength 0.5 (male voices)-1.8 to +0.7 dBA
bGainAdapt 1F1 −125 mel / +125 mel (medians)-1.4 / +2.4 dBA (ranges -11.2 to +0.9 / -2.3 to +5.1)
bGainAdapt 1Trial after a trial that ended while shifted, vs the same trial fresh0 samples differ (the stored gain state does not carry over in this test)

Level and loudness: for FMT level bdl_a0005 F1 -125 mel bGainAdapt 0, the heard level changes by +2.8 dBA and the loudness level by +1.3 to +1.7 phon (ISO 532-1, 60–85 dB SPL): at least 1 phon at every presentation level, above the ~1 dB just-noticeable difference. The loudness level changes less than the A-weighted level because the change also reshapes the spectrum.

All level and loudness estimates for this card, and the assumptions
Comparison (expected → observed)Level, dBALoudness level, phon (ISO 532-1, 60–85 dB SPL)Plain RMS levelNoticeable?
FMT level bdl_a0005 F1 -125 mel bGainAdapt 0+2.8 dBA+1.3 to +1.7 phon (loudness ratio 1.09–1.13)+6.6 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
FMT level bdl_a0005 F1 +125 mel bGainAdapt 0-0.7 dBA-1.8 to -1.7 phon (loudness ratio 0.88–0.89)-2.5 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
FMT level clb_a0030 F1 -125 mel bGainAdapt 0+3.2 dBA+2.4 to +2.8 phon (loudness ratio 1.18–1.21)+5.7 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
FMT level clb_a0030 F1 +125 mel bGainAdapt 0-1.7 dBA-1.4 to -1.2 phon (loudness ratio 0.91–0.92)-3.9 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
FMT level bdl_a0005 2-D centralization 0.5 bGainAdapt 0+0.7 dBA+1.0 to +1.0 phon (loudness ratio 1.07–1.07)+1.0 dBnear the ~1 dB just-noticeable difference; depends on presentation level
FMT level bdl_a0005 F1 -125 mel bGainAdapt 1-2.5 dBA-3.2 to -2.9 phon (loudness ratio 0.80–0.82)+0.9 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
FMT level bdl_a0005 F1 +125 mel bGainAdapt 1+3.6 dBA+1.7 to +2.0 phon (loudness ratio 1.13–1.15)+1.9 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference

dBA: the A-weighted level difference, measured on the audio. Phon: model estimates (ISO 532-1, Zwicker) for a normal-hearing listener on headphones with a flat response, the expected clip presented at 60, 70, 80 and 85 dB SPL (A-weighted) and the observed clip at the same gain. Plain RMS: the unweighted level difference, for reference. Older or clinical listeners and real headphones may differ. How this is computed.

Data: data.json from the export script.

Cause

getGain computes the filters' gain change for each shifted window and gainAdapt applies it at the next zero crossing, but only when bGainAdapt is 1. getAudapterDefaultParams sets gainAdapt = 0 and AudapterIO sends it.

//SC Notice that gain adaptation is done after formant shiftsif (p.bGainAdapt) {    nZC = gainAdapt(outBuf, gtot, p.frameLen, p.frameShift); // apply gain adaption between zerocrossings}nZCp = gainPerturb(outBuf, gtot, p.frameLen, p.frameShift);

Gain adaptation runs only if bGainAdapt is set.

dtype Audapter::getGain(dtype * r, dtype * ophi,dtype * sphi, int nfmts){// this routine calculates the gain factor used by the routine gainAdapt to compensate the formant shift//SC-Mod(2008/01/05) arguments xiff0 and xiff1: for intensity correction during formant shifts    int i0;    dtype prod=1;        for (i0 = 0; i0 < nfmts; i0++)        prod  *= (1-2*r[i0]*cos(sphi[i0])+pow(r[i0],2.0))/(1-2*r[i0]*cos(ophi[i0]) + pow(r[i0],2.0));        return prod;}

The gain factor of the formant filters, per window.

p.gainAdapt     =0;

The MATLAB default switches it off (as in 2.1.5).

Audapter(3,'bgainadapt',p.gainAdapt, toPrompt);

AudapterIO always sends it, overriding the C++ default of 1.

getAudapterDefaultParams has set gainAdapt = 0 since Audapter 2.1.5 (upstream and blab alike); the C++ default is 1.

Options

Decide deliberately. p.gainAdapt = 1 equalises the unweighted level but not the heard level (with it on, lowering F1 makes the voice quieter, median -1.4 dBA here), so it does not remove the cue. Keep 0 and treat the level as part of the manipulation, logging it (A-weighted signalOut level per trial), or match the A-weighted level per condition. Either way, a check of signalOut level by condition in existing data shows the size of the cue. The gain-adaptation state (static gain, formant notes F14) did not carry over between trials in our test.

How we know

  • H report export (eight real voices: report_loudcues.m
  • R reading: notes/formant.md F14: gainAdapt/gainPerturb keep static gain and lastSample across trials
  • H public blab experiments run with their own scripts (labrun): labrun/README.md LABRUN-4, VOICES-3: per-trial heard level = signalOut × dScale re input (summary.tsv gain_dBA; gain_dB unweighted), eight AI-generated voices (labrun/voices/README.md), bGainAdapt 0 as in every runner
  • H independent check by the coordinator (plain RMS): a_fmtlevel.m VERIFY-FMT-LEVEL: F1 −125 mel +4.9 to +7.0 dB RMS, +125 mel −1.0 to −3.8 dB RMS on three ARCTIC sentences

Reproduce cd audit/harness && ./run-oct.sh report_loudcues.m

An undocumented blab change that vsaSentence depends on: a field perturbation switches on again every time the formants re-enter the field (and minVowelLen has no effect)

Touches blab's current experiments: vsaSentence depends on this behaviour, and the other VSA experiments use the same restricted field. It is an undocumented behaviour change, not a defect for blab; it matters for anyone running these designs on Audapter 2.1.5 or another fork.
Relevant blab studies affected. Affected by design: vsaSentence depends on the re-arming, and vsaCentralize and vsaGeneralize use the same restricted field. The uniform whole-plane fields (attentionComp, coAdapt, uhdapter) are unaffected.

What happens

This is a question about documentation, not a defect: blab changed the rule deliberately (the "formant dropout fix", 2020), and vsaSentence's sentence design needs it, because its field excludes consonants, so with the original one-shot rule only the first vowel of each sentence would be perturbed. In perturbation-field mode, Audapter perturbs every stretch of speech whose formants fall inside the field, each time they enter it, for the whole trial. The parameter minVowelLen, which looks like it controls how long a stretch must be, has no effect and cannot even be changed.

When it matters

Affects field-mode experiments without an OST/PCF. On multi-syllable stimuli, diphthongs and glides, later syllables and returns to the target vowel are perturbed too, not only the first vowel. A brief tracker excursion out of the field does not end the perturbation. With a PCF loaded, the PCF decides per OST state and this does not apply. Across 80 real recordings this roughly doubles the perturbed frames compared with perturbing one stretch per trial.

Example

A field that raises F1 by 20 % while F1 is at least 600 Hz. On a 1 s /a/ → /i/ → /a/ glide, F1 leaves the field and comes back: Audapter shifts both /a/ stretches (0.12–0.35 s, 0.88–1.20 s), so the final /a/ is heard at 909 Hz instead of the spoken 778 Hz. A one-shot rule would shift only the first (0.12–0.35 s). On a steady /a/ with a brief F1 dip, the shift resumes after the dip.

Settings used

Source
Hypothetical field: a restricted field that raises F1 20 % above 600 Hz, standing in for vsaSentence's restricted 2-D centralisation field (the same re-entry rule applies to both).
Input
synthetic 1 s /a/ -> /i/ -> /a/ glide, and a steady /a/ with a brief F1 dip
Between trials
one trial per input; no OST or PCF (field mode)
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.rmsThresh0.005
p.bShift1
p.F1Min600
p.pertF2257 values, 0 to 5000
p.pertAmp0.2 (all 257 values)

One trial per input and rule: when the shift is applied with the current behaviour (observed) and with a one-shot rule (for comparison).

Field perturbation (F1 +20 % while F1 is at least 600 Hz): one-shot rule vs current behaviour, on two inputsOn the /a/-/i/-/a/ glide, the one-shot rule shifts F1 only in the first /a/ (0.12-0.35 s); the current behaviour also shifts the final /a/ (0.88-1.20 s). With a brief F1 dip out of the field, the one-shot rule stops shifting for the rest of the trial and the current behaviour resumes after the dip.Input 1: one vowel gliding /a/ → /i/ → /a/Input 1/a/ → /i/ → /a/Input 1/a//i//a/F1 trackedfield: F1 ≥ 600 Hz400 Hz800 Hzfield edge 600 HzShift appliedone-shot ruleShift applied: F1 +20 % from 0.118 to 0.352 sF1 +20 %Shift appliedcurrentShift applied: F1 +20 % from 0.118 to 0.352 sF1 +20 %Shift applied: F1 +20 % from 0.876 to 1.200 sF1 +20 %current: re-armed when F1 re-entered the fieldInput 2: a steady /a/ with a brief F1 dip, a separate trialInput 2/a/, 40 ms F1 dipInput 2/a/, F1 760 HzF1 dips to 520 HzShift appliedone-shot ruleShift applied: F1 +20 % from 0.118 to 0.608 sF1 +20 %Shift appliedcurrentShift applied: F1 +20 % from 0.118 to 0.608 sF1 +20 %Shift applied: F1 +20 % from 0.624 to 1.202 sF1 +20 %one-shot rule: off for the rest of the trial0 s0.2 s0.4 s0.6 s0.8 s1 s1.2 stime in trial (s)
Field perturbation (F1 +20 % while F1 is at least 600 Hz): one-shot rule vs current behaviour, on two inputsOn the /a/-/i/-/a/ glide, the one-shot rule shifts F1 only in the first /a/ (0.12-0.35 s); the current behaviour also shifts the final /a/ (0.88-1.20 s). With a brief F1 dip out of the field, the one-shot rule stops shifting for the rest of the trial and the current behaviour resumes after the dip.Input 1: one vowel gliding /a/ → /i/ → /a/Input 1 (/a/ → /i/ → /a/)Input 1/a//i//a/F1 tracked (field: F1 ≥ 600 Hz)400 Hz800 Hzfield edge 600 HzShift applied (one-shot rule)Shift applied: F1 +20 % from 0.118 to 0.352 sF1 +20 %Shift applied (current)Shift applied: F1 +20 % from 0.118 to 0.352 sF1 +20 %Shift applied: F1 +20 % from 0.876 to 1.200 sF1 +20 %current: re-armed when F1re-entered the fieldInput 2: a steady /a/ with a brief F1 dip,a separate trialInput 2 (/a/, 40 ms F1 dip)Input 2/a/, F1 760 HzF1 dips to 520 HzShift applied (one-shot rule)Shift applied: F1 +20 % from 0.118 to 0.608 sF1 +20 %Shift applied (current)Shift applied: F1 +20 % from 0.118 to 0.608 sF1 +20 %Shift applied: F1 +20 % from 0.624 to 1.202 sF1 +20 %one-shot rule: off forthe rest of the trial0 s0.2 s0.4 s0.6 s0.8 s1 s1.2 stime in trial (s)

Listen

  • Input. /a/ → /i/ → /a/ glide 1.3 s

    No playhead: this clip is not drawn in the figure.

  • Expected. Glide, one-shot rule: only the first /a/ shifted 1.3 s

    No playhead: this clip is not drawn in the figure.

  • Observed. Glide, current behaviour: the final /a/ is shifted too 1.3 s

    No playhead: this clip is not drawn in the figure.

  • Expected. /a/ with dip, one-shot rule: shift ends at the dip 1.3 s

    No playhead: this clip is not drawn in the figure.

  • Observed. /a/ with dip, current behaviour: shift resumes after the dip 1.3 s

    No playhead: this clip is not drawn in the figure.

All clips share one playback gain, so level differences are real. 16 kHz, 16-bit, as recorded by Audapter. Here “expected” means the one-shot rule, not a claim about which rule is correct. Listen to the final /a/ of the glide.

Real speech
  • Input. Input, "I had faith in them." 1.3 s

    Source: CMU ARCTIC (Kominek & Black 2003): arctic_a0030.wav (CMU ARCTIC licence). Cut, mono 48 kHz, processed by Audapter (modified). CMU ARCTIC © 2003 Carnegie Mellon University (licence notice).

  • Expected. One-shot rule: one stretch shifted

    Source: CMU ARCTIC (Kominek & Black 2003): arctic_a0030.wav (CMU ARCTIC licence). Cut, mono 48 kHz, processed by Audapter (modified). CMU ARCTIC © 2003 Carnegie Mellon University (licence notice).

  • Observed. Current behaviour: seven stretches shifted 1.3 s

    Source: CMU ARCTIC (Kominek & Black 2003): arctic_a0030.wav (CMU ARCTIC licence). Cut, mono 48 kHz, processed by Audapter (modified). CMU ARCTIC © 2003 Carnegie Mellon University (licence notice).

These clips share one playback gain. Real recordings from the audit’s open corpus; credits at the end of the report.

Run it in your browser

This runs both inputs through the real Audapter C++ core compiled to WebAssembly from the pinned commit, once with the current behaviour and once with a one-shot-per-trial rule (the change below, which restores the end-of-stretch rule). The one-shot build gives bit-identical output whenever the formants never leave the field. Which rule is right depends on the experiment.

The change in the “one-shot per trial” build: report/patches/f6-restore-transdone.patch
Audapter.cpp//if (p.transCounter>=p.minVowelLen){   CWN removed for dropouts//  p.transDone=true;//}if (p.transCounter>=p.minVowelLen){ /* audit alternative F6: upstream 2.1.5 behaviour (one field entry per trial) */p.transDone=true;}p.transDone = false;    // CWN added for dropouts

How big, how often

syntheticShifted stretches on the glide: current / one-shot0.12–0.35 s, 0.88–1.20 s / 0.12–0.35 s
syntheticShifted stretches on /a/ with a dip: current / one-shot0.12–0.61 s, 0.62–1.20 s / 0.12–0.61 s
syntheticF1 heard in the final /a/: spoken / current / one-shot778 / 909 / 767 Hz
syntheticminVowelLen set to 5 / 60 / 1000 framesidentical output on both builds
real speechPerturbed frames over 80 clips: current / one-shot42947 / 21006
real speechClips where the two rules differ61 of 80

Data: data.json from the export script.

Cause

In field mode, detectTrans() decides each voiced frame whether the formants are in the field. The rule that ended the perturbation when the formants left the field after at least minVowelLen frames (setting transDone, which disables detection until the next reset()) is commented out, so transDone is never set; its else branch can no longer run, and minVowelLen is read nowhere. Separately, minvowellen is registered as an int parameter but stored in a double, so setting it writes 4 bytes into the double and the value stays at 60 frames whatever is set (our sweep: identical output for 5, 60 and 1000).

if (p.bShift){    if (!p.transDone){        f2mp=f2m;        if (f2mp>=p.F2Min && f2mp<=p.F2Max && f1m>=p.F1Min && f1m<=p.F1Max             && ((p.LBk<=0 && f1m*p.LBk+p.LBb<=f2m) || (p.LBk>0 && f1m*p.LBk+p.LBb>=f2m))){            btransition=true;            p.transCounter++;        }        else{            btransition=false;                          //if (p.transCounter>=p.minVowelLen){   CWN removed for dropouts            //  p.transDone=true;            //}            p.transCounter=0;        }               }    else{        btransition=false;        p.transCounter=0;        p.transDone = false;    // CWN added for dropouts    }}

detectTrans(): the end-of-stretch rule is commented out, so transDone stays false and the else branch at 2415–2419 is unreachable.

if (p.bShift) {    if (pertCfg.n == 0) {        /*during_trans = false;*/    }    else {        during_trans = (pertCfg.fmtPertAmp[stat] != 0);    }

With a PCF loaded, the PCF overrides detectTrans(), so only PCF-less field mode is affected.

params.addIntParam("minvowellen", "Minimum vowel length (frames)");

Registered as an int...

dtype minVowelLen;

...stored as a double. The int setter (Audapter.cpp:1196–1197) writes only the low 4 bytes, and getParam reads them back, so the value looks set.

Introduced deliberately in the blab-lab fork (commit deab341, "Formant dropout fix v1", 2020), so that a brief tracker excursion out of the field no longer ends the perturbation for the rest of the trial.

Options

Choose and document one rule. If re-arming is intended, remove transDone, minVowelLen and the dead branch. If one stretch per trial is wanted, report/patches/f6-restore-transdone.patch restores it (4 lines); a parameter could offer both. Either way, store minVowelLen in a type the setter matches.

How we know

  • H report export: report_f6.m glide + dip + minVowelLen sweep; the one-shot comparison runs the same script on a 2.1.5 build
  • H first analysis (F1 850 → 400 → 850 Hz): a_reentry.m a second shifted stretch after re-entry
  • H WASM one-shot build: report/wasm/checks/alt-f6.mjs matches the one-shot comparison to the frame; bit-identical to the current build when the field is never left and on 6 reference scenarios

Reproduce cd audit/harness && ./run-oct.sh report_f6.m && VARIANT=upstream ./run-oct.sh report_f6.m build-upstream upstream/audapter_matlab && ./run-oct.sh report_f6.m

Also relevant to current experiments (small effects)

FMT-F3 Medium R

The 2D perturbation field uses the lower-left grid cell, not interpolation

What happens. The 2D field lookup rounds both grid positions down and ignores the fractional part, although the comments say linear interpolation. The perturbation is piecewise constant, biased by up to one grid step toward lower F1/F2, and the last row and column are never used.

When it matters. Affects 2D-field experiments whose fields change noticeably between grid points. Rows are F1 and columns F2 consistently.

if (p.bShift2D) {     mamp = p.pertAmp2D[locintf1][locintf2]; // Interpolaton (linear),2D pert field edit    mphi = p.pertPhi2D[locintf1][locintf2]; // 2D pert field edit}   

Introduced in the blab-lab fork.

Fix. Interpolate bilinearly, or document the nearest-lower-cell rule.

How we know. By reading. Audit notes

CORPUS-7 Low H

With pitch shifting on, low frequencies are boosted more than the rest: +7 dB at 30 Hz

What happens. At 0 semitones the phase vocoder adds +3.5 dB above 150 Hz (PT-5) and more below: +3.6 dB at 100 Hz, +4.0 at 80, +5.7 at 50 and +7.0 dB at 30 Hz. Measured on the audio, the bus noise alone rises by +3.5 dBA (+3.3 to +3.3 phon, ISO 532-1): most of the added energy is below 100 Hz, where the ear is less sensitive, so the heard change is smaller than the unweighted level change.

When it matters. Affects pitch-shift sessions in rooms with low-frequency noise: in a recording with bus noise, the noise alone rose by +3.5 dBA and the noisy speech as a whole by +3.6 dBA: the extra low-frequency energy is large in unweighted level but adds little that the ear weights, so the balance of noise and voice at the ear barely changes. It matters more for analyses of unweighted level or of low-frequency content in the output.

Level and loudness: for vocoder off vs on, the heard level changes by +3.5 dBA and the loudness level by +3.3 to +3.3 phon (ISO 532-1, 60–85 dB SPL): at least 1 phon at every presentation level, above the ~1 dB just-noticeable difference. Level and loudness agree here: the change is close to a pure gain.

All level and loudness estimates for this card, and the assumptions
Comparison (expected → observed)Level, dBALoudness level, phon (ISO 532-1, 60–85 dB SPL)Plain RMS levelNoticeable?
CORPUS-7 bus noise alone: vocoder off vs on+3.5 dBA+3.3 to +3.3 phon (loudness ratio 1.25–1.26)+7.4 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
CORPUS-7 noisy speech: vocoder off vs on+3.6 dBA+3.1 to +3.3 phon (loudness ratio 1.24–1.26)+6.0 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference

dBA: the A-weighted level difference, measured on the audio. Phon: model estimates (ISO 532-1, Zwicker) for a normal-hearing listener on headphones with a flat response, the expected clip presented at 60, 70, 80 and 85 dB SPL (A-weighted) and the observed clip at the same gain. Plain RMS: the unweighted level difference, for reference. Older or clinical listeners and real headphones may differ. How this is computed.

for (int i = 0; i < pvocFrameLen; ++i) {    X_magn[i] = 2.0 * sqrt(ftBuf1[i * 2] * ftBuf1[i * 2] +                            ftBuf1[i * 2 + 1] * ftBuf1[i * 2 + 1]);

Present since at least Audapter 2.1.5.

Fix. Halve the magnitude of the lowest bin in the phase vocoder, as the formal analysis of the gain shows.

How we know. Harness (harness/oct/corpus_pvoc_lf.m): a sine sweep and a real noisy recording. Audit notes

Questions for the lab, from the public experiment code

We read the lab's public experiment scripts and found three things, of different weight. One is a real open question: as committed, the public vsaGeneralize runner, which its public experiment script calls, perturbs the baseline trials and never scales the field (LAB-2); the lab's logs can settle it, and the later vsaGeneralize2 study is wired correctly. One is repository hygiene: vsaCentralize's repository ships an unused runner with wrong field wiring, while the runner its experiment calls is correct (LAB-1). One is a known, harmless dead setting: p.fb4Gain never reaches Audapter, as the lab's own code already notes, so every fb 4 study used the same default level (LAB-3). Every parameter sequence was reproduced exactly, with the lab's own calc_pertField, through Audapter. This is a reading of public code, not a claim about published results.

For reference, vsaSentence is wired as designed: it sends the mel grids after init (), and at strength 0.5 every vowel is heard 0.49–0.50 of the way to the centre (intended 0.50). That wiring, also used by the runner vsaCentralize's experiment calls, is the "Expected" in LAB-1 and LAB-2.

Two small script defects found by running the public code (see "Running the lab's own code"): free-speech's modelComp template calls run_checkLPC(expt, exptPre) with exptPre never defined (), so it stops when the LPC check is chosen; and attentionAdapt's server-down branch assigns expt.session but then reads expt.sessionOrder (), so the experiment cannot start when the lab server is unreachable. Neither affects data that was collected.

vsaCentralize's public repository ships an unused runner whose field wiring is wrong; the runner the experiment actually calls is wired correctly

A repository-hygiene note, not an Audapter defect and not a question about the lab's data. It matters to anyone who reuses the public runner as a template for a new vowel-space experiment.
Relevant blab studies safe as run; unused public copy mis-wired. vsaCentralize is safe as run: its experiment script calls run_vsaAdapt_audapter (in blab's unpublished code, not in the public repository), which sends the mel grids after init, as vsaSentence does. The public run_vsaAdapt2_audapter.m is called by no script, and it is mis-wired.

What the design intends

Each vowel should be heard partway toward the centre of the speaker's own vowel space. The lab's calc_pertField('in', fmtMeans, 1) builds a 257 × 257 field in mel: at every F1–F2 point the shift points toward the centroid of the speaker's corner vowels and its size is the distance to it. The experiment script scales that field by shiftScaleFact, which is 0.5 in the hold phase for the adapt group, so a vowel is heard 50 % of the way to the centre.

What the unused public runner does

Audapter looks up each frame's formants on the grid in pertf1/pertf2. calc_pertField returns mel grids in p.pertF1/p.pertF2. The runner the experiment calls re-sends those. The unused public runner instead re-sends p.pertf1/p.pertf2 (lower case) right after AudapterIO('init'): the Hz grids (200–1500 and 500–3500) the experiment script stored earlier. MATLAB field names are case-sensitive; Audapter's parameter names are not, so the Hz grid silently replaces the mel grid. The formants (in mel) are then looked up at the wrong cell of the field, and each vowel receives the shift meant for a different point of the vowel space.

Example

What someone reusing the public run_vsaAdapt2_audapter.m would get. On a real sustained /i/ (PVQD speaker LA9015), at strength 0.5: the design intends F2 to fall toward the centre; with the public runner's wiring, F2 rises. Measured on the audio: spoken F1 305 Hz, F2 2292 Hz; correctly wired F1 355 Hz, F2 1926 Hz; public runner F1 345 Hz, F2 2562 Hz. On six synthetic vowels at the lab's default means, 4 of 6 (/i/, /æ/, /ɪ/, /ɛ/) move away from the centre, and every heard vowel misses the intended one by 76–250 mel. Wired correctly (the called runner, and vsaSentence), the same field moves every vowel 0.49–0.50 of the way to the centre, as designed.

Settings used

Source
blab's vsaCentralize run_vsaAdapt2_audapter.m @9beb9a7 (not called by the experiment script) with run_vsaAdapt2_expt.m's stored grids and free-speech calc_pertField('in', means, 1), parameter sequence reproduced exactly. Expected = the same field sent with the mel grids, as the runner the experiment calls and vsaSentence do.
Input
real: PVQD speaker LA9015 (male, no dysphonia), sustained /i/ and /ɑ/, 1.2 s (CC BY 4.0); synthetic: male vowels (F0 120 Hz) at Hillenbrand men's means for /i/, /æ/, /ɑ/, /u/ (the lab's default corner vowels) and /ɪ/, /ɛ/
Trials
strength 0 (baseline), strength 0.5 (hold / train)
Between trials
setParam pertAmp2D = strength x field before every trial (run_vsaAdapt2_audapter.m:104-105); AudapterIO('init') once
Key parameters
bShift 1, bRatioShift 0, bMelShift 1, F2Min 607.455, F2Max 2019.33, F1Min 283.234, F1Max 1290.58, bShift2D 1
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('male'):

p.bShift1
p.bRatioShift0
p.bMelShift1
p.F2Min607.455
p.F2Max2019.33
p.F1Min283.234
p.F1Max1290.58
p.pertF1257 values, 283 to 1290
p.pertF2257 values, 607 to 2019
p.pertAmp2D66049 values, 0 to 493.244
p.pertPhi2D66049 values, -1.5708 to 4.70672
p.bShift2D1
p.pertf1257 values, 200 to 1500
p.pertf2257 values, 500 to 3500
p.fCen[601.391, 1312.89]

Sent with Audapter('setParam', …):

pertf1Hz grid 200..1500 (p.pertf1, lower case), re-sent after init
pertf2Hz grid 500..3500 (p.pertf2), re-sent after init
pertAmp2Dcalc_pertField field x strength, set before every trial
pertPhi2Dcalc_pertField angles, re-sent after init

fb 1 in these runs so formants can be measured on the audio; the experiment runs fb 3 with the bundled babble at 0.02, which does not change the field lookup.

Field centre (calc_pertField, default corner means): see settings.json; grids after init: pertf1 200–1500, pertf2 500–3500 (Hz) while F1Min..F2Max and the formants are in mel.

F1–F2 vowel space (F2 high to low, left to right). Grey dots: produced vowels; +: the field centre. Expected: the intended shift, 50 % toward the centre. Observed: the shift Audapter applied with the public runner's wiring (from the logged sfmts). Orange: where the heard vowel misses the intended one.

Real voice (PVQD speaker LA9015, sustained /i/ and /ɑ/), strength 0.5Expected: /i/ 367/2304 to 429/1896 Hz; /æ/ 598/1941 to 544/1734 Hz; /ɑ/ 745/1325 to 613/1432 Hz; /u/ 377/976 to 434/1239 Hz; /ɪ/ 452/2036 to 473/1778 Hz; /ɛ/ 595/1805 to 543/1671 Hz. Observed: /i/ 367/2304 to 419/2540 Hz; /æ/ 598/1941 to 562/2234 Hz; /ɑ/ 745/1325 to 647/1698 Hz; /u/ 377/976 to 425/1374 Hz; /ɪ/ 452/2036 to 473/2318 Hz; /ɛ/ 595/1805 to 559/2123 Hz.Real voice (PVQD speaker LA9015, sustained /i/ and /ɑ/), strength 0.5Expected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 307/2291 Hz to 396/1891 Hzi/ɑ/: 674/1163 Hz to 581/1345 HzɑObserved: the unused public runner1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 307/2291 Hz to 380/2530 Hzi/ɑ/: 674/1163 Hz to 606/1550 HzɑSynthetic vowels at the lab's default means, strength 0.5 (hold phase)Expected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 367/2304 Hz to 429/1896 Hzi/æ/: 598/1941 Hz to 544/1734 Hzæ/ɑ/: 745/1325 Hz to 613/1432 Hzɑ/u/: 377/976 Hz to 434/1239 Hzu/ɪ/: 452/2036 Hz to 473/1778 Hzɪ/ɛ/: 595/1805 Hz to 543/1671 HzɛObserved: the unused public runner1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 367/2304 Hz to 419/2540 Hzi/æ/: 598/1941 Hz to 562/2234 Hzæ/ɑ/: 745/1325 Hz to 647/1698 Hzɑ/u/: 377/976 Hz to 425/1374 Hzu/ɪ/: 452/2036 Hz to 473/2318 Hzɪ/ɛ/: 595/1805 Hz to 559/2123 HzɛOrange: the heard vowel misses the intended one; for /i/, /æ/, /ɪ/ and /ɛ/ it moves away from the centre (F2 up instead ofdown).
Real voice (PVQD speaker LA9015, sustained /i/ and /ɑ/), strength 0.5Expected: /i/ 367/2304 to 429/1896 Hz; /æ/ 598/1941 to 544/1734 Hz; /ɑ/ 745/1325 to 613/1432 Hz; /u/ 377/976 to 434/1239 Hz; /ɪ/ 452/2036 to 473/1778 Hz; /ɛ/ 595/1805 to 543/1671 Hz. Observed: /i/ 367/2304 to 419/2540 Hz; /æ/ 598/1941 to 562/2234 Hz; /ɑ/ 745/1325 to 647/1698 Hz; /u/ 377/976 to 425/1374 Hz; /ɪ/ 452/2036 to 473/2318 Hz; /ɛ/ 595/1805 to 559/2123 Hz.Real voice (PVQD speaker LA9015, sustained/i/ and /ɑ/), strength 0.5Expected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 307/2291 Hz to 396/1891 Hzi/ɑ/: 674/1163 Hz to 581/1345 HzɑObserved: the unused public runner1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 307/2291 Hz to 380/2530 Hzi/ɑ/: 674/1163 Hz to 606/1550 HzɑSynthetic vowels at the lab's defaultmeans, strength 0.5 (hold phase)Expected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 367/2304 Hz to 429/1896 Hzi/æ/: 598/1941 Hz to 544/1734 Hzæ/ɑ/: 745/1325 Hz to 613/1432 Hzɑ/u/: 377/976 Hz to 434/1239 Hzu/ɪ/: 452/2036 Hz to 473/1778 Hzɪ/ɛ/: 595/1805 Hz to 543/1671 HzɛObserved: the unused public runner1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 367/2304 Hz to 419/2540 Hzi/æ/: 598/1941 Hz to 562/2234 Hzæ/ɑ/: 745/1325 Hz to 647/1698 Hzɑ/u/: 377/976 Hz to 425/1374 Hzu/ɪ/: 452/2036 Hz to 473/2318 Hzɪ/ɛ/: 595/1805 Hz to 559/2123 HzɛOrange: the heard vowel misses the intended one;for /i/, /æ/, /ɪ/ and /ɛ/ it moves away from thecentre (F2 up instead of down).

Listen

  • Input, real /i/. F1 305 Hz, F2 2292 Hz 1.2 s

    No playhead: this clip is not drawn in the figure.

  • Expected, strength 0.5. wired with the mel grids: F1 355 Hz, F2 1926 Hz (F2 -367 Hz) 1.2 s

    No playhead: this clip is not drawn in the figure.

  • Observed, strength 0.5. public runner: F1 345 Hz, F2 2562 Hz (F2 +269 Hz) 1.2 s

    No playhead: this clip is not drawn in the figure.

  • A/B. expected, then observed 2.8 s

    No playhead: two clips back to back.

  • Expected, synthetic /i/. F1 463 Hz, F2 1906 Hz 0.8 s

    No playhead: this clip is not drawn in the figure.

  • Observed, synthetic /i/. F1 447 Hz, F2 2524 Hz 0.8 s

    No playhead: this clip is not drawn in the figure.

All clips share one playback gain. Real voice: PVQD (Walden 2022), speaker LA9015, sustained /i/, cut and processed by Audapter (CC BY 4.0). F1 and F2 beside each clip are measured on that clip's audio.

How big, how often

real voicePVQD /i/, F2 measured on the audio: spoken → correctly wired / public runner2292 → 1926 / 2562 Hz
syntheticVowels moved away from the centre, strength 0.54 of 6 (/i/, /æ/, /ɪ/, /ɛ/)
syntheticMovement toward the centre, as a fraction of the distance (intended 0.50)-0.58 to 0.70
syntheticSame field wired as vsaSentence does0.49 to 0.50 (as designed)

Data: data.json from the export script.

The code

The field and the formants are in mel; the grid the unused public runner re-sends is in Hz. The lines below build the mel field, store the Hz grids, and (in the unused runner) re-send them after init.

p.bShift2D = 1;     % flag for 2D experimentsp.pertAmp2D = pertAmp';     % convert to Audapter naming scheme w/ 2Dp.pertPhi2D = pertPhi';     % convert to Audapter naming scheme w/ 2Dp.pertF1 = pertF1;p.pertF2 = pertF2;

calc_pertField returns the mel field and its mel grid as pertF1 (upper-case F).

p.pertf1 = floor(p.F1Min:(p.F1Max-p.F1Min)/(fieldDim-1):p.F1Max);p.pertf2 = floor(p.F2Min:(p.F2Max-p.F2Min)/(fieldDim-1):p.F2Max);

The experiment script also stores Hz grids as pertf1/pertf2 (lower case).

AudapterIO('init', p);Audapter(3,'pertf1',p.pertf1);Audapter(3,'pertf2',p.pertf2);Audapter(3,'pertAmp2D',p.pertAmp2D);Audapter(3,'pertPhi2D',p.pertPhi2D);

After init the unused public runner re-sends the lower-case Hz grids (the runner the experiment calls re-sends the mel grids instead); Audapter's parameter names are case-insensitive, so they replace the mel grid.

if strcmp(expt.group,'adapt')    maxScaleFact = .5;elseif strcmp(expt.group,'null')    maxScaleFact = 0;end% shiftScaleFact is a scalar between 0 and maxScaleFact that is used to scale shiftMag matrixexpt.shiftScaleFact = [zeros(1,nBaseline) linspace(0,maxScaleFact,nRamp) maxScaleFact*ones(1,nHold) zeros(1,nWashout+nRetention)];

The design: the field is scaled by 0.5 in the hold phase (adapt group).

Fix

Re-send the mel grids p.pertF1/p.pertF2 after init in run_vsaAdapt2_audapter.m, or delete it or replace it with the runner the experiment calls. If desired, the lab's logs could confirm the wiring directly: on hold trials sfmts − fmts points toward the vowel-space centre.

How we know

  • H report export (public runner's exact parameter sequence: report_vsa.m
  • H field wiring check: exp_vsa_field.m EXP-4: re-run for this report

Reproduce cd audit/harness && ./run-oct.sh report_vsa.m

An open question for vsaGeneralize: as committed, the runner its experiment script calls perturbs the baseline trials by 201–490 mel and gives the training trials the full, unscaled field; the lab's logs can settle it

A question about the lab's public experiment code, not an Audapter defect. The public experiment script calls this public runner; the version that collected the data could still differ from the committed one. This is a reading of public code, not a claim about published results; the lab's public data (OSF 78z93) can settle it.
Relevant blab studies affected. vsaGeneralize: its public experiment script calls this public runner, so this is the open question. The lab's later vsaGeneralize2 study is not affected: it sends the mel grids and scales pertAmp2D.

What the design intends

"A non-uniform auditory perturbation of vowel formants that centralizes all vowels" (README). Baseline-training trials have strength 0 (no perturbation); training trials have strength 0.5, so each vowel is heard halfway to the centre of the speaker's vowel space; generalization trials are unperturbed and masked (fb 4).

What the committed code does

calc_pertField switches on the two-dimensional field (bShift2D = 1) at full strength, and AudapterIO('init') sends it. Per trial, the committed runner scales only the one-dimensional pertAmp, which is all zeros, so the two-dimensional field is never scaled: it stays at full strength on every fb 3 trial, including strength 0. The runner also re-sends the Hz grids (as in vsaCentralize), so the full-strength field is looked up in the wrong place.

Example

On a real sustained /ɑ/ (PVQD speaker LA9015) on a baseline-training trial (strength 0): the design intends no change; as committed, F2 is raised by 761 Hz. Measured on the audio: spoken F1 673 Hz, F2 1163 Hz; intended F1 673 Hz, F2 1163 Hz; as committed F1 523 Hz, F2 1923 Hz. On six synthetic vowels every one is shifted, by 201–490 mel, and the training-phase shifts are identical to the baseline ones. Confirmed by running the committed vsaGeneralize experiment script and runner end to end for a full session (466 trials, unmodified code; see "Running the lab's own code"): baseline-training trials at strength 0 are shifted like training trials in every one of eight AI-generated voices: 0.29–0.37 s per trial, median 177–236 mel in F1 and 334–424 mel in F2, heard F2 ×1.51–1.70. The lab's logs can settle whether this runner collected the data.

Settings used

Source
blab's vsaGeneralize run_vsaGeneralize_audapter.m @0ec575a with run_vsaGeneralize_expt.m and free-speech calc_pertField('in', means, 1), parameter sequence reproduced exactly. Expected = the design: no shift at strength 0, the field scaled by 0.5 on training trials.
Input
real: PVQD speaker LA9015 (male, no dysphonia), sustained /ɑ/ and /i/, 1.2 s (CC BY 4.0); synthetic: six male vowels at Hillenbrand men's means
Trials
strength 0 (baseline), strength 0.5 (hold / train)
Between trials
setParam pertAmp = strength x p.pertAmp before every trial (run_vsaGeneralize_audapter.m:117-118); AudapterIO('init') once
Key parameters
bShift 1, bRatioShift 0, bMelShift 1, F2Min 607.455, F2Max 2019.33, F1Min 283.234, F1Max 1290.58
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('male'):

p.bShift1
p.bRatioShift0
p.bMelShift1
p.F2Min607.455
p.F2Max2019.33
p.F1Min283.234
p.F1Max1290.58
p.pertF1257 values, 283 to 1290
p.pertF2257 values, 607 to 2019
p.pertAmp2D66049 values, 0 to 986.487
p.pertPhi2D66049 values, -1.5708 to 4.70672
p.bShift2D1
p.pertf1257 values, 200 to 1500
p.pertf2257 values, 500 to 3500
p.fCen[601.391, 1312.89]

Sent with Audapter('setParam', …):

pertf1Hz grid 200..1500 (p.pertf1), re-sent after init
pertf2Hz grid 500..3500 (p.pertf2), re-sent after init
pertAmp1-D field (all zeros) x strength, set before every trial
pertPhi1-D angles (all zeros)
pertAmp2Dfull-strength calc_pertField field, sent once by AudapterIO('init') (bShift2D = 1)

bShift2D = 1 and the full-strength pertAmp2D come from calc_pertField via expt.audapterParams and are sent by AudapterIO('init'); pertAmp (1-D, all zeros) is the only field the runner scales.

fb 1 in these runs so formants can be measured; the experiment runs fb 3 with babble at 0.02 (field lookup unchanged).

F1–F2 vowel space. Expected: what the design intends (no shift at strength 0; 50 % toward the centre at strength 0.5). Observed: the shift Audapter applied with the committed wiring (logged sfmts). Orange: where they differ.

Real voice (PVQD speaker LA9015, /i/ and /ɑ/), strength 0 (baseline training)Strength 0, expected: no shift. Observed: /i/ 367/2304 to 473/2796 Hz; /æ/ 598/1941 to 527/2560 Hz; /ɑ/ 745/1325 to 556/2140 Hz; /u/ 377/976 to 475/1866 Hz; /ɪ/ 452/2036 to 493/2628 Hz; /ɛ/ 595/1805 to 524/2482 Hz. Strength 0.5, expected: /i/ 367/2304 to 429/1896 Hz; /æ/ 598/1941 to 544/1734 Hz; /ɑ/ 745/1325 to 613/1432 Hz; /u/ 377/976 to 434/1239 Hz; /ɪ/ 452/2036 to 473/1778 Hz; /ɛ/ 595/1805 to 543/1671 Hz.Real voice (PVQD speaker LA9015, /i/ and /ɑ/), strength 0 (baseline training)Expected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centreiɑno shift intendedObserved: the committed wiring1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 307/2291 Hz to 458/2791 Hzi/ɑ/: 674/1163 Hz to 541/2017 HzɑSynthetic vowels, strength 0 (baseline-training trials)Expected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centreiæɑuɪɛno shift intendedObserved: the committed wiring1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 367/2304 Hz to 473/2796 Hzi/æ/: 598/1941 Hz to 527/2560 Hzæ/ɑ/: 745/1325 Hz to 556/2140 Hzɑ/u/: 377/976 Hz to 475/1866 Hzu/ɪ/: 452/2036 Hz to 493/2628 Hzɪ/ɛ/: 595/1805 Hz to 524/2482 HzɛStrength 0.5 (training trials)Expected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 367/2304 Hz to 429/1896 Hzi/æ/: 598/1941 Hz to 544/1734 Hzæ/ɑ/: 745/1325 Hz to 613/1432 Hzɑ/u/: 377/976 Hz to 434/1239 Hzu/ɪ/: 452/2036 Hz to 473/1778 Hzɪ/ɛ/: 595/1805 Hz to 543/1671 HzɛObserved: the committed wiring1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 367/2304 Hz to 473/2796 Hzi/æ/: 598/1941 Hz to 527/2560 Hzæ/ɑ/: 745/1325 Hz to 556/2140 Hzɑ/u/: 377/976 Hz to 475/1866 Hzu/ɪ/: 452/2036 Hz to 493/2628 Hzɪ/ɛ/: 595/1805 Hz to 524/2482 HzɛOrange: at strength 0 every vowel is shifted, with F2 raised by several hundred hertz; at strength 0.5 the shifts are thesame, not scaled.
Real voice (PVQD speaker LA9015, /i/ and /ɑ/), strength 0 (baseline training)Strength 0, expected: no shift. Observed: /i/ 367/2304 to 473/2796 Hz; /æ/ 598/1941 to 527/2560 Hz; /ɑ/ 745/1325 to 556/2140 Hz; /u/ 377/976 to 475/1866 Hz; /ɪ/ 452/2036 to 493/2628 Hz; /ɛ/ 595/1805 to 524/2482 Hz. Strength 0.5, expected: /i/ 367/2304 to 429/1896 Hz; /æ/ 598/1941 to 544/1734 Hz; /ɑ/ 745/1325 to 613/1432 Hz; /u/ 377/976 to 434/1239 Hz; /ɪ/ 452/2036 to 473/1778 Hz; /ɛ/ 595/1805 to 543/1671 Hz.Real voice (PVQD speaker LA9015, /i/ and/ɑ/), strength 0 (baseline training)Expected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centreiɑno shift intendedObserved: the committed wiring1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 307/2291 Hz to 458/2791 Hzi/ɑ/: 674/1163 Hz to 541/2017 HzɑSynthetic vowels, strength 0(baseline-training trials)Expected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centreiæɑuɪɛno shift intendedObserved: the committed wiring1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 367/2304 Hz to 473/2796 Hzi/æ/: 598/1941 Hz to 527/2560 Hzæ/ɑ/: 745/1325 Hz to 556/2140 Hzɑ/u/: 377/976 Hz to 475/1866 Hzu/ɪ/: 452/2036 Hz to 493/2628 Hzɪ/ɛ/: 595/1805 Hz to 524/2482 HzɛStrength 0.5 (training trials)Expected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 367/2304 Hz to 429/1896 Hzi/æ/: 598/1941 Hz to 544/1734 Hzæ/ɑ/: 745/1325 Hz to 613/1432 Hzɑ/u/: 377/976 Hz to 434/1239 Hzu/ɪ/: 452/2036 Hz to 473/1778 Hzɪ/ɛ/: 595/1805 Hz to 543/1671 HzɛObserved: the committed wiring1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/i/: 367/2304 Hz to 473/2796 Hzi/æ/: 598/1941 Hz to 527/2560 Hzæ/ɑ/: 745/1325 Hz to 556/2140 Hzɑ/u/: 377/976 Hz to 475/1866 Hzu/ɪ/: 452/2036 Hz to 493/2628 Hzɪ/ɛ/: 595/1805 Hz to 524/2482 HzɛOrange: at strength 0 every vowel is shifted, withF2 raised by several hundred hertz; at strength0.5 the shifts are the same, not scaled.

Listen

  • Input, real /ɑ/. F1 673 Hz, F2 1163 Hz 1.2 s

    No playhead: this clip is not drawn in the figure.

  • Expected, strength 0. no shift: F1 673 Hz, F2 1163 Hz 1.2 s

    No playhead: this clip is not drawn in the figure.

  • Observed, strength 0. as committed: F1 523 Hz, F2 1923 Hz (F2 +761 Hz) 1.2 s

    No playhead: this clip is not drawn in the figure.

  • A/B. expected, then observed 2.8 s

    No playhead: two clips back to back.

  • Observed, synthetic /u/, strength 0. F1 483 Hz, F2 1889 Hz (spoken F1 377 Hz, F2 971 Hz) 0.8 s

    No playhead: this clip is not drawn in the figure.

All clips share one playback gain. Real voice: PVQD (Walden 2022), speaker LA9015, sustained /ɑ/, cut and processed by Audapter (CC BY 4.0). F1 and F2 beside each clip are measured on that clip's audio.

How big, how often

real voicePVQD /ɑ/, baseline trial (strength 0): F2 measured on the audio, spoken → as committed1163 → 1923 Hz (intended: unchanged, 1163 Hz)
syntheticBaseline trials (strength 0): logged shift201–490 mel on every vowel (intended 0)
syntheticTraining trials (strength 0.5) vs baselineidentical shifts: the strength is never applied
syntheticTraining trials: vowels moved away from the centre4 of 6

Data: data.json from the export script.

The code

The two-dimensional field is switched on at full strength by calc_pertField; the runner scales the one-dimensional field instead, and re-sends the Hz grids.

p.bShift2D = 1;     % flag for 2D experimentsp.pertAmp2D = pertAmp';     % convert to Audapter naming scheme w/ 2Dp.pertPhi2D = pertPhi';     % convert to Audapter naming scheme w/ 2D

calc_pertField turns on the 2-D field and supplies it at full strength.

p.pertf1 = floor(p.F1Min:(p.F1Max-p.F1Min)/(fieldDim-1):p.F1Max);p.pertf2 = floor(p.F2Min:(p.F2Max-p.F2Min)/(fieldDim-1):p.F2Max);% p.pertAmp = zeros(fieldDim,fieldDim); % define dummy pert fieldp.pertAmp = zeros(1, fieldDim);% p.pertPhi = zeros(fieldDim,fieldDim); % define dummy pert fieldp.pertPhi = zeros(1, fieldDim);expt.audapterParams = p; maxScaleFact = .5;% shiftScaleFact is a scalar between 0 and maxScaleFact that is used to scale shiftMag matrixexpt.shiftScaleFact = [zeros(1,nBaseline_generalize) zeros(1, nBaseline_train) maxScaleFact*ones(1,nTrain) zeros(1,nGeneralization)];

The script stores an all-zero 1-D field and Hz grids, and a strength of 0 for the baselines and 0.5 for training.

Audapter(3,'pertf1',p.pertf1);Audapter(3,'pertf2',p.pertf2);Audapter(3,'pertAmp',p.pertAmp);Audapter(3,'pertPhi',p.pertPhi);

After init: the Hz grids and the 1-D field are re-sent.

% set new perturbationp.pertAmp = expt.shiftScaleFact(trial_index) * expt.audapterParams.pertAmp;Audapter('setParam','pertAmp',p.pertAmp)

Per trial only the 1-D pertAmp (zeros) is scaled; pertAmp2D keeps its full strength.

How to check

In the public data (OSF 78z93): on baseline-training trials (shiftScaleFact = 0) every frame should have sfmts == fmts. If the committed runner was used, sfmts differs from fmts by hundreds of mel, mostly F2 up, and training trials show the same shift size as baseline trials. If baseline trials have sfmts == fmts and training shifts point toward the centre at about half the distance, the question is closed. Correction, if needed: scale pertAmp2D per trial (p.pertAmp2D = scale * expt.audapterParams.pertAmp2D) and re-send the mel grids p.pertF1/p.pertF2, as vsaSentence does.

How we know

  • H report export (public runner's exact parameter sequence: report_vsa.m
  • H field wiring check: exp_vsa_field.m EXP-4: re-run for this report
  • H the committed runner run end to end: labrun/README.md LABRUN-2, VOICES-2: with eight AI-generated voices, baseline-training trials (strength 0) shifted like training trials, 40/40 trials in every voice, 0.29–0.37 s per trial, median |dF1| 177–236 mel and |dF2| 334–424 mel, heard F2 ×1.51–1.70

Reproduce cd audit/harness && ./run-oct.sh report_vsa.m

A dead line in the public vsaGeneralize script: p.fb4Gain = 0.98 never reaches Audapter, so the noise plays at the default level, as in every blab fb 4 study

Informational. For everyone: AudapterIO('init') silently ignores fields of p it does not know, so a misspelt or non-existent parameter has no effect and no warning; check the saved data.params for the value you meant.
Relevant blab studies safe as run. Safe as run, and already noted in the lab's own code: blab's unpublished runners (vsaGeneralize2, biteblockAdapt, trainingVarGen, sponAdapt2) carry comments that fb4Gain is not an Audapter parameter, that the real one is fb4GainDB (default 10 dB), and that none of their experiments change it. So every blab fb 4 study, vsaGeneralize included, played the noise at the same default level; only the 0.98 in the public script suggests otherwise.

What the line suggests

In the generalization phases (fb 4, speech-modulated masking noise) the script sets the noise gain to 0.98 (p.fb4Gain = 0.98), just under unity.

What the committed code does

AudapterIO('init') forwards only p.fb4GainDB, in decibels; a field called fb4Gain is ignored without a warning. Audapter therefore uses its default of 10 dB, not the −0.18 dB that 0.98 corresponds to.

Example

A real sentence (CMU ARCTIC bdl "Will we ever forget it.") in fb 4 with the bundled babble. Measured on the audio: at the gain the line suggests (0.98) the output would be -42.4 dBFS (A-weighted); at the default level that actually plays, -32.2 dBFS (10.2 dBA louder).

Settings used

Source
blab's vsaGeneralize run_vsaGeneralize_audapter.m lines 62–66 @0ec575a (fb 4, p.fb4Gain = 0.98), bundled babble via free-speech get_noiseSource. Expected: fb4GainDB = 20·log10(0.98).
Input
real: CMU ARCTIC bdl a0005 "Will we ever forget it." (male); synthetic: male /ɑ/, 1 s
Between trials
generalization phases: fb 4 (speech-modulated noise)
Key parameters
fb 4, fb4Gain 0.98
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('male'):

p.fb4
p.fb4Gain0.98

Sent with Audapter('setParam', …):

datapbbundled babble via free-speech get_noiseSource

A-weighted level of what the participant hears in fb 4 (speech-modulated babble), measured on the audio.

Expected: fb4GainDB = 20·log10(0.98)-42.4 dBFS(A)Observed: default fb4GainDB (10 dB)-32.2 dBFS(A)+10.2 dBA louder masking noise than the script sets-45-40-35-30-25-20fb 4 output level, A-weighted, dB re full scale (measured)
Expected: fb4GainDB = 20·log10(0.98)-42.4 dBFS(A)Observed: default fb4GainDB (10 dB)-32.2 dBFS(A)+10.2 dBA louder than the script sets-45-40-35-30-25-20fb 4 output level, A-weighted, dB re full scale (measured)

Listen

  • Input. the spoken sentence (not heard in fb 4) 1.3 s

    No playhead: this clip is not drawn in the figure.

  • Expected. noise at the gain the line suggests: -42.4 dBFS (A-weighted) 1.3 s

    No playhead: this clip is not drawn in the figure.

  • Observed. noise at the default level (what plays): -32.2 dBFS (A-weighted, +10.2 dBA) 1.3 s

    No playhead: this clip is not drawn in the figure.

  • A/B. expected, then observed 3.0 s

    No playhead: two clips back to back.

All clips share one playback gain, so the level difference you hear is real. The noise follows the loudness of the sentence (fb 4). CMU ARCTIC © 2003 Carnegie Mellon University (licence notice); cut and processed by Audapter.

How big, how often

real voicefb 4 output level, A-weighted: the 0.98 the line suggests / the default that plays-42.4 / -32.2 dBFS
real voiceDifference+10.2 dBA
settingsfb4GainDB in effect / what 0.98 would be10.0 / -0.18 dB

Level and loudness: for 0.98 (intended) vs default, the heard level changes by +10.2 dBA and the loudness level by +8.9 to +9.6 phon (ISO 532-1, 60–85 dB SPL): at least 1 phon at every presentation level, above the ~1 dB just-noticeable difference. The loudness level changes less than the A-weighted level because the change also reshapes the spectrum.

All level and loudness estimates for this card, and the assumptions
Comparison (expected → observed)Level, dBALoudness level, phon (ISO 532-1, 60–85 dB SPL)Plain RMS levelNoticeable?
LAB-3 fb 4 noise: 0.98 (intended) vs default+10.2 dBA+8.9 to +9.6 phon (loudness ratio 1.86–1.95)+10.2 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference

dBA: the A-weighted level difference, measured on the audio. Phon: model estimates (ISO 532-1, Zwicker) for a normal-hearing listener on headphones with a flat response, the expected clip presented at 60, 70, 80 and 85 dB SPL (A-weighted) and the observed clip at the same gain. Plain RMS: the unweighted level difference, for reference. Older or clinical listeners and real headphones may differ. A pure gain change of the noise, so the model adds little to the level figure. How this is computed.

Data: data.json from the export script.

The code

The script sets a field that AudapterIO does not know; only fb4GainDB is forwarded.

w = get_noiseSource(p);Audapter('setParam', 'datapb', w, 1);if strcmp(conds2run{1},'baselineGeneralize') || strcmp(conds2run{1},'generalization')    p.fb = 4;    p.fb4Gain = 0.98;

The script sets fb4Gain (linear).

if (isfield(p, 'fb4GainDB'))    Audapter(3, 'fb4gaindb', p.fb4GainDB, toPrompt);end

AudapterIO forwards only fb4GainDB; other fields of p are ignored silently.

Fix

Delete the line, or write p.fb4GainDB = … if a different level is ever wanted. Audapter could have warned: AudapterIO('init') should list fields of p it does not forward.

How we know

Reproduce cd audit/harness && ./run-oct.sh report_vsa.m

Trial-to-trial carry-over in mixed designs

Carrie Niziolek asked whether, in designs where perturbed trials are followed by unperturbed ones, one trial's lingering state can affect the next. As blab runs its experiments, it does not. With other OST rules or other ways of switching conditions, it can, and the effect is large and silent. This section shows both.

blab's switching methods: no carry-over

We ran each method the lab uses to change the condition between trials, with the lab's own settings and OST files, in mixed sessions of perturbed and catch trials, once in order and once in reverse order.

How the condition changes between trialsUsed byResult
Field mode: setParam pertAmp/pertPhi before each trial (0 on catch trials); OST and PCF cleared at the start attentionComp, coAdapt, uhdapter, the VSA experiments, the free-speech template Catch trials: 0.000 s shifted; F1 heard/spoken 0.998–1.009. Perturbed trials: exactly ±125 mel logged. Reversing the trial order changes nothing: 7 of 7 trials identical in the log, 8 of 8 in the audio.
PCF rewritten and reloaded before each trial, OST loaded once simonMultisyllable (seven/sever, pedigree), simonSingleWord v2 Catch trials: 0.000 s shifted. The shift ends at the detected vowel offset on every perturbed trial. 7 of 7 trials identical in either order.
AudapterIO('init', p) before each trial simonSingleWord v2; blab's pitch experiments (pitchAdaptPD, pitchComp) Re-sends every parameter, including pitchShiftRatio, so the pitch carry-over below cannot occur.

Across all 69 OST files in blab's public and unpublished experiment code, every one of the 57 INTENSITY_FALL rules comes after a rule in the same trial that refreshes the stored time, so none can read the previous trial's value (blab's timeWrap uses the RMS-floor rule for a burst onset, but a rise-and-hold rule runs before its FALL). No blab OST has a maxIOI timeout. The one remaining difference between trials is cosmetic: the first trial after MATLAB starts logs slightly different formants for its first 36 ms of voicing (the tracker's memory; the audio is identical). Evidence: exp_mixed.m (S1, S2), exp_simon_session.m; log entries EXP-2, EXP-6, EXP-10.

What to avoid: when carry-over does happen

OST-F1 · offset detected later every trial

Affected if an INTENSITY_FALL rule has no refreshing rule before it in the same trial: the only rules before it are ELAPSED_TIME, the RMS-floor rule (…_WITH_RMS_FLOOR, mode 32) or the ratio rules (INTENSITY_RATIO_RISE, INTENSITY_RATIO_FALL_HOLD). These do not refresh the stored time; every other rule does. Reloading the OST or re-running init does not help.

Safe if any rise-and-hold, slope, stretch, FALL or AND_RATIO rule runs before the FALL in the same trial, as in every blab OST.

COORD-1 · a PCF left loaded from an earlier block

Affected if a field-mode block follows any block that loaded an OST/PCF (every blab session starts with the measureFormants calibration) and does not clear them. AudapterIO('init') does not.

Safe if the runner calls Audapter('ost', '', 0); Audapter('pcf', '', 0); first, as every blab field-mode runner does.

OST-F5 · pitch catch trials shifted

Affected if pitch catch trials are made with Audapter('pcf', '', 0) after a trial that ended while shifted, without re-running init.

Safe if catch trials load an all-zero PCF, or init runs before every trial.

OST-F2 · maxIOI timeout drifts

Affected if the OST has a maxIOI (timeout) section and is loaded once for many trials.

Safe if there is no maxIOI section (all blab OSTs), or the OST is reloaded before every trial.

With some OST rule combinations, the "speech has ended" rule fires later on every trial, so a perturbation meant to stop at the end of a word runs on into the next word

Not triggered by any blab design we inspected: in all 69 blab OST files, public and unpublished, every INTENSITY_FALL has a rule that refreshes the stored time earlier in the same trial. Affects any design in which the only rules before an INTENSITY_FALL are ELAPSED_TIME, blab's RMS-floor onset rule (INTENSITY_RATIO_ABOVE_THRESH_WITH_RMS_FLOOR) or a ratio rule.
Relevant blab studies safe as run. Safe as run: no blab OST, public or unpublished, has an INTENSITY_FALL preceded only by ELAPSED_TIME, the RMS-floor rule or a ratio rule (timeWrap uses the RMS-floor rule for onset, followed by a rise-and-hold rule).

What happens

An OST remembers the frame at which a level-based rule last ended (lastStatEnd). INTENSITY_FALL, the usual "speech has ended" rule, may only fire once more than its minimum time has passed since that frame. Audapter('reset') does not clear the stored frame, and neither does reloading the OST or AudapterIO('init'). Rules that record their own end (the rise-and-hold, slope and stretch rules, and INTENSITY_FALL itself) overwrite it in every trial, so a FALL after them works. ELAPSED_TIME and the RMS-floor rule never write it, and the ratio rules write it only in a state that a normally numbered rule never reaches. A FALL right after one of those rules therefore waits for a frame from an earlier trial: the detected offset can never come earlier than the previous trial's offset plus the minimum time. It creeps later on every trial, and jumps after any long trial. The perturbation that should stop at the offset stays on.

When it matters

Matters when a perturbation (or the next state) is timed from a detected offset, no rule before the FALL in the trial refreshes the stored time, and the same Audapter session runs more than one trial. It does not matter whether the earlier trials were perturbed: only how long their speech lasted. The first trial after MATLAB starts is always correct, so a single-trial pilot will not show it. Safe if any INTENSITY_RISE_HOLD, INTENSITY_RISE_HOLD_POS_SLOPE, slope, stretch, FALL or INTENSITY_AND_RATIO rule runs before the FALL in the same trial (as in blab's measureFormants, bedhead, seven/sever and pedigree OSTs), or if no rule is timed from an offset.

Example

A "shift the first word" design with catch trials: two-word trials, F1 raised 125 mel from the detected onset of word 1 to its detected offset, one catch trial in four, same OST all session and the PCF reloaded before every trial, with blab's standard settings. Onset is detected with blab's RMS-floor rule. The words are a real voice (a sustained /ɑ/ then /i/ of PVQD speaker SJ7001, cut to each trial's length). Trials 1 and 2 are correct. Trial 3 is a catch trial with a long first word (1.20 s); after it, trial 4's offset is found only at 1.45 s instead of 0.53 s, so word 2 (/i/) is heard with F1 raised (+80 % instead of +1 %; F1 253 → 454 Hz). After the second long catch trial the offset is never detected again (trials 7 and 8). The same session on synthetic vowels gives the same timings within 22 ms.

Settings used

Source
Hypothetical design: a plausible design, not one blab runs. Settings are blab's standard runner settings; the onset rule is blab's own (it is used in blab's timeWrap experiment, where the FALL follows a rise-and-hold rule and is safe).
Input
real voice (figure and main clips): PVQD speaker SJ7001 (female, no dysphonia), sustained /ɑ/ as word 1 cut to 0.30-1.20 s and sustained /i/ as word 2 (0.5 s), CC BY 4.0; synthetic (table, secondary clips, browser panel): /æ/-like and /e/-like vowels with the same timing
Trials
shift (word 1 0.30 s), shift (word 1 0.35 s), catch (word 1 1.20 s), shift (word 1 0.30 s), shift (word 1 0.40 s), catch (word 1 0.90 s), shift (word 1 0.35 s), shift (word 1 0.30 s)
Between trials
OST loaded once; PCF reloaded before every trial (the shift PCF, or the zero PCF on catch trials); reset() before every trial
Key parameters
bShift 1, bRatioShift 0, bMelShift 1, fb 3, fb3Gain 0.02
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.bShift1
p.bRatioShift0
p.bMelShift1
p.fb3
p.fb3Gain0.02

Sent with Audapter('setParam', …):

datapbbundled babble mtbabble48k.wav, loaded with free-speech get_noiseSource (479,230 samples)
OST file
rmsSlopeWin = 0.030000window for the level-slope estimate n = 33 rules follow0 INTENSITY_RATIO_ABOVE_THRESH_WITH_RMS_FLOOR 0.2 0.02 {}state 0 → 2: high-frequency energy ratio above 0.2 (ignored below level 0.0003) for 0.02 s; does not record its end time2 INTENSITY_FALL 0.01 0.02 {}state 2 → 3: level below 0.01 for 10 ms, once more than 0.02 s have passed since the last recorded state end3 OST_END NaN NaN {}state 3: final state n = 0no maxIOI timeouts
OST file, safe variant
rmsSlopeWin = 0.030000window for the level-slope estimate n = 33 rules follow0 INTENSITY_RISE_HOLD_POS_SLOPE 0.01 0.05 {}state 0 → 2: level rises above 0.01 and keeps rising for 0.05 s2 INTENSITY_FALL 0.01 0.02 {}state 2 → 3: level below 0.01 for 10 ms, once more than 0.02 s have passed since the last recorded state end3 OST_END NaN NaN {}state 3: final state n = 0no maxIOI timeouts
PCF file
00 time-warp sections 44 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0.0, 0, 0, 0state 0: no change1, 0.0, 0, 0, 0state 1: no change2, 0.0, 0, 125, 0state 2: formant shift 125 mel, F1 up3, 0.0, 0, 0, 0state 3: no change
PCF file on catch trials
00 time-warp sections 44 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0.0, 0, 0, 0state 0: no change1, 0.0, 0, 0, 0state 1: no change2, 0.0, 0, 0, 0state 2: no change3, 0.0, 0, 0, 0state 3: no change

The safe variant differs only in its first rule: 0 INTENSITY_RISE_HOLD_POS_SLOPE 0.01 0.05 {}, the onset rule of free-speech measureFormants.ost.

Trials 1–5 of the session, end to end. Expected: each trial as the first trial of a session (what the one-line fix gives). Observed: the same trials in one session. Settings as in the panel above; values under the words are F1 measured on the audio.

Expected what the design should do

Expected: what the design should doExpected (what the design should do). trial 1 (shift): F1 +125 mel +125 mel 0.18-0.52 s; F1 heard vs spoken +10 %, +1 %. trial 2 (shift): F1 +125 mel +125 mel 0.18-0.57 s; F1 heard vs spoken +10 %, +1 %. trial 3 (catch): F1 +125 mel no shift (catch); F1 heard vs spoken 0 %, +1 %. trial 4 (shift): F1 +125 mel +125 mel 0.18-0.52 s; F1 heard vs spoken +10 %, +1 %. trial 5 (shift): F1 +125 mel +125 mel 0.18-0.62 s; F1 heard vs spoken +11 %, +1 %.Trial1 · shift2 · shift3 · catch4 · shift5 · shiftSpeechtrial 1: word 1 0.15-0.45 strial 1: word 2 0.70-1.20 strial 2: word 1 0.15-0.50 strial 2: word 2 0.75-1.25 strial 3: word 1 0.15-1.35 sword 1trial 3: word 2 1.60-2.10 strial 4: word 1 0.15-0.45 strial 4: word 2 0.70-1.20 strial 5: word 1 0.15-0.55 strial 5: word 2 0.80-1.30 sF1 +125 meltrial 1: +125 mel from 0.18 to 0.52 strial 2: +125 mel from 0.18 to 0.57 sno shift (catch)trial 4: +125 mel from 0.18 to 0.52 strial 5: +125 mel from 0.18 to 0.62 sOffset detectedtrial 1: offset at 0.530 strial 2: offset at 0.580 strial 3: offset at 1.432 strial 4: offset at 0.530 strial 5: offset at 0.630 sF1 heard vs spoken+10 %+1 %+10 %+1 %0 %+1 %+10 %+1 %+11 %+1 %0123456789time in the session (s), trials drawn end to end; dashed rules = reset()

Observed what Audapter did

Observed: what Audapter didObserved (what Audapter did). trial 1 (shift): F1 +125 mel +125 mel 0.18-0.52 s; F1 heard vs spoken +10 %, +1 %. trial 2 (shift): F1 +125 mel +125 mel 0.18-0.57 s; F1 heard vs spoken +10 %, +1 %. trial 3 (catch): F1 +125 mel no shift (catch); F1 heard vs spoken +1 %, +1 %. trial 4 (shift): F1 +125 mel +125 mel 0.18-1.27 s; F1 heard vs spoken +10 %, +80 %. trial 5 (shift): F1 +125 mel +125 mel 0.18-1.37 s; F1 heard vs spoken +10 %, +80 %.Trial1 · shift2 · shift3 · catch4 · shift5 · shiftSpeechtrial 1: word 1 0.15-0.45 strial 1: word 2 0.70-1.20 strial 2: word 1 0.15-0.50 strial 2: word 2 0.75-1.25 strial 3: word 1 0.15-1.35 sword 1trial 3: word 2 1.60-2.10 strial 4: word 1 0.15-0.45 strial 4: word 2 0.70-1.20 strial 5: word 1 0.15-0.55 strial 5: word 2 0.80-1.30 sF1 +125 meltrial 1: +125 mel from 0.18 to 0.52 strial 2: +125 mel from 0.18 to 0.57 sno shift (catch)trial 4: +125 mel from 0.18 to 1.27 s+125 meltrial 5: +125 mel from 0.18 to 1.37 s+125 melOffset detectedtrial 1: offset at 0.530 strial 2: offset at 0.580 strial 3: offset at 1.432 strial 4: offset at 1.454 strial 5: offset at 1.476 sF1 heard vs spoken+10 %+1 %+10 %+1 %+1 %+1 %+10 %+80 %+10 %+80 %After the long catch trial 3, the shift runs on through word 2 in trials 4 and 50123456789time in the session (s), trials drawn end to end; dashed rules = reset()

Expected what the design should do

Expected: what the design should doExpected (what the design should do). trial 1 (shift): F1 +125 mel +125 mel 0.18-0.52 s; F1 heard vs spoken +10 %, +1 %. trial 2 (shift): F1 +125 mel +125 mel 0.18-0.57 s; F1 heard vs spoken +10 %, +1 %. trial 3 (catch): F1 +125 mel no shift (catch); F1 heard vs spoken 0 %, +1 %. trial 4 (shift): F1 +125 mel +125 mel 0.18-0.52 s; F1 heard vs spoken +10 %, +1 %. trial 5 (shift): F1 +125 mel +125 mel 0.18-0.62 s; F1 heard vs spoken +11 %, +1 %.Trial 1 · shiftword 1 0.15-0.45 sword 2 0.70-1.20 sword 2+125 mel from 0.18 to 0.52 soffset at 0.530 s+10 %+1 %Trial 2 · shift (after reset())word 1 0.15-0.50 sword 1word 2 0.75-1.25 sword 2+125 mel from 0.18 to 0.57 soffset at 0.580 s+10 %+1 %Trial 3 · catch (after reset())word 1 0.15-1.35 sword 1word 2 1.60-2.10 sword 2no shift (catch)offset at 1.432 s0 %+1 %Trial 4 · shift (after reset())word 1 0.15-0.45 sword 2 0.70-1.20 sword 2+125 mel from 0.18 to 0.52 soffset at 0.530 s+10 %+1 %Trial 5 · shift (after reset())word 1 0.15-0.55 sword 1word 2 0.80-1.30 sword 2+125 mel from 0.18 to 0.62 s+125 meloffset at 0.630 s+11 %+1 %00.511.52time in each trial (s)

Observed what Audapter did

Observed: what Audapter didObserved (what Audapter did). trial 1 (shift): F1 +125 mel +125 mel 0.18-0.52 s; F1 heard vs spoken +10 %, +1 %. trial 2 (shift): F1 +125 mel +125 mel 0.18-0.57 s; F1 heard vs spoken +10 %, +1 %. trial 3 (catch): F1 +125 mel no shift (catch); F1 heard vs spoken +1 %, +1 %. trial 4 (shift): F1 +125 mel +125 mel 0.18-1.27 s; F1 heard vs spoken +10 %, +80 %. trial 5 (shift): F1 +125 mel +125 mel 0.18-1.37 s; F1 heard vs spoken +10 %, +80 %.Trial 1 · shiftword 1 0.15-0.45 sword 2 0.70-1.20 sword 2+125 mel from 0.18 to 0.52 soffset at 0.530 s+10 %+1 %Trial 2 · shift (after reset())word 1 0.15-0.50 sword 1word 2 0.75-1.25 sword 2+125 mel from 0.18 to 0.57 soffset at 0.580 s+10 %+1 %Trial 3 · catch (after reset())word 1 0.15-1.35 sword 1word 2 1.60-2.10 sword 2no shift (catch)offset at 1.432 s+1 %+1 %Trial 4 · shift (after reset())word 1 0.15-0.45 sword 2 0.70-1.20 sword 2+125 mel from 0.18 to 1.27 s+125 meloffset at 1.454 s+10 %+80 %Trial 5 · shift (after reset())word 1 0.15-0.55 sword 1word 2 0.80-1.30 sword 2+125 mel from 0.18 to 1.37 s+125 meloffset at 1.476 s+10 %+80 %After the long catch trial 3, the shift runs onthrough word 2 in trials 4 and 500.511.52time in each trial (s)

Listen

  • Input, trial 4. real voice: /ɑ/ then /i/, same as trial 1 1.5 s

  • Expected, trial 4. word 1 raised (+10 %), word 2 unchanged (+1 %) 1.5 s

  • Observed, trial 4. word 1 raised (+10 %), word 2 also raised (+80 %) 1.5 s

  • A/B, trial 4. expected, then observed; listen to the second word 3.5 s

    No playhead: two clips back to back.

  • Synthetic, expected. the same trial on synthetic vowels: word 2 -1 % 1.5 s

    No playhead: the figure shows the real-voice session.

  • Synthetic, observed. word 2 +18 % (519 → 612 Hz) 1.5 s

    No playhead: the figure shows the real-voice session.

All clips share one playback gain. Real voice: PVQD (Walden 2022), speaker SJ7001, sustained /ɑ/ and /i/, cut and processed by Audapter (CC BY 4.0). Values beside each clip are F1 measured on that clip, per word.

Run it in your browser

This runs trials 1–5 of the example on the synthetic inputs (same timing, OST and PCFs; masking noise off, which does not affect detection) through the real Audapter core compiled to WebAssembly from the pinned commit, as released and with the one-line fix.

The change in the “fix-ost-f1” build: report/patches/ost-f1-reset-per-trial.patch
Audapter.cppostTab.reset(); /* audit fix OST-F1: OST_TAB::reset() was never called */ost.cpplastStatEnd = 0;    /* audit fix OST-F1: was never initialised */stretchSpanAccum = 0.0;/* audit fix OST-F1: state onset frames must not carry over from the previous trial */if (statOnsetIndices)memset(statOnsetIndices, 0, n * 4 * sizeof(int));   /* 4 = maxStatesPerLine in readFromFile */

How big, how often

real voiceTrial 4 (after the 1.20 s catch trial): offset detected1.45 s (expected 0.53 s)
real voiceTrial 4, word 2 (/i/): F1 heard vs spoken+80 % (expected +1 %; 253 → 454 Hz)
syntheticTrial 4 (after the 1.20 s catch trial): offset detected1.47 s (expected 0.54 s)
syntheticTrial 4, word 2: F1 heard vs spoken, measured on the audio+18 % (expected -1 %; 519 → 612 Hz)
syntheticPerturbed trials with a late or missing offset4 of 6; never detected in 2
syntheticTen identical trials in a row, no long trialoffset 22 ms later on each trial; covers word 2 from trial 9
syntheticOST reloaded before every trialno help: first offset 0.57 s, never detected in 2 perturbed trials
syntheticSame design with the rise-and-hold onset ruleevery offset 0.09 s or less after word 1; identical to the expected run

Data: data.json from the export script.

Cause

OST_TAB::reset() clears lastStatEnd, but nothing calls it: Audapter::reset() only sets stat = 0, and an OST reload frees and rebuilds the rule arrays but leaves lastStatEnd alone. INTENSITY_FALL fires only when data_counter - lastStatEnd exceeds its minimum, so a value left from an earlier trial blocks it. The RMS-floor rule, unlike the rise-and-hold rules, never sets lastStatEnd.

void OST_TAB::reset() {    stretchCnt = 0;    lastStatEnd = 0;    stretchSpanAccum = 0.0;}

The reset exists; nothing calls it.

dataFileCnt = 0; //SC(2012/10/19): OST online sentence trackingstat = 0; 

Audapter::reset() resets the OST state index but not the OST table.

if ((bIsGOET == 0) && ((data_counter - lastStatEnd) > minDurN)) {    stat_out = stat + 1;    statOnsetIndices[stat_out] = frame_counter;    lastStatEnd = data_counter;}

INTENSITY_FALL compares this trial's frame counter with the stored frame.

else if (t_mode == INTENSITY_RATIO_ABOVE_THRESH_WITH_RMS_FLOOR) { // (+2) RMS ratio above threshold, with minimum RMS throughout. prm1: rms_ratio threshold; prm2: minDurN    if (stat == t_stat0) {        // If RMS intensity isn't above 0.0003, ignore the ratio as it's too noisy at low intensities        if ((1. / rms_ratio > prm1[k]) && (rms_s >= 0.0003)) {            stat_out = stat + 1;            statOnsetIndices[stat_out] = frame_counter;            stretchCnt = 0;        }    }    else if (stat - t_stat0 == 1) {        minDurN = (int)floor(prm2[k] / frameDur);         if ((1. / rms_ratio > prm1[k]) && (rms_s >= 0.0003)) {            stretchCnt++;            if (stretchCnt > minDurN) {                stat_out = stat + 1;                statOnsetIndices[stat_out] = frame_counter;            }        }        else {            stat_out = stat - 1;        }    } }

The RMS-floor rule advances the state but never writes lastStatEnd (compare lines 393 and 420 in the rise-and-hold rules).

Present since at least Audapter 2.1.5.

Safe pattern and fix

Safe pattern: put INTENSITY_FALL only after a rule that records its end (INTENSITY_RISE_HOLD or INTENSITY_RISE_HOLD_POS_SLOPE, as blab's own OSTs do); if you need the RMS-floor onset, follow it with a short rise-and-hold rule before the FALL. Reloading the OST or re-running AudapterIO('init') does not help. Fix: call ostTab.reset() from Audapter::reset() (one line, report/patches/ost-f1-reset-per-trial.patch). With it, and the OST-F2 fix, every trial behaves like the first trial of a session (proven on a model of the OST code). Check past data: in ost_stat, compare the frame at which the offset state starts across trials; a creep of about the FALL minimum per trial, or a jump after a long trial, is this bug.

How we know

  • H report export (mixed session: report_ost_f1.m
  • H realistic mixed-design session: exp_mixed.m SCEN=S5 EXP-9: shift runs 0.84 s into word 2 after a long catch trial
  • H harness test: t_ost.m FAIL “OST F1: trial B (after A) offset detected”: burst ends 0.40 s, state 2 at 1.678 s
  • D standalone driver of ost.cpp: scratch/ost-pcf/t/drv.cpp fall_leak offset at frame 200 fired at frame 815
  • F Lean 4 proof: formal/lean/Ost.lean with both fixes

Reproduce cd audit/harness && ./run-oct.sh exp_mixed.m

A PCF loaded earlier in the session stays loaded through AudapterIO('init') and overrides a later field-mode experiment: shift trials are not shifted, or no-shift trials are

blab's runners are protected: every blab field-mode runner we inspected clears the OST and PCF before AudapterIO('init'). Affects any runner that leaves out those two lines, for example a copy of a runner or a new experiment run in the same MATLAB session after an OST/PCF block.
Relevant blab studies safe as run. Safe as run: every blab field-mode runner clears the OST and PCF before AudapterIO('init').

What happens

An OST or PCF stays loaded until Audapter('ost', '', 0) and Audapter('pcf', '', 0) clear it. Audapter('reset') and AudapterIO('init', p) do not. While a PCF is loaded, it decides every formant shift, and the perturbation field set with pertAmp/pertPhi is ignored. Every blab formant session starts with the measureFormants calibration block, which loads an OST and an all-zero PCF, so a field-mode experiment that follows it without clearing them delivers no shift at all.

When it matters

Matters when a field-mode block follows any block that loaded an OST/PCF in the same MATLAB session. With the calibration's all-zero PCF, the whole experiment is silently unperturbed; after a block with a nonzero PCF (for example a bedhead hold block), "noShift" trials are perturbed at that block's strength. Safe if the runner calls Audapter('ost', '', 0); Audapter('pcf', '', 0); before AudapterIO('init', p), as blab's attentionComp, coAdapt, uhdapter, VSA and template runners do. These two lines are load-bearing.

Example

The measureFormants calibration (two trials), then an attentionComp-style field experiment: 125 mel F1 shift set per trial with pertAmp/pertPhi, noShift trials interleaved, fb 3 with the bundled babble, a real sentence on every trial (CMU ARCTIC clb "I had faith in them."). With the clear lines, trial 3 is heard with F1 raised (+23 %). Without them, trial 3 is heard unchanged (-1 %) and the log shows no shifted frame (0.000 s instead of 1.174 s). A synthetic vowel gives the same result (+20 % vs +1 %).

Settings used

Source
blab's attentionComp experiment (field mode, per-trial pertAmp/pertPhi, 125 mel, fb 3 with the bundled babble at 0.02), after free-speech's measureFormants calibration block; the only change is removing the runner's two clear lines.
Input
real: CMU ARCTIC clb a0030 "I had faith in them." (female) on every trial; synthetic: a 0.4 s /ɛ/-like vowel
Trials
calibration, calibration, shift, noShift, shift
Between trials
calibration block with measureFormants.ost/.pcf (bShift 0); then AudapterIO('init', p) and field-mode pertAmp/pertPhi set before every trial; reset() before every trial. Expected: the runner clears the OST and PCF first; observed: it does not
Key parameters
bShift 1, bRatioShift 0, bMelShift 1, fb 3, fb3Gain 0.02
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.bShift1
p.bRatioShift0
p.bMelShift1
p.fb3
p.fb3Gain0.02

Sent with Audapter('setParam', …):

pertAmp125 (all 257 values) on shift trials, 0 on noShift trials
pertPhi0 (all 257 values): F1 up
datapbbundled babble mtbabble48k.wav, loaded with free-speech get_noiseSource
OST file
# Online status tracking (OST) configuration filermsSlopeWin = 0.050000window for the level-slope estimate # Main section: Heuristic rules for trackingn = 33 rules follow0 INTENSITY_RISE_HOLD_POS_SLOPE 0.01 0.05 {} # Detect the onset the vowelstate 0 → 2: level rises above 0.01 and keeps rising for 0.05 s2 INTENSITY_FALL   0.03 0.01 {} # Detect the offset of the vowelstate 2 → 3: level below 0.03 for 10 ms, once more than 0.01 s have passed since the last recorded state end4 OST_END NaN NaN {} state 4: final state n = 0no maxIOI timeouts
PCF file
# Section 1 (Time warping): (ostStat_initial), tBegin, rate1, dur1, durHold, rate2 00 time-warp sections # Section 2: stat pitchShift(st) gainShift(dB) fmtPertAmp fmtPertPhi(rad)55 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0.0, 0, 0, 0state 0: no change1, 0.0, 0, 0, 0state 1: no change2, 0.0, 0, 0, 0state 2: no change3, 0.0, 0, 0, 0state 3: no change4, 0.0, 0, 0, 0state 4: no change

The OST and PCF shown are measureFormants.ost/.pcf from the calibration block, which stay loaded in the observed session. The Playground link loads the field experiment only.

One session: two calibration trials, then the experiment block. Expected: the runner clears the OST and PCF before AudapterIO('init'). Observed: it does not. Values are F1 measured on the audio.

Expected what the design should do

Expected: what the design should doExpected (what the design should do). trial 1 (calibration): F1 +125 mel none (calibration); F1 heard vs spoken 0 %. trial 2 (calibration): F1 +125 mel none (calibration); F1 heard vs spoken 0 %. trial 3 (shift): F1 +125 mel +125 mel 0.20-1.51 s; F1 heard vs spoken +23 %. trial 4 (noShift): F1 +125 mel none; F1 heard vs spoken 0 %. trial 5 (shift): F1 +125 mel +125 mel 0.20-1.51 s; F1 heard vs spoken +22 %.Trial1 · calibration2 · calibration3 · shift4 · noShift5 · shiftSpeechtrial 1: “I had faith in them.” 0.10-1.69 strial 2: “I had faith in them.” 0.10-1.69 strial 3: “I had faith in them.” 0.10-1.69 strial 4: “I had faith in them.” 0.10-1.69 strial 5: “I had faith in them.” 0.10-1.69 sF1 +125 melnone (calibration)none (calibration)trial 3: +125 mel from 0.20 to 1.51 s+125 melnonetrial 5: +125 mel from 0.20 to 1.51 s+125 melF1 heard vs spoken0 %0 %+23 %0 %+22 %runner clears OST and PCF, then AudapterIO('init')0246810time in the session (s), trials drawn end to end; dashed rules = reset()

Observed what Audapter did

Observed: what Audapter didObserved (what Audapter did). trial 1 (calibration): F1 +125 mel none (calibration); F1 heard vs spoken 0 %. trial 2 (calibration): F1 +125 mel none (calibration); F1 heard vs spoken 0 %. trial 3 (shift): F1 +125 mel none; F1 heard vs spoken -1 %. trial 4 (noShift): F1 +125 mel none; F1 heard vs spoken 0 %. trial 5 (shift): F1 +125 mel none; F1 heard vs spoken -1 %.Trial1 · calibration2 · calibration3 · shift4 · noShift5 · shiftSpeechtrial 1: “I had faith in them.” 0.10-1.69 strial 2: “I had faith in them.” 0.10-1.69 strial 3: “I had faith in them.” 0.10-1.69 strial 4: “I had faith in them.” 0.10-1.69 strial 5: “I had faith in them.” 0.10-1.69 sF1 +125 melnone (calibration)none (calibration)nonenonenoneF1 heard vs spoken0 %0 %-1 %0 %-1 %AudapterIO('init') only: the clear lines are missingThe measureFormants PCF is still loaded and overrides the field: no shift on eithershift trial0246810time in the session (s), trials drawn end to end; dashed rules = reset()

Expected what the design should do

Expected: what the design should doExpected (what the design should do). trial 1 (calibration): F1 +125 mel none (calibration); F1 heard vs spoken 0 %. trial 2 (calibration): F1 +125 mel none (calibration); F1 heard vs spoken 0 %. trial 3 (shift): F1 +125 mel +125 mel 0.20-1.51 s; F1 heard vs spoken +23 %. trial 4 (noShift): F1 +125 mel none; F1 heard vs spoken 0 %. trial 5 (shift): F1 +125 mel +125 mel 0.20-1.51 s; F1 heard vs spoken +22 %.Trial 1 · calibration“I had faith in them.” 0.10-1.69 s“I had faith in them.”none (calibration)0 %Trial 2 · calibration (after reset())“I had faith in them.” 0.10-1.69 s“I had faith in them.”none (calibration)0 %Trial 3 · shift (after runner clears OST and PCF, then AudapterIO('init'))“I had faith in them.” 0.10-1.69 s“I had faith in them.”+125 mel from 0.20 to 1.51 s+125 mel+23 %Trial 4 · noShift (after reset())“I had faith in them.” 0.10-1.69 s“I had faith in them.”none0 %Trial 5 · shift (after reset())“I had faith in them.” 0.10-1.69 s“I had faith in them.”+125 mel from 0.20 to 1.51 s+125 mel+22 %00.511.52time in each trial (s)

Observed what Audapter did

Observed: what Audapter didObserved (what Audapter did). trial 1 (calibration): F1 +125 mel none (calibration); F1 heard vs spoken 0 %. trial 2 (calibration): F1 +125 mel none (calibration); F1 heard vs spoken 0 %. trial 3 (shift): F1 +125 mel none; F1 heard vs spoken -1 %. trial 4 (noShift): F1 +125 mel none; F1 heard vs spoken 0 %. trial 5 (shift): F1 +125 mel none; F1 heard vs spoken -1 %.Trial 1 · calibration“I had faith in them.” 0.10-1.69 s“I had faith in them.”none (calibration)0 %Trial 2 · calibration (after reset())“I had faith in them.” 0.10-1.69 s“I had faith in them.”none (calibration)0 %Trial 3 · shift (after AudapterIO('init') only: the clear lines are missing)“I had faith in them.” 0.10-1.69 s“I had faith in them.”none-1 %Trial 4 · noShift (after reset())“I had faith in them.” 0.10-1.69 s“I had faith in them.”none0 %Trial 5 · shift (after reset())“I had faith in them.” 0.10-1.69 s“I had faith in them.”none-1 %The measureFormants PCF is still loaded andoverrides the field: no shift on either shifttrial00.511.52time in each trial (s)

Listen

  • Input, trial 3. real sentence, the same on every trial 2.0 s

  • Expected, trial 3. F1 raised (+23 %) 2.0 s

  • Observed, trial 3. not shifted (-1 %) 2.0 s

  • A/B, trial 3. expected, then observed 4.5 s

    No playhead: two clips back to back.

  • Synthetic, observed. a synthetic vowel, not shifted (+1 %; expected +20 %) 1.4 s

    No playhead: the figure shows the real-sentence session.

All clips share one playback gain. Real voice: CMU ARCTIC (Kominek and Black 2003), speaker clb, "I had faith in them.", processed by Audapter; © 2003 Carnegie Mellon University (licence notice). Values: median per-frame F1 ratio of output to input, measured on the audio.

How big, how often

real voiceShift trial, F1 heard vs spoken: with / without the clear lines+23 % / -1 %
syntheticShift trial, F1 heard vs spoken: with / without the clear lines+20 % / +1 %
syntheticLogged shifted time on a shift trial: with / without0.442 / 0.000 s
syntheticAfter a bedhead hold block: a noShift trial without / with the clear linesshifted 0.442 s at +125 mel / 0.000 s

Data: data.json from the export script.

Cause

With a PCF loaded (pertCfg.n > 0), the formant branch takes the on/off decision, amplitude and phase from the PCF row of the current OST state. Only Audapter('pcf', '', 0) unloads a PCF, and AudapterIO('init') never calls it.

if (p.bShift) {    if (pertCfg.n == 0) {        /*during_trans = false;*/    }    else {        during_trans = (pertCfg.fmtPertAmp[stat] != 0);    }

With a PCF loaded, the PCF row decides whether to shift at all; measureFormants.pcf's rows are all zero, so never.

/* That using ost and pcf files overrides the perturbation field     specified with pertF2, pertAmp, pertPhi. */if (pertCfg.n > 0) {    mamp = pertCfg.fmtPertAmp[stat];            mphi = pertCfg.fmtPertPhi[stat];}

...and supplies amplitude and phase, as the comment intends for OST/PCF experiments.

case 'pcf'    if nargin == 2        Audapter(9, params);    elseif nargin == 4        Audapter(9, params, varargin{1});    else        error('%s: Invalid syntax under mode: %s', mfilename, action);    end

Only this action loads or clears a PCF; the 'init' case (lines 8–415) never calls it.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Safe pattern: keep Audapter('ost', '', 0); Audapter('pcf', '', 0); at the top of every field-mode runner, before AudapterIO('init', p). Fix: unload the OST and PCF in AudapterIO('init') unless the caller asks to keep them, or warn when a PCF is loaded while field parameters are set. Check past data: field-mode trials whose sfmts equal fmts on every frame were not shifted.

How we know

  • H report export (calibration block: report_coord1.m
  • H realistic session with blab's files: exp_mixed.m SCEN=S3 EXP-7: 0.000 s vs 0.442 s shifted; noShift trials +125 mel after a bedhead block
  • H harness test: t_pcf_persist.m FAIL as expected: shifted frames fresh 435, after an earlier PCF experiment 0

Reproduce cd audit/harness && ./run-oct.sh report_coord1.m

Pitch catch trials made by clearing the PCF can be heard at the full pitch shift, when the trial before ended while shifted

Not triggered by any blab design we inspected: blab's pitch experiments (pitchAdaptPD, pitchComp) re-run AudapterIO('init', p) before every trial, which restores the pitch setting. Affects pitch designs that make catch trials with Audapter('pcf', '', 0) and do not re-run init.
Relevant blab studies safe as run. Safe as run: blab's pitch experiments (pitchAdaptPD, pitchComp) re-run AudapterIO('init', p) before every trial.

What happens

While a PCF is loaded, Audapter writes the PCF's pitch shift for the current OST state into the user parameter pitchShiftRatio on every frame. Clearing the PCF with Audapter('pcf', '', 0) leaves the last value there, and with no PCF the phase vocoder uses pitchShiftRatio for the whole trial. If the previous trial ended in a shifted state (the participant was still voicing when the trial stopped), every following "unperturbed" trial is shifted by the full amount, and data.params.pitchShiftRatio logs the shifted value.

When it matters

Matters for pitch-perturbation designs with interleaved catch or control trials made by clearing the PCF, when a perturbed trial can end before the OST returns to an unshifted state: sustained-vowel designs, or trials cut off mid-word. If the perturbed trial ends after the shift is switched off, the catch trial is clean. Safe if catch trials load an all-zero PCF, or AudapterIO('init', p) runs before every trial (it re-sends pitchShiftRatio), or the script sets pitchShiftRatio after clearing the PCF.

Example

A sustained-vowel pitch design: +2 semitones from the detected voice onset until the level falls, phase vocoder, catch trials interleaved, on a real sustained /a/ (PVQD speaker SJ7001). The speaker is still voicing when each perturbed trial stops. With catch trials made by loading an all-zero PCF, catch trial 2 is heard at 0 cents. Made with Audapter('pcf', '', 0), it is heard at +198 cents, the whole perturbation, and data.params.pitchShiftRatio logs 1.1225. A synthetic vowel gives the same (0 cents vs +200 cents).

Settings used

Source
Hypothetical design: a plausible pitch design, not one blab runs. blab's defaults (female preset, 16 kHz, frameLen 32) with the phase vocoder on.
Input
real: PVQD SJ7001 sustained /a/ (female, CC BY 4.0), 1.2 s cut mid-phonation on shift trials, 0.8 s on catch trials; synthetic: the same timing
Trials
shift, catch, shift, catch
Between trials
OST loaded once; before every trial the +2 st PCF is loaded on shift trials; on catch trials the expected session loads an all-zero PCF and the observed session clears the PCF with Audapter('pcf', '', 0); reset() before every trial
Key parameters
bPitchShift 1
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.bPitchShift1
OST file
rmsSlopeWin = 0.030000window for the level-slope estimate n = 33 rules follow0 INTENSITY_RISE_HOLD 0.02 0.02 {}state 0 → 2: level rises above 0.02 (next state) and stays above it for 0.02 s2 INTENSITY_FALL 0.01 0.02 {}state 2 → 3: level below 0.01 for 10 ms, once more than 0.02 s have passed since the last recorded state end3 OST_END NaN NaN {}state 3: final state n = 0no maxIOI timeouts
PCF file
00 time-warp sections 44 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0.0, 0, 0, 0state 0: no change1, 0.0, 0, 0, 0state 1: no change2, 2.0, 0, 0, 0state 2: pitch +2 st3, 0.0, 0, 0, 0state 3: no change
PCF file on catch trials
00 time-warp sections 44 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0.0, 0, 0, 0state 0: no change1, 0.0, 0, 0, 0state 1: no change2, 0.0, 0, 0, 0state 2: no change3, 0.0, 0, 0, 0state 3: no change

One session of four trials. Expected: catch trials made with an all-zero PCF (the safe pattern). Observed: catch trials made by clearing the PCF. Values are F0 measured on the audio, in cents re the spoken F0.

Expected what the design should do

Expected: what the design should doExpected (what the design should do). trial 1 (shift): Pitch shift +2 st 0.35-1.50 s; F0 heard vs spoken +198 cents. trial 2 (catch): Pitch shift none (catch); F0 heard vs spoken 0 cents. trial 3 (shift): Pitch shift +2 st 0.35-1.50 s; F0 heard vs spoken +198 cents. trial 4 (catch): Pitch shift none (catch); F0 heard vs spoken 0 cents.Trial1 · shift2 · catch3 · shift4 · catchSpeechtrial 1: real /a/, voicing at stop 0.30-1.50 strial 2: real /a/ 0.30-1.10 sreal /a/trial 3: real /a/, voicing at stop 0.30-1.50 strial 4: real /a/ 0.30-1.10 sreal /a/Pitch shifttrial 1: +2 st from 0.35 to 1.50 s+2 stnone (catch)trial 3: +2 st from 0.35 to 1.50 s+2 stnone (catch)F0 heard vs spoken+198 cents0 cents+198 cents0 centscatch trials: an all-zero PCF is loaded01234567time in the session (s), trials drawn end to end; dashed rules = reset()

Observed what Audapter did

Observed: what Audapter didObserved (what Audapter did). trial 1 (shift): Pitch shift +2 st 0.35-1.50 s; F0 heard vs spoken +198 cents. trial 2 (catch): Pitch shift +2 st left over 0.00-1.50 s; F0 heard vs spoken +198 cents. trial 3 (shift): Pitch shift +2 st 0.35-1.50 s; F0 heard vs spoken +198 cents. trial 4 (catch): Pitch shift +2 st left over 0.00-1.50 s; F0 heard vs spoken +198 cents.Trial1 · shift2 · catch3 · shift4 · catchSpeechtrial 1: real /a/, voicing at stop 0.30-1.50 strial 2: real /a/ 0.30-1.10 sreal /a/trial 3: real /a/, voicing at stop 0.30-1.50 strial 4: real /a/ 0.30-1.10 sreal /a/Pitch shifttrial 1: +2 st from 0.35 to 1.50 s+2 sttrial 2: +2 st left over from 0.00 to 1.50 s+2 st left overtrial 3: +2 st from 0.35 to 1.50 s+2 sttrial 4: +2 st left over from 0.00 to 1.50 s+2 st left overF0 heard vs spoken+198 cents+198 cents+198 cents+198 centscatch trials: Audapter('pcf', '', 0) clears the PCFEvery catch trial is heard 2 semitones up01234567time in the session (s), trials drawn end to end; dashed rules = reset()

Expected what the design should do

Expected: what the design should doExpected (what the design should do). trial 1 (shift): Pitch shift +2 st 0.35-1.50 s; F0 heard vs spoken +198 cents. trial 2 (catch): Pitch shift none (catch); F0 heard vs spoken 0 cents. trial 3 (shift): Pitch shift +2 st 0.35-1.50 s; F0 heard vs spoken +198 cents. trial 4 (catch): Pitch shift none (catch); F0 heard vs spoken 0 cents.Trial 1 · shiftreal /a/, voicing at stop 0.30-1.50 sreal /a/, voicing at stop+2 st from 0.35 to 1.50 s+2 st+198 centsTrial 2 · catch (after catch trials: an all-zero PCF is loaded)real /a/ 0.30-1.10 sreal /a/none (catch)0 centsTrial 3 · shift (after reset())real /a/, voicing at stop 0.30-1.50 sreal /a/, voicing at stop+2 st from 0.35 to 1.50 s+2 st+198 centsTrial 4 · catch (after reset())real /a/ 0.30-1.10 sreal /a/none (catch)0 cents00.511.5time in each trial (s)

Observed what Audapter did

Observed: what Audapter didObserved (what Audapter did). trial 1 (shift): Pitch shift +2 st 0.35-1.50 s; F0 heard vs spoken +198 cents. trial 2 (catch): Pitch shift +2 st left over 0.00-1.50 s; F0 heard vs spoken +198 cents. trial 3 (shift): Pitch shift +2 st 0.35-1.50 s; F0 heard vs spoken +198 cents. trial 4 (catch): Pitch shift +2 st left over 0.00-1.50 s; F0 heard vs spoken +198 cents.Trial 1 · shiftreal /a/, voicing at stop 0.30-1.50 sreal /a/, voicing at stop+2 st from 0.35 to 1.50 s+2 st+198 centsTrial 2 · catch (after catch trials: Audapter('pcf', '', 0) clears the PCF)real /a/ 0.30-1.10 sreal /a/+2 st left over from 0.00 to 1.50 s+2 st left over+198 centsTrial 3 · shift (after reset())real /a/, voicing at stop 0.30-1.50 sreal /a/, voicing at stop+2 st from 0.35 to 1.50 s+2 st+198 centsTrial 4 · catch (after reset())real /a/ 0.30-1.10 sreal /a/+2 st left over from 0.00 to 1.50 s+2 st left over+198 centsEvery catch trial is heard 2 semitones up00.511.5time in each trial (s)

Listen

  • Input, trial 2. real sustained /a/ (catch trial) 1.5 s

  • Expected, trial 2. not shifted (0 cents) 1.5 s

  • Observed, trial 2. shifted up (+198 cents) 1.5 s

  • A/B, trial 2. expected, then observed 3.4 s

    No playhead: two clips back to back.

  • Synthetic, observed. a synthetic vowel, shifted (+200 cents; expected 0 cents) 1.5 s

    No playhead: the figure shows the real-voice session.

All clips share one playback gain. Real voice: PVQD (Walden 2022), speaker SJ7001, sustained /a/, cut and processed by Audapter (CC BY 4.0). Values: F0 measured on the clip, in cents re the spoken F0.

How big, how often

real voiceCatch trial after a trial that ended while shifted: F0 heard+198 cents (all-zero PCF: 0 cents)
syntheticCatch trial after a trial that ended while shifted: F0 heard+200 cents (all-zero PCF: 0 cents)
syntheticLogged data.params.pitchShiftRatio on that catch trial1.1225 (all-zero PCF: 1.0000)
syntheticSame, with AudapterIO('init', p) before every trialcatch trials within 0 cents of the spoken F0
syntheticCatch trial after a trial that ended after the shift was switched offclean (EXP-8)

Data: data.json from the export script.

Cause

With a PCF loaded, each frame overwrites p.pitchShiftRatio[0] with the current state's pitch shift. After Audapter('pcf', '', 0), pertCfg.n is 0, the overwrite stops, and the vocoder keeps using the last value written. data.params reports the overwritten value.

if (pertCfg.n > 0) {    if (stat < pertCfg.n) {        p.pitchShiftRatio[0] = pow(2.0, pertCfg.pitchShift[stat] / 12.0);        intShiftRatio = pow(10, pertCfg.intShift[stat] / 20.0);    }

The PCF writes into the user parameter; nothing restores it.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Safe pattern: make catch trials by loading an all-zero PCF, or re-run AudapterIO('init', p) before every trial as blab's pitch experiments do. Fix: keep the PCF's ratio in a separate variable, so that clearing the PCF returns to the user's pitchShiftRatio. Check past data: catch trials whose data.params.pitchShiftRatio is not 1 were shifted.

How we know

Reproduce cd audit/harness && ./run-oct.sh report_ost_f5.m

After an OST maxIOI timeout, the next ELAPSED_TIME lasts one frame, and without reloading the OST the timeout fires 0.2 s later in every trial

Not triggered: no blab OST we inspected has a maxIOI (timeout) section. Affects OSTs with a timeout that are loaded once for many trials.
Relevant blab studies n.a.. Not applicable: no blab OST we inspected has a maxIOI section.

What happens

When a maxIOI rule moves the OST to a later state, Audapter records the time of that jump against the wrong state. An ELAPSED_TIME rule in the new state then fires on the next frame instead of after its duration. The wrong record also shifts the reference that later timeouts are measured from, so if the OST is not reloaded between trials, each trial's timeout fires later than the one before.

When it matters

Affects OST files with a maxIOI section, on trials where the timeout actually fires, typically quiet or unusual productions where onset detection fails. The one-frame ELAPSED_TIME happens on every such trial. The growing delay happens only when the OST is loaded once and several trials run with reset() between them; reloading the OST before each trial removes the delay but not the one-frame ELAPSED_TIME.

Example

A soft talker: a real sustained /a/ (PVQD speaker SJ7001) that never reaches the onset threshold, so the 0.2 s timeout must start the sequence, and F1 is raised 30 % from 0.1 s after the timeout. Intended: the shift starts at 0.30 s in every trial. With the OST loaded once and reset() between trials, it starts at 0.20, 0.41 and 0.61 s in trials 1–3: the ELAPSED_TIME state lasts one frame instead of 0.1 s, and every trial's timeout comes 0.2 s later than the last.

Settings used

Source
Hypothetical design: a plausible design, not one blab runs; blab's default settings (getAudapterDefaultParams) unless listed.
Input
real: PVQD SJ7001 sustained /a/ (female, CC BY 4.0), played softly so it never reaches the onset threshold; synthetic: a soft /a/ with the same property
Trials
trial 1, trial 2, trial 3
Between trials
OST and PCF loaded once; reset() before every trial
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.rmsThresh0.005
p.bShift1
p.pertF1257 values, 0 to 5000
p.pertF2257 values, 0 to 5000
OST file
rmsSlopeWin = 0.030000window for the level-slope estimate n = 33 rules follow0 INTENSITY_RISE_HOLD 0.02 0.02 {}state 0 → 2: level rises above 0.02 (next state) and stays above it for 0.02 s2 ELAPSED_TIME 0.1 NaN {}state 2 → 3: moves on 0.1 s after this state began; does not record its end time3 OST_END NaN NaN {}state 3: final state n = 11 maxIOI (timeout) entries0 0.2 2
PCF file
00 time-warp sections 44 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0, 0, 0, 0state 0: no change1, 0, 0, 0, 0state 1: no change2, 0, 0, 0, 0state 2: no change3, 0, 0, 0.3, 0state 3: formants ×1.3 (F1)

One session of three trials, end to end. Expected: the intended timing. Observed: the OST loaded once. Values: F1 heard vs spoken, measured on the audio in 0.21–0.30 s and 0.42–0.60 s of each trial.

Expected the intended timing on every trial: timeout at 0.2 s, shift 0.1 s later

Expected: the intended timing on every trial: timeout at 0.2 s, shift 0.1 s laterExpected (the intended timing on every trial: timeout at 0.2 s, shift 0.1 s later). trial 1 (soft /a/): Perturbation F1 +30 % 0.30-1.20 s. trial 2 (soft /a/): Perturbation F1 +30 % 0.30-1.20 s. trial 3 (soft /a/): Perturbation F1 +30 % 0.30-1.20 s.Trial1 · soft /a/2 · soft /a/3 · soft /a/Speechtrial 1: soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)trial 2: soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)trial 3: soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)Perturbationtrial 1: F1 +30 % from 0.30 to 1.20 sF1 +30 %trial 2: F1 +30 % from 0.30 to 1.20 sF1 +30 %trial 3: F1 +30 % from 0.30 to 1.20 sF1 +30 %Timeout firestrial 1: timeout at 0.202 strial 2: timeout at 0.202 strial 3: timeout at 0.202 sF1 heard vs spoken0 %+12 %0 %+12 %0 %+12 %01234time in the session (s), trials drawn end to end; dashed rules = reset()

Observed the OST loaded once, reset() between trials

Observed: the OST loaded once, reset() between trialsObserved (the OST loaded once, reset() between trials). trial 1 (soft /a/): Perturbation F1 +30 % 0.20-1.20 s. trial 2 (soft /a/): Perturbation F1 +30 % 0.41-1.20 s. trial 3 (soft /a/): Perturbation F1 +30 % 0.61-1.20 s.Trial1 · soft /a/2 · soft /a/3 · soft /a/Speechtrial 1: soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)trial 2: soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)trial 3: soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)Perturbationtrial 1: F1 +30 % from 0.20 to 1.20 sF1 +30 %trial 2: F1 +30 % from 0.41 to 1.20 sF1 +30 %trial 3: F1 +30 % from 0.61 to 1.20 sF1 +30 %Timeout firestrial 1: timeout at 0.202 strial 2: timeout at 0.404 strial 3: timeout at 0.606 sF1 heard vs spoken+18 %+12 %0 %+12 %0 %0 %Trial 1 starts the shift 0.1 s early; after that the timeout drifts 0.2 slater on every trial01234time in the session (s), trials drawn end to end; dashed rules = reset()

Expected the intended timing on every trial: timeout at 0.2 s, shift 0.1 s later

Expected: the intended timing on every trial: timeout at 0.2 s, shift 0.1 s laterExpected (the intended timing on every trial: timeout at 0.2 s, shift 0.1 s later). trial 1 (soft /a/): Perturbation F1 +30 % 0.30-1.20 s. trial 2 (soft /a/): Perturbation F1 +30 % 0.30-1.20 s. trial 3 (soft /a/): Perturbation F1 +30 % 0.30-1.20 s.Trial 1 · soft /a/soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)F1 +30 % from 0.30 to 1.20 sF1 +30 %timeout at 0.202 s0 %+12 %Trial 2 · soft /a/ (after reset())soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)F1 +30 % from 0.30 to 1.20 sF1 +30 %timeout at 0.202 s0 %+12 %Trial 3 · soft /a/ (after reset())soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)F1 +30 % from 0.30 to 1.20 sF1 +30 %timeout at 0.202 s0 %+12 %00.51time in each trial (s)

Observed the OST loaded once, reset() between trials

Observed: the OST loaded once, reset() between trialsObserved (the OST loaded once, reset() between trials). trial 1 (soft /a/): Perturbation F1 +30 % 0.20-1.20 s. trial 2 (soft /a/): Perturbation F1 +30 % 0.41-1.20 s. trial 3 (soft /a/): Perturbation F1 +30 % 0.61-1.20 s.Trial 1 · soft /a/soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)F1 +30 % from 0.20 to 1.20 sF1 +30 %timeout at 0.202 s+18 %+12 %Trial 2 · soft /a/ (after reset())soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)F1 +30 % from 0.41 to 1.20 sF1 +30 %timeout at 0.404 s0 %+12 %Trial 3 · soft /a/ (after reset())soft /a/ (real voice) 0.05-1.20 ssoft /a/ (real voice)F1 +30 % from 0.61 to 1.20 sF1 +30 %timeout at 0.606 s0 %0 %Trial 1 starts the shift 0.1 s early; after thatthe timeout drifts 0.2 s later on every trial00.51time in each trial (s)

Listen

  • Input, trial 1. a soft real /a/, the same on every trial 1.4 s

  • Expected, trial 1. F1 raised from 0.30 s (0 % at 0.21–0.30 s, +12 % at 0.42–0.60 s) 1.4 s

  • Observed, trial 1. F1 raised already from 0.20 s (+18 %, then +12 %) 1.4 s

  • Observed, trial 3. F1 raised only from 0.61 s (0 %, then 0 %) 1.4 s

  • A/B, trial 3. expected, then observed 3.2 s

    No playhead: two clips back to back.

  • Synthetic, trial 1. the same design on a synthetic soft /a/ 1.4 s

    No playhead: the synthetic run is not drawn in the figure.

All clips share one playback gain. Real voice: PVQD (Walden 2022), speaker SJ7001, sustained /a/, played softly, cut and processed by Audapter (CC BY 4.0).

Run it in your browser

This runs one session of three trials, with the OST loaded once, through the real Audapter C++ core compiled to WebAssembly from the pinned commit: once with the current code and once with the one-line fix below.

The change in the “fix-ost-f2” build: report/patches/ost-f2-maxioi-onset-index.patch
ost.cppstatOnsetIndices[stat] = frame_counter;statOnsetIndices[j] = frame_counter;    /* audit fix OST-F2: was [stat] */

How big, how often

real voiceShift onset, trials 1 / 2 / 3 (intended 0.30 s)0.20 / 0.41 / 0.61 s
syntheticTimeout, trials 1 / 2 / 3, OST loaded once0.20 / 0.40 / 0.61 s (intended 0.20 s)
syntheticTime in state 2 (ELAPSED_TIME 0.1 s)2 ms in every trial
syntheticTimeout with the OST reloaded before the trial0.20 s; state 2 still lasts 2 ms
syntheticF1 at 0.22–0.28 s, trial 1 vs intended997 vs 789 Hz
real speechTimeout, trials 1–5, five quiet speakers, OST loaded once0.203 / 0.405 / 0.607 / 0.809 / 1.011 s
real speechTimeout with the OST reloaded0.203 s in every trial

Data: data.json from the export script.

Cause

When a maxIOI rule fires, the loop that should record the onset of each state it jumps over writes statOnsetIndices[stat], the state being left, instead of statOnsetIndices[j]. ELAPSED_TIME in the new state then measures from a stale onset and fires at once. State 0's onset is overwritten with the timeout frame and never cleared, so the next trial's timeout is measured from it.

if (maxIOICfg.n > 0) {    for (i = 0; i < maxIOICfg.n; i++) {        if ((stat >= maxIOICfg.stat0[i]) && (stat < maxIOICfg.stat1[i])) {            if ((frame_counter - statOnsetIndices[maxIOICfg.stat0[i]]) * frameDur > maxIOICfg.maxInterval[i]) {                for (j = stat + 1; j <= maxIOICfg.stat1[i]; j++) {                    statOnsetIndices[stat] = frame_counter;                }                 stat_out = maxIOICfg.stat1[i];                            }

The loop runs over the target states j, but writes index stat every time.

if (t_mode == ELAPSED_TIME) { // (+1) Elapsed time from previous state. prm1: duration (s)    if ((data_counter - statOnsetIndices[stat]) * frameDur > prm1[k]) {        stat_out = stat + 1;        statOnsetIndices[stat_out] = frame_counter;    }

ELAPSED_TIME measures from the current state's recorded onset, which the jump never set.

if ((statOnsetIndices = (int *) calloc(n * maxStatesPerLine, sizeof(int))) == NULL) {

The onset table is zeroed only when the OST is loaded.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Write statOnsetIndices[j] in the maxIOI loop (report/patches/ost-f2-maxioi-onset-index.patch, one line). Together with the OST-F1 fix this is proven sufficient. The OST-F1 fix alone removes the growing delay but not the one-frame ELAPSED_TIME. Workaround: reload the OST before every trial, and avoid ELAPSED_TIME directly after a maxIOI target.

How we know

  • H harness test: t_ost.m FAIL “OST F2: maxIOI trial 1 ELAPSED 0.1 s in state 2”: state 2 held 0.002 s
  • H harness test: t_ost.m FAIL “OST F2: no-reload trial 2 timeout at 0.2 s”: state 2 at 0.404 s
  • H harness test: t_ost.m FAIL “OST F2: no-reload trial 3 timeout at 0.2 s”: state 2 at 0.606 s
  • H report export (vowel, audible): report_ost_f2.m vowel input, so the effect is audible
  • H WASM patch check: report/wasm/checks/fix-ost-f2.mjs fix-ost-f2 removes both symptoms; a configuration without maxIOI stays bit-identical
  • D standalone driver of ost.cpp: scratch/ost-pcf/t/drv.cpp maxioi_onset, maxioi_trials state 2 entered at frame 101, left at 102; timeouts at frames 101, 202, 303
  • F Lean 4 proof, theorem trial_independent: formal/lean/Ost.lean with both fixes, trials no longer depend on earlier trials; clearing statOnsetIndices is not needed
  • F Lean 4 counterexamples: formal/lean/OstCex.lean the reset fix alone leaves the one-frame ELAPSED_TIME; the [j] fix alone leaves OST-F1 (ce2_bug_trial1, ce2_bug_drift, ce1_leak)

Reproduce cd audit/harness && ./run-oct.sh t_ost.m

Pitfalls for future designs

None of these is triggered by the blab designs we inspected. Each can affect a plausible design, so each card states the settings that trigger it, the safe pattern, and the fix. Examples use the most plausible settings a lab would choose and are marked as hypothetical.

An ELAPSED_TIME rule numbered as if it advanced two states runs twice, so a 100–200 ms post-onset delay becomes 200–400 ms and the shift misses a short vowel; Audapter does not check the numbering

Affects OST files where a one-state rule (ELAPSED_TIME, INTENSITY_FALL) is followed by a rule numbered two states later, an easy slip because most level rules do span two states.
Relevant blab studies n.a.. Not applicable as far as we know: blab's OST files number their rules correctly, apart from free-speech's measureFormants.ost, whose doubled INTENSITY_FALL is harmless there (EXP-2).

What happens

Each OST rule owns the states from its own number up to the next rule's number. Level rules such as INTENSITY_RISE_HOLD use two states, but ELAPSED_TIME and INTENSITY_FALL move on after one. If the next rule is numbered two higher anyway, the one-state rule is applied again in the unused state in between: its time runs twice, and any PCF rows written for "the rule's two states" apply for twice as long. Audapter accepts the file without a warning.

When it matters

Matters for timed designs: a random delay after the detected onset, a fixed perturbation duration, or a hold before the next event. Safe if every ELAPSED_TIME or INTENSITY_FALL rule is followed by a rule numbered one higher, and the PCF's shifted rows match the states the rules actually use. Checking ost_stat of a pilot trial shows the doubled states.

Example

A plausible design, not one blab runs: F1 raised 125 mel for 0.2 s, starting a random 100–200 ms after the detected voice onset, on a short real vowel (0.35 s). Numbered correctly, the shift starts 0.10 s, 0.15 s and 0.20 s after onset and covers 190, 140 and 90 ms of the vowel. With every rule numbered two apart it starts 0.20 s, 0.30 s and never after onset and covers 88, 0 and 0 ms: at the longest delay the vowel is over before the shift begins. The shift state itself lasts 0.40 s instead of 0.20 s (from ost_stat).

Settings used

Source
Hypothetical design: a plausible design, not one blab runs (blab's default female settings, F1 +125 mel in mel units).
Input
real voice: PVQD SJ7001 sustained /a/ cut to a 0.35 s vowel (CC BY 4.0)
Trials
delay 0.10 s, delay 0.15 s, delay 0.20 s
Between trials
the delay row is rewritten before every trial (a random 100-200 ms delay after the detected onset); reset() per trial
Key parameters
bShift 1, bRatioShift 0, bMelShift 1
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.bShift1
p.bRatioShift0
p.bMelShift1
OST file
rmsSlopeWin = 0.030000window for the level-slope estimate n = 44 rules follow0 INTENSITY_RISE_HOLD 0.02 0.02 {}state 0 → 2: level rises above 0.02 (next state) and stays above it for 0.02 s2 ELAPSED_TIME 0.15 NaN {}state 2 → 3: moves on 0.15 s after this state began; does not record its end time4 ELAPSED_TIME 0.2 NaN {}state 4 → 5: moves on 0.2 s after this state began; does not record its end time6 OST_END NaN NaN {}state 6: final state n = 0no maxIOI timeouts
OST file, safe variant
rmsSlopeWin = 0.030000window for the level-slope estimate n = 44 rules follow0 INTENSITY_RISE_HOLD 0.02 0.02 {}state 0 → 2: level rises above 0.02 (next state) and stays above it for 0.02 s2 ELAPSED_TIME 0.15 NaN {}state 2 → 3: moves on 0.15 s after this state began; does not record its end time3 ELAPSED_TIME 0.2 NaN {}state 3 → 4: moves on 0.2 s after this state began; does not record its end time4 OST_END NaN NaN {}state 4: final state n = 0no maxIOI timeouts
PCF file
00 time-warp sections 77 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0, 0, 0, 0state 0: no change1, 0, 0, 0, 0state 1: no change2, 0, 0, 0, 0state 2: no change3, 0, 0, 0, 0state 3: no change4, 0, 0, 125, 0state 4: formant shift 125 mel, F1 up5, 0, 0, 125, 0state 5: formant shift 125 mel, F1 up6, 0, 0, 0, 0state 6: no change
PCF file on catch trials
00 time-warp sections 55 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0, 0, 0, 0state 0: no change1, 0, 0, 0, 0state 1: no change2, 0, 0, 0, 0state 2: no change3, 0, 0, 125, 0state 3: formant shift 125 mel, F1 up4, 0, 0, 0, 0state 4: no change

Three trials with delays of 100, 150 and 200 ms. Expected: rules numbered by the states they use. Observed: every rule numbered two apart, with the PCF shifting both of the rule's states. Values: how much of the vowel is shifted.

Expected rules numbered by the states they use (ELAPSED_TIME advances one state)

Expected: rules numbered by the states they use (ELAPSED_TIME advances one state)Expected (rules numbered by the states they use (ELAPSED_TIME advances one state)). trial 1 (delay 100 ms): Perturbation F1 +125 mel 0.36-0.56 s; Vowel time shifted 190 ms shifted. trial 2 (delay 150 ms): Perturbation F1 +125 mel 0.41-0.61 s; Vowel time shifted 140 ms shifted. trial 3 (delay 200 ms): Perturbation F1 +125 mel 0.46-0.61 s; Vowel time shifted 90 ms shifted.Trial1 · delay 100 ms2 · delay 150 ms3 · delay 200 msSpeechtrial 1: vowel 0.20-0.55 svoweltrial 2: vowel 0.20-0.55 svoweltrial 3: vowel 0.20-0.55 svowelPerturbationtrial 1: F1 +125 mel from 0.36 to 0.56 strial 2: F1 +125 mel from 0.41 to 0.61 strial 3: F1 +125 mel from 0.46 to 0.61 sOnset detectedtrial 1: onset at 0.258 strial 2: onset at 0.258 strial 3: onset at 0.258 sVowel time shifted190 ms shifted140 ms shifted90 ms shifted0123time in the session (s), trials drawn end to end; dashed rules = reset()

Observed every rule numbered two apart

Observed: every rule numbered two apartObserved (every rule numbered two apart). trial 1 (delay 100 ms): Perturbation F1 +125 mel 0.46-0.61 s; Vowel time shifted 88 ms shifted. trial 2 (delay 150 ms): Perturbation F1 +125 mel 0.56-0.61 s; Vowel time shifted 0 ms shifted. trial 3 (delay 200 ms): Perturbation shift misses the vowel; Vowel time shifted 0 ms shifted.Trial1 · delay 100 ms2 · delay 150 ms3 · delay 200 msSpeechtrial 1: vowel 0.20-0.55 svoweltrial 2: vowel 0.20-0.55 svoweltrial 3: vowel 0.20-0.55 svowelPerturbationtrial 1: F1 +125 mel from 0.46 to 0.61 strial 2: F1 +125 mel from 0.56 to 0.61 sshift misses the vowelOnset detectedtrial 1: onset at 0.258 strial 2: onset at 0.258 strial 3: onset at 0.258 sVowel time shifted88 ms shifted0 ms shifted0 ms shiftedThe doubled delay pushes the shift past a short vowel0123time in the session (s), trials drawn end to end; dashed rules = reset()

Expected rules numbered by the states they use (ELAPSED_TIME advances one state)

Expected: rules numbered by the states they use (ELAPSED_TIME advances one state)Expected (rules numbered by the states they use (ELAPSED_TIME advances one state)). trial 1 (delay 100 ms): Perturbation F1 +125 mel 0.36-0.56 s; Vowel time shifted 190 ms shifted. trial 2 (delay 150 ms): Perturbation F1 +125 mel 0.41-0.61 s; Vowel time shifted 140 ms shifted. trial 3 (delay 200 ms): Perturbation F1 +125 mel 0.46-0.61 s; Vowel time shifted 90 ms shifted.Trial 1 · delay 100 msvowel 0.20-0.55 svowelF1 +125 mel from 0.36 to 0.56 sonset at 0.258 s190 ms shiftedTrial 2 · delay 150 ms (after reset())vowel 0.20-0.55 svowelF1 +125 mel from 0.41 to 0.61 sonset at 0.258 s140 ms shiftedTrial 3 · delay 200 ms (after reset())vowel 0.20-0.55 svowelF1 +125 mel from 0.46 to 0.61 sonset at 0.258 s90 ms shifted00.51time in each trial (s)

Observed every rule numbered two apart

Observed: every rule numbered two apartObserved (every rule numbered two apart). trial 1 (delay 100 ms): Perturbation F1 +125 mel 0.46-0.61 s; Vowel time shifted 88 ms shifted. trial 2 (delay 150 ms): Perturbation F1 +125 mel 0.56-0.61 s; Vowel time shifted 0 ms shifted. trial 3 (delay 200 ms): Perturbation shift misses the vowel; Vowel time shifted 0 ms shifted.Trial 1 · delay 100 msvowel 0.20-0.55 svowelF1 +125 mel from 0.46 to 0.61 sonset at 0.258 s88 ms shiftedTrial 2 · delay 150 ms (after reset())vowel 0.20-0.55 svowelF1 +125 mel from 0.56 to 0.61 sonset at 0.258 s0 ms shiftedTrial 3 · delay 200 ms (after reset())vowel 0.20-0.55 svowelshift misses the vowelonset at 0.258 s0 ms shiftedThe doubled delay pushes the shift past a shortvowel00.51time in each trial (s)

Listen

  • Input, trial 2. a short real vowel 1.1 s

  • Expected, trial 2. F1 raised from 0.15 s after onset 1.1 s

  • Observed, trial 2. F1 raised only from 0.30 s: 0 ms of the vowel 1.1 s

  • A/B, trial 2. expected, then observed 2.5 s

    No playhead: two clips back to back.

All clips share one playback gain. Real voice: PVQD (Walden 2022), speaker SJ7001, sustained /a/ cut to 0.35 s, processed by Audapter (CC BY 4.0).

How big, how often

real voiceShift onset after the detected onset, delays 100 / 150 / 200 ms: correct / misnumbered0.10 s, 0.15 s, 0.20 s / 0.20 s, 0.30 s, never
real voiceVowel time shifted (vowel 350 ms): correct / misnumbered190, 140, 90 / 88, 0, 0 ms
real voiceTime in the shift state(s)0.20 s correct, 0.40 s misnumbered

Data: data.json from the export script.

Cause

osTrack picks the rule whose number range contains the current state, so a rule numbered two below the next one also handles the state in between; ELAPSED_TIME then measures its duration again from that state's onset.

// Determine which segment of the tab (ostCfg) we are currently ink = -1;for (i = 0; i < n; i++) {    if (stat >= stat0[i] && stat < stat0[i + 1]) {        k = i;        break;    }

The rule is chosen by number range, not by what the rule uses.

if (t_mode == ELAPSED_TIME) { // (+1) Elapsed time from previous state. prm1: duration (s)    if ((data_counter - statOnsetIndices[stat]) * frameDur > prm1[k]) {        stat_out = stat + 1;        statOnsetIndices[stat_out] = frame_counter;    }}

ELAPSED_TIME advances one state, measured from that state's onset.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Safe pattern: number each rule by the states the previous rule uses (+1 after ELAPSED_TIME and INTENSITY_FALL, +2 after the level rules). Fix: reject or warn at load when a rule's range contains more states than its mode uses (the notes' OST F7 check).

How we know

Reproduce cd audit/harness && SCEN=ostnum ./run-oct.sh report_pitfalls2.m

A 2-D perturbation patch built for one vowel shifts every formant outside it by the edge cell's amount, unless F1Min..F2Max are set to the patch; tokens 63 mel away get a fixed shift instead of none

Affects 2-D field designs whose grid (pertF1/pertF2) covers only part of the formant space, when F1Min..F2Max are not set to the grid.
Relevant blab studies n.a.. The public VSA studies send the field's bounds with the field (calc_pertField returns them), so they are not affected; any design that builds a small patch and leaves F1Min..F2Max at their defaults is.

What happens

Audapter looks up each frame's F1–F2 on the 2-D grid, but it clamps the lookup to the grid's edge: a formant pair outside the grid gets the shift of the nearest edge cell. Only F1Min..F1Max and F2Min..F2Max decide whether a frame is shifted at all, and they default to the whole range. A patch meant to act only around one vowel therefore also shifts transitions, other vowels and outlier tokens, each by a fixed amount taken from the patch's edge.

When it matters

Matters for per-vowel or per-token patches (for example scaling each token toward its vowel's centre, or pushing one vowel's variability in or out). Safe if F1Min..F1Max and F2Min..F2Max are sent with the patch and equal its grid limits, as calc_pertField does; then formants outside the patch are left alone.

Example

A plausible design: a ±90 mel patch around a male /ɛ/ that moves each token half-way to the vowel's centre. Tokens inside the patch are moved as designed in both runs. Two outlier tokens, 150 and 215 mel from the centre, are left alone when the patch's bounds are sent (0 and 0 mel of shift) but are shifted by 49 and 63 mel without them, the edge cell's shift.

Settings used

Source
Hypothetical design: a plausible per-vowel patch (blab's default male settings, mel units, 257 × 257 grid over ±90 mel).
Input
synthetic male /E/ tokens (F0 120 Hz) at the vowel centre and at mel offsets inside and outside a +-90 mel patch
Between trials
one trial per token
Key parameters
bShift 1, bRatioShift 0, bMelShift 1
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('male'):

p.bShift1
p.bRatioShift0
p.bMelShift1

Sent with Audapter('setParam', …):

bShift2D1
pertf1mel grid 590..770 (257 values)
pertf2mel grid 1345..1525
pertAmp2Dhalf the distance to the /E/ centre (mel)
pertPhi2Dtoward the centre
F1Min..F2Maxexpected: the patch bounds; observed: not sent (0..5000 defaults)

F1–F2 plane (F2 high to low). Expected: the patch sent with its bounds. Observed: the same patch without F1Min..F2Max. Numbers label the tokens; tokens 4 and 5 lie outside the patch.

Five /ɛ/ tokens: 1–3 inside the ±90 mel patch, 4–5 outsideTokens inside are moved half-way to the centre in both runs; outside, the bounded patch leaves them alone and the unbounded one shifts them by the edge cell.Five /ɛ/ tokens: 1–3 inside the ±90 mel patch, 4–5 outsideExpected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/ɛ/: 595/1805 Hz to 587/1803 Hz1/ɛ/: 611/1695 Hz to 595/1747 Hz2/ɛ/: 506/1906 Hz to 542/1852 Hz345Observed: patch sent without F1Min..F2Max1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/ɛ/: 595/1805 Hz to 587/1803 Hz1/ɛ/: 611/1695 Hz to 595/1747 Hz2/ɛ/: 506/1906 Hz to 542/1852 Hz3/ɛ/: 738/1712 Hz to 682/1756 Hz4/ɛ/: 468/2192 Hz to 516/2079 Hz5Orange: tokens 4 and 5 lie outside the patch; without the bounds they get the edge cell's shift, a fixed amount not tied totheir own position.
Five /ɛ/ tokens: 1–3 inside the ±90 mel patch, 4–5 outsideTokens inside are moved half-way to the centre in both runs; outside, the bounded patch leaves them alone and the unbounded one shifts them by the edge cell.Five /ɛ/ tokens: 1–3 inside the ±90 melpatch, 4–5 outsideExpected: what the design intends1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/ɛ/: 595/1805 Hz to 587/1803 Hz1/ɛ/: 611/1695 Hz to 595/1747 Hz2/ɛ/: 506/1906 Hz to 542/1852 Hz345Observed: patch sent without F1Min..F2Max1000150020002500300500700F2 (Hz), high to lowF1 (Hz)vowel-space centre/ɛ/: 595/1805 Hz to 587/1803 Hz1/ɛ/: 611/1695 Hz to 595/1747 Hz2/ɛ/: 506/1906 Hz to 542/1852 Hz3/ɛ/: 738/1712 Hz to 682/1756 Hz4/ɛ/: 468/2192 Hz to 516/2079 Hz5Orange: tokens 4 and 5 lie outside the patch;without the bounds they get the edge cell's shift,a fixed amount not tied to their own position.

Listen

  • Expected, token 5. outside the patch: not shifted 0.8 s

    No playhead: this clip is not drawn in the figure.

  • Observed, token 5. shifted by the edge cell: 63 mel 0.8 s

    No playhead: this clip is not drawn in the figure.

  • A/B, token 5. expected, then observed 2.1 s

    No playhead: two clips back to back.

Synthetic tokens, so the positions are exact; a real vowel's transitions pass through the same edge cells.

How big, how often

syntheticOutlier tokens 4 / 5: shift with the patch bounds0 / 0 mel
syntheticOutlier tokens 4 / 5: shift without them (edge cell)49 / 63 mel

Data: data.json from the export script.

Cause

locateF1/locateF2 clamp the grid position to the edge; the gate that decides whether to shift uses F1Min..F2Max only.

if (!p.transDone){    f2mp=f2m;    if (f2mp>=p.F2Min && f2mp<=p.F2Max && f1m>=p.F1Min && f1m<=p.F1Max         && ((p.LBk<=0 && f1m*p.LBk+p.LBb<=f2m) || (p.LBk>0 && f1m*p.LBk+p.LBb>=f2m))){

Whether a frame is shifted depends only on F1Min..F2Max.

loc += (f1 - p.pertF1[k]) / (p.pertF1[k + 1] - p.pertF1[k]); if (loc >= pfNPoints - 1) { //pfNPoints=257, so locint not be greater than 255 (pfNPoints-2)    loc = pfNPoints - 1 - 0.000000000001;}if (loc < 0) {    loc = 0;}

Outside the grid, the position is clamped to the edge cell.

Introduced in the blab-lab fork.

Safe pattern and fix

Safe pattern: send F1Min..F1Max and F2Min..F2Max equal to the patch's grid limits whenever the patch covers part of the plane. Fix: treat formants outside the grid as unshifted, or warn when the grid is narrower than F1Min..F2Max.

How we know

  • H report export (five tokens: report_pitfalls2.m SCEN=patch
  • H Playground: audit/playground/test/run.mjs PLAYGROUND-4: a vowel at F1 800 Hz outside a 500–700 Hz field shifted by the edge value

Reproduce cd audit/harness && SCEN=patch ./run-oct.sh report_pitfalls2.m

AudapterIO('init') only sends the optional fields present in p, so a setting from an earlier block stays on: a formant block after a pitch block keeps the phase vocoder, +3.5 dBA louder

Affects sessions that run several blocks in one MATLAB session and build p for a later block without the optional fields an earlier block set (bPitchShift, rmsFF_fb, the time-domain pitch settings, fb gains).
Relevant blab studies safe as run. Safe as run, as far as we know: blab's runners build p from getAudapterDefaultParams, which always contains bPitchShift. Related to COORD-1 (a PCF that survives init).

What happens

AudapterIO('init', p) sends many parameters only if isfield(p, …). A field missing from p is not reset to a default: Audapter keeps whatever the previous block set. data.params then shows the kept value, but the script's own p does not.

When it matters

Matters when the parameter struct is built fresh for each block (for example from a saved struct or by hand) rather than from getAudapterDefaultParams. Safe if every block starts from getAudapterDefaultParams, or sets every optional field it relies on explicitly, or the MEX is cleared between blocks.

Example

A plausible session: block 1 sets p.bPitchShift = 1; block 2's p is built without that field. Audapter keeps bPitchShift = 1, so in block 2 the voice is played at +3.5 dBA instead of +0.0 dBA re the input (real sentence, measured on the audio).

Settings used

Source
Hypothetical design: two blocks in one MATLAB session, blab's default female settings.
Input
real speech: CMU ARCTIC clb a0030
Trials
block 1: p.bPitchShift = 1, block 2: p built without the bPitchShift field
Between trials
AudapterIO('init', p) at the start of each block
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

none

Two blocks in one session. Expected: block 2 without the vocoder. Observed: the vocoder from block 1 is still on.

Expected what the design should do

Expected: what the design should doExpected (what the design should do). trial 1 (block 1: pitch): Phase vocoder vocoder on (p.bPitchShift 1) 0.02-1.68 s; Heard level, dBA as designed. trial 2 (block 2: formant): Phase vocoder vocoder off (field absent from p); Heard level, dBA +0.0 dBA.Trial1 · block 1: pitch2 · block 2: formantSpeechtrial 1: “I had faith in them.” 0.10-1.60 s“I had faith in them.”trial 2: “I had faith in them.” 0.10-1.60 s“I had faith in them.”Phase vocodertrial 1: vocoder on (p.bPitchShift 1) from 0.02 to 1.68 svocoder on (p.bPitchShift 1)vocoder off (field absent from p)Heard level, dBAas designed+0.0 dBAAudapterIO('init', p) with a p that has nobPitchShift field0123time in the session (s), trials drawn end to end; dashed rules = reset()

Observed what Audapter did

Observed: what Audapter didObserved (what Audapter did). trial 1 (block 1: pitch): Phase vocoder vocoder on (p.bPitchShift 1) 0.02-1.68 s; Heard level, dBA as designed. trial 2 (block 2: formant): Phase vocoder vocoder still on 0.02-1.68 s; Heard level, dBA +3.5 dBA.Trial1 · block 1: pitch2 · block 2: formantSpeechtrial 1: “I had faith in them.” 0.10-1.60 s“I had faith in them.”trial 2: “I had faith in them.” 0.10-1.60 s“I had faith in them.”Phase vocodertrial 1: vocoder on (p.bPitchShift 1) from 0.02 to 1.68 svocoder on (p.bPitchShift 1)trial 2: vocoder still on from 0.02 to 1.68 svocoder still onHeard level, dBAas designed+3.5 dBAAudapterIO('init', p) with a p that has nobPitchShift fieldBlock 2 keeps block 1's vocoder: the voice is 3.5 dBAlouder0123time in the session (s), trials drawn end to end; dashed rules = reset()

Expected what the design should do

Expected: what the design should doExpected (what the design should do). trial 1 (block 1: pitch): Phase vocoder vocoder on (p.bPitchShift 1) 0.02-1.68 s; Heard level, dBA as designed. trial 2 (block 2: formant): Phase vocoder vocoder off (field absent from p); Heard level, dBA +0.0 dBA.Trial 1 · block 1: pitch“I had faith in them.” 0.10-1.60 s“I had faith in them.”vocoder on (p.bPitchShift 1) from 0.02 to 1.68 svocoder on (p.bPitchShift 1)as designedTrial 2 · block 2: formant (after AudapterIO('init', p) with a p that has no bPitchShift field)“I had faith in them.” 0.10-1.60 s“I had faith in them.”vocoder off (field absent from p)+0.0 dBA00.511.5time in each trial (s)

Observed what Audapter did

Observed: what Audapter didObserved (what Audapter did). trial 1 (block 1: pitch): Phase vocoder vocoder on (p.bPitchShift 1) 0.02-1.68 s; Heard level, dBA as designed. trial 2 (block 2: formant): Phase vocoder vocoder still on 0.02-1.68 s; Heard level, dBA +3.5 dBA.Trial 1 · block 1: pitch“I had faith in them.” 0.10-1.60 s“I had faith in them.”vocoder on (p.bPitchShift 1) from 0.02 to 1.68 svocoder on (p.bPitchShift 1)as designedTrial 2 · block 2: formant (after AudapterIO('init', p) with a p that has no bPitchShift field)“I had faith in them.” 0.10-1.60 s“I had faith in them.”vocoder still on from 0.02 to 1.68 svocoder still on+3.5 dBABlock 2 keeps block 1's vocoder: the voice is 3.5dBA louder00.511.5time in each trial (s)

Listen

  • Expected, block 2. vocoder off: +0.0 dBA 1.7 s

  • Observed, block 2. vocoder still on: +3.5 dBA 1.7 s

  • A/B. expected, then observed 3.8 s

    No playhead: two clips back to back.

Both clips share one playback gain. CMU ARCTIC clb a0030 © 2003 Carnegie Mellon University (licence notice), processed by Audapter.

How big, how often

real voiceBlock 2 heard level re input: expected / observed+0.0 / +3.5 dBA
settingsAudapter bPitchShift after block 2's init1

Level and loudness: for vocoder off (intended) vs left on, the heard level changes by +3.6 dBA and the loudness level by +3.2 to +3.3 phon (ISO 532-1, 60–85 dB SPL): at least 1 phon at every presentation level, above the ~1 dB just-noticeable difference. Level and loudness agree here: the change is close to a pure gain.

All level and loudness estimates for this card, and the assumptions
Comparison (expected → observed)Level, dBALoudness level, phon (ISO 532-1, 60–85 dB SPL)Plain RMS levelNoticeable?
INIT-PERSIST block 2: vocoder off (intended) vs left on+3.6 dBA+3.2 to +3.3 phon (loudness ratio 1.25–1.26)+3.5 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference

dBA: the A-weighted level difference, measured on the audio. Phon: model estimates (ISO 532-1, Zwicker) for a normal-hearing listener on headphones with a flat response, the expected clip presented at 60, 70, 80 and 85 dB SPL (A-weighted) and the observed clip at the same gain. Plain RMS: the unweighted level difference, for reference. Older or clinical listeners and real headphones may differ. How this is computed.

Data: data.json from the export script.

Cause

AudapterIO sends optional parameters only when the field exists, so an absent field leaves Audapter's current value in place.

if (isfield(p, 'rmsFF_fb'))    Audapter(3, 'rmsff_fb', p.rmsFF_fb, toPrompt);end %SC(2012/03/05) Frequency/pitch shiftingif (isfield(p, 'bPitchShift'))    Audapter(3, 'bpitchshift', p.bPitchShift, toPrompt);

Sent only if present; otherwise Audapter keeps its current value.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Safe pattern: build every block's p from getAudapterDefaultParams, or clear Audapter between blocks. Fix: have AudapterIO send a default for every parameter it knows, or reset Audapter's parameters at init.

How we know

Reproduce cd audit/harness && SCEN=persist ./run-oct.sh report_pitfalls2.m

At frameLen 48 (24 kHz) a time warp delivers only 73 % of the programmed lag: a 60 ms slow-down gives 44 ms

Affects time-warp (PCF warp) designs run at frameLen 48 with pvocHop 64, where the hop is not a whole multiple of the frame.
Relevant blab studies n.a.. Not applicable to public blab studies. It applies to any time-warp design at frameLen 48 with the default pvocHop 64, such as the timeAdapt settings in free-speech's public run_measureDuration_audapter.m.

What happens

A PCF warp slows the feedback down and then catches up, creating a programmed lag. At 24 kHz with frameLen 32 the delivered lag matches the programme; at frameLen 48 it is about three quarters of it, the ratio of frameLen to pvocHop (48 / 64). Nothing reports the difference.

When it matters

Matters whenever the size of the timing perturbation is the independent variable. Safe if frameLen divides pvocHop (for example frameLen 32 with pvocHop 64), or the delivered lag is measured and reported instead of the programmed one.

Example

A warp that slows to ×0.5 for 0.12 s (programmed lag 60 ms), held for 0.3 s, measured with a frequency sweep through Audapter: the delivered lag during the hold is 58.5 ms at frameLen 32 (97 %) and 44.0 ms at frameLen 48 (73 %).

Settings used

Source
Hypothetical design with the timeAdapt settings (24 kHz, downFact 2, frameLen 48, bPitchShift 1) and, for comparison, frameLen 32.
Input
a 300-1260 Hz sweep for the lag measurement; real speech CMU ARCTIC clb a0018 for listening
Trials
frameLen 32, frameLen 48
Between trials
the same warp PCF at frameLen 32 and 48 (24 kHz)
Key parameters
downFact 2, frameLen 48, sr 24000, nDelay 3, bPitchShift 1
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.downFact2
p.frameLen48
p.sr24000
p.nDelay3
p.bPitchShift1
OST file
# one statermsSlopeWin = 0.030000window for the level-slope estimate n = 11 rules follow0 OST_END NaN NaN {}state 0: final state n = 0no maxIOI timeouts
PCF file
11 time-warp section0.30, 0.5, 0.12, 0.30, 2.0time warp from 0.30 s: slow to ×0.5 for 0.12 s, hold 0.30 s, then ×2.0 11 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0.0, 0, 0, 0state 0: no change

The lag between input and output during the warp, measured with a sweep (output frequency at time t = input frequency at t − lag). Thin line: programmed. Thick: delivered.

Delivered lag of a programmed 60 ms time warp at frameLen 32 and 48 (24 kHz)Programmed 60 ms; delivered 58 ms at frameLen 32 and 44 ms at frameLen 48.frameLen 32delivered 58 ms of 600 ms30 ms60 msframeLen 48delivered 44 ms of 600 ms30 ms60 msthin line: the programmed lag (slow to ×0.5 for 0.12 s, hold, catch up); thick: whatAudapter delivered0.2 s0.4 s0.6 s0.8 s1 s1.2 stime in trial (s)
Delivered lag of a programmed 60 ms time warp at frameLen 32 and 48 (24 kHz)Programmed 60 ms; delivered 58 ms at frameLen 32 and 44 ms at frameLen 48.frameLen 32 (delivered 58 ms of 60)0 ms30 ms60 msframeLen 48 (delivered 44 ms of 60)0 ms30 ms60 msthin line: the programmed lag (slow to×0.5 for 0.12 s, hold, catch up); thick:what Audapter delivered0.2 s0.4 s0.6 s0.8 s1 s1.2 stime in trial (s)

Listen

  • frameLen 32. the programmed warp on a real sentence 1.8 s

    No playhead: this clip is not drawn in the figure.

  • frameLen 48. the same PCF: a smaller delay 1.8 s

    No playhead: this clip is not drawn in the figure.

The difference is a timing difference of about 15 ms, hard to hear in isolation. CMU ARCTIC © 2003 Carnegie Mellon University (licence notice).

How big, how often

sweepDelivered lag during the hold, programmed 60 ms: frameLen 32 / 4858.5 / 44.0 ms

Data: data.json from the export script.

Cause

The warp converts the programmed time into vocoder hops (pvocHop samples), but the cache it indexes advances once per processing frame. When pvocHop is not a whole multiple of frameLen the two units differ, and the lag comes out scaled by frameLen/pvocHop. The same frame/hop mismatch is the most likely cause of PT-5's gain of 2 (+6 dB) at frameLen 48 (not isolated in the code).

int cidx0 = 0;if (mode == TIME_WARP_ONLY) {    cidx0 = pvCtr;    //cidx0 = pvCtr / (pvocHop / frameLen);

The cache index counts processing frames (the hop-based version is commented out).

/* Each PVOC hop takes a period of (pvocHop / sr) */dtype warp_i =     warp_t * sr / static_cast<dtype>(pvocHop);

The warp time is converted to hops.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Safe pattern: use a frameLen that divides pvocHop (32 at 24 kHz), or measure the delivered lag. Fix: convert the warp time with the same unit the cache index uses.

How we know

Reproduce cd audit/harness && SCEN=warp48 ./run-oct.sh report_pitfalls2.m

A time-domain pitch-shift schedule counts from the first frame above the level threshold, 56–111 ms after the acoustic onset on real speech, so analyses aligned to acoustic onset misplace the perturbation

Affects time-domain (bTimeDomainShift) pitch designs with a timed schedule, especially with soft or breathy onsets.
Relevant blab studies safe as run. blab's time-domain pitch studies use perturbations that do not depend on a precise onset time as far as we know; named public studies do not use the time-domain shifter.

What happens

The schedule's clock starts when the frame level first exceeds rmsThresh, not at the acoustic onset, and it then runs only on frames above the threshold (PT-3). A step scheduled "0.2 s after onset" therefore comes 0.2 s after the level crossing, which lags the acoustic onset by an amount that depends on how soft the onset is.

When it matters

Matters for analyses that align responses to the acoustic onset, and for designs comparing speakers or conditions with different onset softness. Safe if responses are aligned to the logged perturbation time (from the output or the first above-threshold frame), not to the acoustic onset.

Example

Four ARCTIC sentences and two PVQD vowels with a soft onset, a +2 st step scheduled at 0.2 s (frameLen 64, nDelay 7): the first above-threshold frame comes 56–111 ms after the acoustic onset (level within 40 dB of the clip's peak), so the step is heard that much later than 0.2 s after the acoustic onset.

Settings used

Source
Hypothetical design: time-domain pitch shifting as in Audapter's time_domain_shift_demo, frameLen 64 / nDelay 7, a +2 st step at 0.2 s.
Input
real speech: CMU ARCTIC bdl a0005, clb a0030, slt a0018, rms a0036; PVQD SJ7001, LA9015 /a/ with a soft 150 ms onset
Between trials
one trial per clip
Key parameters
frameLen 64, nDelay 7, bCepsLift 1, bTimeDomainShift 1
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('male'):

p.frameLen64
p.nDelay7
p.bCepsLift1
p.bTimeDomainShift1
p.pitchLowerBoundHz70
p.pitchUpperBoundHz200
p.timeDomainPitchShiftSchedule[[0, 1], [0.2, 1], [0.201, 1.12246], [5, 1.12246]]

One trial per clip: when the step starts, relative to the acoustic onset. Hollow: intended (200 ms). Solid: 200 ms after the first above-threshold frame.

ARCTIC bdl a0005+76 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onsetARCTIC clb a0030+65 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onsetARCTIC slt a0018+56 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onsetARCTIC rms a0036+91 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onsetPVQD SJ7001 /a/+102 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onsetPVQD LA9015 /a/+111 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onset150200250300350shift onset after the acoustic onset, ms (schedule step at 0.2 s)intended: 200 ms after the acoustic onsetobserved: 200 ms after the first above-threshold frame
ARCTIC bdl a0005+76 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onsetARCTIC clb a0030+65 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onsetARCTIC slt a0018+56 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onsetARCTIC rms a0036+91 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onsetPVQD SJ7001 /a/+102 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onsetPVQD LA9015 /a/+111 msshift starts (schedule 0.2 s after the first above-threshold frame)intended: 0.2 s after acoustic onset150200250300350shift onset after the acoustic onset, ms (schedule step at 0.2 s)intended: 200 ms after the acoustic onsetobserved: 200 ms after the first above-threshold frame

Listen

  • Input. ARCTIC bdl "Will we ever forget it." 1.3 s

    No playhead: this clip is not drawn in the figure.

  • Observed. +2 st step scheduled at 0.2 s 1.3 s

    No playhead: this clip is not drawn in the figure.

The timing is the point, not an audible artefact. CMU ARCTIC © 2003 Carnegie Mellon University (licence notice).

How big, how often

real speechFirst above-threshold frame after the acoustic onset, 6 clips56–111 ms

Data: data.json from the export script.

Cause

The time-domain shifter is only called on frames above the level threshold, and its clock counts those frames.

if (above_rms && p.bTimeDomainShift) {    f0BandpassFilter(        pBuf + (p.nDelay - 1) * p.frameLen + si, f0Buf + si, fmtTracker->getLatestPitchHz(),        static_cast<dtype>(p.sr), p.frameShift);    timeDomainShifter->processFrame(        f0Buf + si, pBuf + (p.nDelay - 1) * p.frameLen + si, outBuf, p.frameShift);}

The shifter runs only on frames above rmsThresh.

const dtype nowSec = static_cast<dtype>(totalLen) / sr;

Schedule time = samples processed so far.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Safe pattern: align to the first above-threshold frame (from data.rms) or measure the step in the output. Fix: run the schedule clock on trial time (PT-3).

How we know

Reproduce cd audit/harness && SCEN=tdsonset ./run-oct.sh report_pitfalls2.m

At 24 kHz with frameLen 48, a phase-vocoder pitch shift lowers the level of a fricative far more than that of voiced speech (-9 to -17 dBA on a real /ʃ/, -2 to -8 dBA on the vowel after it), so a loudness cue tracks the perturbation

Affects phase-vocoder pitch shifts applied across fricatives or other noise-like segments (whole-utterance shifts, or states that include consonants).
Relevant blab studies n.a.. Not applicable to the public blab studies, which do not shift pitch.

What happens

The phase vocoder is not level-preserving (PT-5): at 0 st it raises the level, and any nonzero shift removes some of that, by an amount that depends on the signal. With frameLen 32 or 64 every segment we tried (vowel, /ʃ/, noise band) comes out 3.4 to 7.2 dBA below its 0 st level. With frameLen 48 fricatives and noise lose more, 6 to 16 dBA, and the loss does not grow steadily with the shift (−2 st loses more than −4 st).

When it matters

Matters when the perturbation covers consonants, or when the level of the feedback is analysed or should not cue the condition. Safe if the shift is confined to voiced segments (an OST state per vowel), or the level of shifted fricatives is checked and compensated.

Example

At 24 kHz, frameLen 48, for shifts of ±2 and ±4 semitones, relative to the same segment at 0 st: the real /ʃ/ of an ARCTIC sentence changes by -17.2 to -8.6 dBA in level (−4 st: -10.3 dBA); a /s/-like noise band by -14.8 to -6.4 dBA (−4 st: -6.4 dBA); the voiced stretch that follows the /ʃ/ by -7.8 to -2.0 dBA (−4 st: -7.8 dBA).

Settings used

Source
Hypothetical design: phase-vocoder pitch shifts of −4 to +4 st at the timeAdapt rate settings (24 kHz, frameLen 48).
Input
real /S/ of CMU ARCTIC slt a0036 ("She ..."), the voiced stretch after it, and an /s/-like noise band (3.5-9 kHz)
Trials
-4 st, -2 st, 0 st, +2 st, +4 st
Between trials
one trial per shift
Key parameters
downFact 2, frameLen 48, sr 24000, bPitchShift 1, pitchShiftRatio 1.25992
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.downFact2
p.frameLen48
p.sr24000
p.nDelay3
p.bPitchShift1
p.pitchShiftRatio1.25992

A-weighted level (dBA) of each shifted segment relative to the same segment at 0 st (the vocoder on in both). Values on the right are for the voiced stretch, the /ʃ/ and the noise band.

-4 st-7.8, -10.3, -6.4 dBAvoiced stretchreal /ʃ/noise band-2 st-5.5, -17.2, -14.8 dBAvoiced stretchreal /ʃ/noise band+2 st-2.0, -8.6, -10.9 dBAvoiced stretchreal /ʃ/noise band+4 st-5.0, -12.1, -10.8 dBAvoiced stretchreal /ʃ/noise band-20-16-12-8-40heard level re the same segment at 0 st, dBA (grey band: ±1 dB)voiced stretch after it (/i t ɚ/), for comparisonreal /ʃ/ (ARCTIC slt a0036)/s/-like noise band, 3.5-9 kHz
-4 st-7.8, -10.3, -6.4 dBAvoiced stretchreal /ʃ/noise band-2 st-5.5, -17.2, -14.8 dBAvoiced stretchreal /ʃ/noise band+2 st-2.0, -8.6, -10.9 dBAvoiced stretchreal /ʃ/noise band+4 st-5.0, -12.1, -10.8 dBAvoiced stretchreal /ʃ/noise band-20-16-12-8-40heard level re the same segment at 0 st, dBA (grey band: ±1 dB)voiced stretch after it (/i t ɚ/), for comparisonreal /ʃ/ (ARCTIC slt a0036)/s/-like noise band, 3.5-9 kHz

Listen

  • 0 st. the /ʃ/ of "She", vocoder on, no shift 0.3 s

    No playhead: this clip is not drawn in the figure.

  • −4 st. -10.3 dBA 0.3 s

    No playhead: this clip is not drawn in the figure.

  • A/B. 0 st, then −4 st 1.0 s

    No playhead: two clips back to back.

All clips share one playback gain. CMU ARCTIC slt a0036 © 2003 Carnegie Mellon University (licence notice), processed by Audapter.

How big, how often

real /ʃ/Level change at −4 / −2 / +2 / +4 st-17.2 to -8.6 dBA
noise bandLevel change at −4 / −2 / +2 / +4 st-14.8 to -6.4 dBA
voiced stretch (control)Level change at −4 / −2 / +2 / +4 st-7.8 to -2.0 dBA

Level and loudness: for 0 st vs -4 st, the heard level changes by -10.3 dBA and the loudness level by -10.0 to -8.7 phon (ISO 532-1, 60–85 dB SPL): at least 1 phon at every presentation level, above the ~1 dB just-noticeable difference. The loudness level changes less than the A-weighted level because the change also reshapes the spectrum.

All level and loudness estimates for this card, and the assumptions
Comparison (expected → observed)Level, dBALoudness level, phon (ISO 532-1, 60–85 dB SPL)Plain RMS levelNoticeable?
PVOC-NOISE real /S/: 0 st vs -4 st-10.3 dBA-10.0 to -8.7 phon (loudness ratio 0.50–0.55)-9.7 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
PVOC-NOISE real /S/: 0 st vs +2 st-8.7 dBA-8.7 to -7.9 phon (loudness ratio 0.55–0.58)-8.2 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference
PVOC-NOISE /s/-like noise: 0 st vs -4 st-7.0 dBA-7.3 to -6.3 phon (loudness ratio 0.60–0.64)-7.4 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference

dBA: the A-weighted level difference, measured on the audio. Phon: model estimates (ISO 532-1, Zwicker) for a normal-hearing listener on headphones with a flat response, the expected clip presented at 60, 70, 80 and 85 dB SPL (A-weighted) and the observed clip at the same gain. Plain RMS: the unweighted level difference, for reference. Older or clinical listeners and real headphones may differ. How this is computed.

Data: data.json from the export script.

Cause

The phase vocoder's synthesis does not normalise for the phase coherence of the input; its gain depends on the signal (PT-5).

/* Scaling */for (int i = 0; i < pvocFrameLen; ++i)    ftBuf2[2 * i] = 2 * ftBuf2[2 * i] * hWin[i] / (osamp / 2.0);

The synthesis scaling is a constant; the resulting level depends on the signal.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Safe pattern: shift only voiced segments, using OST states, and avoid frameLen 48 with the vocoder; compare shifted and 0 st trials at the same vocoder setting and check their level. Fix: a running level match after synthesis (PT-5).

How we know

Reproduce cd audit/harness && SCEN=pvocnoise ./run-oct.sh report_pitfalls2.m

With the default frame settings, the logged pitch is wrong for voices below about 140 Hz

Not triggered by any blab design we inspected: none uses time-domain pitch shifting, the only mode that logs pitch. Affects time-domain pitch experiments with low voices at the default frame settings.
Relevant blab studies safe as run. Safe as run: the time-domain pitch studies (pitchAdapt, pitchComp, pitchAdaptPD) use frameLen 64 / nDelay 7 with pitch bounds set around each participant's calibrated F0.

What happens

With the time-domain pitch shifter on, Audapter tracks F0 and logs it as pitchHz. Its analysis window is frameLen × nDelay samples, 10 ms with the defaults, which holds too few cycles of a low voice. Below about 140 Hz the tracker returns a higher, wrong value, and the shifter's F0 filter is centred on it.

When it matters

Affects time-domain pitch shifting with the default frameLen 32 / nDelay 5 for typical adult male voices (85–130 Hz) and some low female voices. pitchHz and shiftedPitchHz are then not F0. The heard shift stayed close to target on real speech. With frameLen 64 / nDelay 7 (28 ms), as in Audapter's own time-domain shift demo, it is correct.

Example

Synthetic vowels of known F0, each in a fresh session. With the default window a 90 Hz voice is logged at 229 Hz, 110 Hz at 200 Hz and 130 Hz at 180 Hz; from 140 Hz up the logged value is right. With frameLen 64 / nDelay 7 every F0 from 85 to 250 Hz is right.

Settings used

Source
Hypothetical design: time-domain pitch shifting as in Audapter's time_domain_shift_demo, with blab's male defaults.
Input
synthetic vowels of known F0, 85-250 Hz; real speech in the table
Between trials
each F0 in a fresh session
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('male'):

p.bTimeDomainShift1
p.bCepsLift1
p.pitchLowerBoundHz70
p.pitchUpperBoundHz300
p.frameLen32 (default) or 64
p.nDelay5 (default) or 7

Sent with Audapter('setParam', …):

timeDomainPitchShiftSchedule[0, 2^(1/12); 100, 2^(1/12)] (+1 semitone throughout)

Logged pitchHz against the true F0 of synthetic vowels, for the default and the longer window. Each point is one trial; the axis is F0, not time.

Logged pitchHz against the true F0 of synthetic vowels, default and longer analysis windowWith the default frameLen 32 / nDelay 5 the logged pitch is right from about 140 Hz up and wrong below: 90 Hz is logged as about 229 Hz. With frameLen 64 / nDelay 7 it follows the true F0 everywhere tested.Logged pitchHzmedian over the vowel100 Hz150 Hz200 Hz250 HzF0 85 Hz: logged 235 Hz, 4 % of frames within 5 %F0 90 Hz: logged 229 Hz, 4 % of frames within 5 %F0 100 Hz: logged 208 Hz, 11 % of frames within 5 %F0 110 Hz: logged 200 Hz, 17 % of frames within 5 %F0 120 Hz: logged 186 Hz, 25 % of frames within 5 %F0 130 Hz: logged 180 Hz, 34 % of frames within 5 %F0 140 Hz: logged 140 Hz, 78 % of frames within 5 %F0 150 Hz: logged 151 Hz, 89 % of frames within 5 %F0 160 Hz: logged 160 Hz, 90 % of frames within 5 %F0 170 Hz: logged 170 Hz, 89 % of frames within 5 %F0 180 Hz: logged 180 Hz, 90 % of frames within 5 %F0 200 Hz: logged 200 Hz, 90 % of frames within 5 %F0 220 Hz: logged 219 Hz, 91 % of frames within 5 %F0 250 Hz: logged 250 Hz, 93 % of frames within 5 %default window: wrong below 140 Hz (shaded); frameLen 64 / nDelay 7 (ink) lies on the true F0Frames within 5 %0 %50 %100 %80 Hz100 Hz120 Hz140 Hz160 Hz180 Hz200 Hz220 Hz240 Hz260 Hztrue F0 of the synthetic vowel (Hz)
Logged pitchHz against the true F0 of synthetic vowels, default and longer analysis windowWith the default frameLen 32 / nDelay 5 the logged pitch is right from about 140 Hz up and wrong below: 90 Hz is logged as about 229 Hz. With frameLen 64 / nDelay 7 it follows the true F0 everywhere tested.Logged pitchHz (median over the vowel)100 Hz150 Hz200 Hz250 HzF0 85 Hz: logged 235 Hz, 4 % of frames within 5 %F0 90 Hz: logged 229 Hz, 4 % of frames within 5 %F0 100 Hz: logged 208 Hz, 11 % of frames within 5 %F0 110 Hz: logged 200 Hz, 17 % of frames within 5 %F0 120 Hz: logged 186 Hz, 25 % of frames within 5 %F0 130 Hz: logged 180 Hz, 34 % of frames within 5 %F0 140 Hz: logged 140 Hz, 78 % of frames within 5 %F0 150 Hz: logged 151 Hz, 89 % of frames within 5 %F0 160 Hz: logged 160 Hz, 90 % of frames within 5 %F0 170 Hz: logged 170 Hz, 89 % of frames within 5 %F0 180 Hz: logged 180 Hz, 90 % of frames within 5 %F0 200 Hz: logged 200 Hz, 90 % of frames within 5 %F0 220 Hz: logged 219 Hz, 91 % of frames within 5 %F0 250 Hz: logged 250 Hz, 93 % of frames within 5 %default window: wrong below 140 Hz (shaded);frameLen 64 / nDelay 7 (ink) lies on the true F0Frames within 5 %0 %50 %100 %80 Hz120 Hz160 Hz200 Hz240 Hztrue F0 of the synthetic vowel (Hz)

No recordings for this card: the finding is about logged values, shown in the figure.

How big, how often

syntheticLogged pitchHz at true 90 / 110 / 130 Hz, default window229 / 200 / 180 Hz
syntheticCorrect from (default window / 64 / 7)140 Hz / all F0 tested (≥ 91 % of frames within 5 %)
real speechFrames within 5 % of F0, default window: men / women / children7 / 46 / 65 %
real speechSame with frameLen 64 / nDelay 7: men / women80 / 86 %

Data: data.json from the export script.

Cause

The tracker picks the strongest cepstral peak between the pitch bounds, but the cepstrum comes from the formant tracker's analysis buffer. A pitch period longer than about half the window gives no cepstral peak, so the search returns a spurious one.

const int minCepstralIndex = bTrackPitch ? static_cast<int>(sr / pitchUpperBoundHz) : -1;const int maxCepstralIndex = bTrackPitch ? static_cast<int>(sr / pitchLowerBoundHz) : -1;

The search range follows the pitch bounds, whatever the window can support.

if (bTrackPitch) {    std::vector<std::pair<int, dtype>> indexMags;    for (int i = minCepstralIndex; i < maxCepstralIndex; ++i) {        indexMags.push_back(std::make_pair(            i,            ftBuf2[i * 2] * ftBuf2[i * 2] + ftBuf2[i * 2 + 1] * ftBuf2[i * 2 + 1]));    }    latestPitchHz = smoothPitchTracker.track(indexMags);

The strongest peak in that range becomes latestPitchHz, which is logged as pitchHz.

if (above_rms && p.bTimeDomainShift) {    f0BandpassFilter(        pBuf + (p.nDelay - 1) * p.frameLen + si, f0Buf + si, fmtTracker->getLatestPitchHz(),        static_cast<dtype>(p.sr), p.frameShift);

The estimate centres the time-domain shifter's F0 band-pass filter.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Warn when 2 / pitchLowerBoundHz is longer than the analysis window, or size the pitch analysis from the pitch bounds. Workaround: use frameLen 64 / nDelay 7 for low voices.

How we know

Reproduce cd audit/harness && SCEN="32 5" ./run-oct.sh report_corpus8.m

After a change of frameLen or nDelay in the same session, runFrame can hang forever at 100 % CPU

Not triggered by blab's runners: they change frameLen only together with the sample rate, followed by AudapterIO('init'), which rebuilds the tracker. Affects designs that change frameLen or nDelay at a fixed sample rate in one MATLAB session.
Relevant blab studies safe as run. Safe as run: blab runners change frameLen only together with the sample rate, followed by AudapterIO('init').

What happens

Changing frameLen, nDelay or nWin does not rebuild the formant tracker. The stale tracker can produce NaN LPC coefficients, and the root finder then loops forever because it has no iteration limit. In an experiment this freezes the audio callback and MATLAB.

When it matters

Needs a frame-setting change within one MATLAB session, for example switching between a formant and a time-domain pitch condition with different frame settings. It depends on the audio: of four real clips run through the same switch, one hung. A fresh session with the same settings is fine.

Example

A sung phrase (vocadito_4) with the time-domain pitch shifter, one trial at frameLen 32 / nDelay 5, then AudapterIO('init') with frameLen 64 / nDelay 7 and the same clip again. The second trial's first runFrame call never returns: no frame completes in 62 s at 99–100 % CPU. In a fresh session the same trial processes all 1150 frames in 0.12 s.

Settings used

Source
Hypothetical design: a session that switches from the default frame settings to the longer window recommended for low voices.
Input
vocadito_4 (sung phrase, CC BY 4.0)
Between trials
one trial, then AudapterIO('init') with the new frame settings, then the same clip again
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.bTimeDomainShift1
p.frameLen32, then 64
p.nDelay5, then 7

Progress of the second trial after the switch, against the same trial in a fresh session; both on the wall-clock time since the trial started.

runFrame progress and CPU after a frameLen change, against a fresh sessionIn a fresh session the clip is processed in a fraction of a second. After one trial at frameLen 32 / nDelay 5 and a switch to 64 / 7, the first runFrame call of the next trial never returns: no frame completes and the process runs at 100 % CPU until killed.Fresh sessionall 1150 frames in 0.12 sFresh session: from 0.000 to 0.260 s1150 frames, the whole 4.6 s clip, processed in 0.12 sAfter a frameLen 32 trial0 frames processedAfter a frameLen 32 trial: runFrame never returns from 0.000 to 61.844 srunFrame never returnsCPUhung process0 %50 %100 %0 s10 s20 s30 s40 s50 swall-clock time since the trial started (s)
runFrame progress and CPU after a frameLen change, against a fresh sessionIn a fresh session the clip is processed in a fraction of a second. After one trial at frameLen 32 / nDelay 5 and a switch to 64 / 7, the first runFrame call of the next trial never returns: no frame completes and the process runs at 100 % CPU until killed.Fresh session (all 1150 frames in 0.12 s)Fresh session: from 0.000 to 0.260 s1150 frames, the whole 4.6 s clip, processed in0.12 sAfter a frameLen 32 trial (0 frames processed)After a frameLen 32 trial: runFrame never returns from 0.000 to 61.844 srunFrame never returnsCPU (hung process)0 %50 %100 %0 s10 s20 s30 s40 s50 swall-clock time since the trial started (s)

No recordings: nothing comes out while the process is hung.

How big, how often

real speechFrames completed: fresh session / after the switch1150 / 0
real speechCPU while hung99.4–100.2 %
real speechTime until killed62 s (240 s in an independent re-run)
real speechClips that hang after the same switch1 of 4

Data: data.json from the export script.

Cause

hqr_roots uses the QR iteration from Numerical Recipes without its limit of 30 iterations, so NaN coefficients make it loop forever. The NaNs come from the formant tracker, which framelen, ndelay and nwin do not mark for rebuilding (I-09).

while (nn >= 1) {    its=0;    do {

The per-root iteration counter is reset...

if (its == 10 || its == 20) {    t += x;    for (i = 1; i <= nn; i++)         aMat(i, i) -= x;        /*AHess[(i - 1) * nLPC + i - 1] -= x;*/     s = fabs(aMat(nn, nn - 1)) + fabs(aMat(nn - 1, nn - 2));    /*s = fabs(AHess[(nn - 2) * nLPC + nn - 1]) + fabs(AHess[(nn - 3) * nLPC + nn - 2]);*/     y = x = 0.75 * s;    w = -0.4375 * s * s;} ++its;

...used only for the exceptional shifts at 10 and 20, and incremented, but never checked against a maximum. Numerical Recipes returns an error at its == 30.

getAi(xx, lpcAi); int quit_hqr = hqr_roots(lpcAi, realRoots, imagRoots);

Called on every voiced frame with the coefficients from getAi, which are not checked for NaN.

else if (ns == string("framelen")) {    ptr = (void *)&p.frameLen;    if (bSet && static_cast<int>(*((dtype *)value)) != p.frameLen) {        bRemakePVoc = true;        bRemakeTimeDomainShifter = true;    }}else if (ns == string("ndelay")) {    ptr = (void *)&p.nDelay;}

framelen and ndelay rebuild the phase vocoder and time-domain shifter, but not the formant tracker.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Cap the iterations and skip the frame, check the LPC coefficients with isfinite, and rebuild the tracker when frame settings change. Workaround: after changing frameLen, nDelay or nWin, change nLPC or bCepsLift in a throw-away AudapterIO('init') and restore it, or clear Audapter.

How we know

Reproduce cd audit/harness && oct/report_corpus10.sh 60

Reloading OST/PCF files or rebuilding DSP objects while audio runs can crash MATLAB

Not triggered by blab's runners: every one stops audio before loading files or changing parameters (stop, load, reset, start), which we found race-free. Affects always-on designs that change settings while audio runs.
Relevant blab studies safe as run. Safe as run: no blab runner keeps audio running across trials; files and parameters are changed while audio is stopped.

What happens

In always-on designs the audio keeps running while MATLAB loads a new OST or PCF, or sets parameters that rebuild the pitch shifter, phase vocoder or formant tracker. These operations free and reallocate memory that the audio callback is using at the same moment. The callback then reads a NULL pointer, which crashes MATLAB, or freed memory, which may play garbage or crash later.

When it matters

Affects designs that change OST, PCF, pitchShiftRatio with the time-domain shifter, fn1/fn2, or pvocFrameLen while audio is running, which AudapterIO('init') does in an always-on loop. Stopping audio before these calls avoids it. The usual per-trial start–stop flow is not affected. These numbers come from a simulation of the live path (the real audio code, built with GCC on Linux, with a modelled ASIO driver), so rates are indicative.

Example

A simulated always-on session reloading the PCF every 20 or 250 ms while a vowel plays: 10 crashes in 6605 reloads. Reloading the OST: 13 crashes in 3022 reloads. Each run died after between 1 and 1842 reloads.

Settings used

Source
Hypothetical always-on design, simulated on the live audio path (real audioIO and RtAudio code, simulated ASIO driver).
Input
a looped vowel
Between trials
OST/PCF reloaded or parameters set every 20 or 250 ms while audio runs
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

none

Two threads on one time axis: what the MATLAB thread does to the data, and when the audio callback reads it.

Loading a PCF while the audio callback runsThe MATLAB thread frees the PCF arrays and sets them to NULL, parses, then allocates new ones. A callback that runs in between reads pitchShift[stat] through a NULL pointer, because pertCfg.n still holds the old state count.Loading a PCF while the audio callback runswindow: pitchShift is NULL but n > 0MATLAB threadAudapterIO('pcf')MATLAB thread: read file from 0.100 to 1.700 sread fileMATLAB thread: free → NULL from 1.700 to 3.400 sfree → NULLMATLAB thread: parse the file from 3.400 to 6.100 sparse the fileMATLAB thread: calloc(n) from 6.100 to 7.600 scalloc(n)MATLAB thread: rest of readPertCfg from 7.600 to 9.900 srest of readPertCfgAudio threadcallback every 2 msAudio thread: from 0.200 to 0.750 sAudio thread: from 2.000 to 2.550 sAudio thread: from 3.800 to 4.350 sAudio thread: from 5.600 to 6.150 sAudio thread: from 7.400 to 7.950 sAudio thread: from 9.200 to 9.750 shandleBuffer reads pitchShift[stat]time within one call (schematic, not to scale) →
callback in the window: pertCfg.pitchShift[stat] → NULL read → process dies
ASan: SEGV on unknown address 0x000000000000
      #0 in Audapter::handleBuffer() Audapter.cpp:1972
TSan (OST reload): SEGV ost.cpp:361 in OST_TAB::osTrack
setParam('pitchshiftratio') while the audio callback runssetParam('pitchshiftratio') replaces the time-domain shifter object. The old one is deleted while the callback is still inside its processFrame.setParam('pitchshiftratio') while the audio callback runscallback reads freed memoryMATLAB threadsetParam('pitchshiftratio')MATLAB thread: new TimeDomainShifter(…) from 0.600 to 4.000 snew TimeDomainShifter(…)MATLAB thread: delete old from 4.000 to 5.500 sdelete oldreturn to MATLABAudio threadcallback every 2 msAudio thread: from 0.200 to 0.750 sAudio thread: processFrame on the old object from 3.400 to 6.200 sAudio thread: from 7.400 to 7.950 sAudio thread: from 9.200 to 9.750 shandleBuffer → timeDomainShifter->processFrametime within one call (schematic, not to scale) →
ASan: heap-use-after-free in TimeDomainShifter::genShiftedPitchCycle() time_domain_shifter.cpp:316
      freed by thread T0 in Audapter::setGetParam() Audapter.cpp:1451
also: hqr_roots (lpc_formant.cpp:570); getMode (phase_vocoder.cpp:542)
Reloads until the process died, one mark per runPCF: 10 crashes in 6605 reloads; OST: 13 crashes in 3022 reloadsReloads until the process died, one mark per runPCF reload20 ms apart: 8/8 crashedPCF reload, every 20 ms: crashed after 29 reloadsPCF reload, every 20 ms: crashed after 46 reloadsPCF reload, every 20 ms: crashed after 93 reloadsPCF reload, every 20 ms: crashed after 190 reloadsPCF reload, every 20 ms: crashed after 420 reloadsPCF reload, every 20 ms: crashed after 476 reloadsPCF reload, every 20 ms: crashed after 792 reloadsPCF reload, every 20 ms: crashed after 1842 reloadsPCF reload250 ms apart: 2/6 crashedPCF reload, every 250 ms: crashed after 43 reloadsPCF reload, every 250 ms: crashed after 274 reloadsPCF reload, every 250 ms: run ended after 600 reloads without a crashPCF reload, every 250 ms: run ended after 600 reloads without a crashPCF reload, every 250 ms: run ended after 600 reloads without a crashPCF reload, every 250 ms: run ended after 600 reloads without a crashOST reload20 ms apart: 8/8 crashedOST reload, every 20 ms: crashed after 15 reloadsOST reload, every 20 ms: crashed after 32 reloadsOST reload, every 20 ms: crashed after 59 reloadsOST reload, every 20 ms: crashed after 100 reloadsOST reload, every 20 ms: crashed after 130 reloadsOST reload, every 20 ms: crashed after 196 reloadsOST reload, every 20 ms: crashed after 505 reloadsOST reload, every 20 ms: crashed after 623 reloadsOST reload250 ms apart: 5/6 crashedOST reload, every 250 ms: crashed after 1 reloadsOST reload, every 250 ms: crashed after 15 reloadsOST reload, every 250 ms: crashed after 46 reloadsOST reload, every 250 ms: crashed after 167 reloadsOST reload, every 250 ms: crashed after 533 reloadsOST reload, every 250 ms: run ended after 600 reloads without a crashdot: the run crashed after this many reloads; square: the run ended (600 reloads) without a crash1101001000reloads while audio ran (log scale)
Loading a PCF while the audio callback runsThe MATLAB thread frees the PCF arrays and sets them to NULL, parses, then allocates new ones. A callback that runs in between reads pitchShift[stat] through a NULL pointer, because pertCfg.n still holds the old state count.Loading a PCF while the audio callback runswindow: pitchShift is NULL but n > 0MATLAB thread (AudapterIO('pcf'))MATLAB thread: read file from 0.100 to 1.700 sMATLAB thread: free → NULL from 1.700 to 3.400 sMATLAB thread: parse the file from 3.400 to 6.100 sMATLAB thread: calloc(n) from 6.100 to 7.600 sMATLAB thread: rest of readPertCfg from 7.600 to 9.900 sAudio thread (callback every 2 ms)Audio thread: from 0.200 to 0.750 sAudio thread: from 2.000 to 2.550 sAudio thread: from 3.800 to 4.350 sAudio thread: from 5.600 to 6.150 sAudio thread: from 7.400 to 7.950 sAudio thread: from 9.200 to 9.750 shandleBuffer readspitchShift[stat]time within one call (schematic, not to scale) →
callback in the window: pertCfg.pitchShift[stat] → NULL read → process dies
ASan: SEGV on unknown address 0x000000000000
      #0 in Audapter::handleBuffer() Audapter.cpp:1972
TSan (OST reload): SEGV ost.cpp:361 in OST_TAB::osTrack
setParam('pitchshiftratio') while the audio callback runssetParam('pitchshiftratio') replaces the time-domain shifter object. The old one is deleted while the callback is still inside its processFrame.setParam('pitchshiftratio') while the audiocallback runscallback reads freed memoryMATLAB thread (setParam('pitchshiftratio'))MATLAB thread: new TimeDomainShifter(…) from 0.600 to 4.000 sMATLAB thread: delete old from 4.000 to 5.500 sreturn to MATLABAudio thread (callback every 2 ms)Audio thread: from 0.200 to 0.750 sAudio thread: processFrame on the old object from 3.400 to 6.200 sAudio thread: from 7.400 to 7.950 sAudio thread: from 9.200 to 9.750 shandleBuffer →timeDomainShifter->processFrametime within one call (schematic, not to scale) →
ASan: heap-use-after-free in TimeDomainShifter::genShiftedPitchCycle() time_domain_shifter.cpp:316
      freed by thread T0 in Audapter::setGetParam() Audapter.cpp:1451
also: hqr_roots (lpc_formant.cpp:570); getMode (phase_vocoder.cpp:542)
Reloads until the process died, one mark per runPCF: 10 crashes in 6605 reloads; OST: 13 crashes in 3022 reloadsReloads until the process died, one markper runPCF reload (20 ms apart: 8/8 crashed)PCF reload, every 20 ms: crashed after 29 reloadsPCF reload, every 20 ms: crashed after 46 reloadsPCF reload, every 20 ms: crashed after 93 reloadsPCF reload, every 20 ms: crashed after 190 reloadsPCF reload, every 20 ms: crashed after 420 reloadsPCF reload, every 20 ms: crashed after 476 reloadsPCF reload, every 20 ms: crashed after 792 reloadsPCF reload, every 20 ms: crashed after 1842 reloadsPCF reload (250 ms apart: 2/6 crashed)PCF reload, every 250 ms: crashed after 43 reloadsPCF reload, every 250 ms: crashed after 274 reloadsPCF reload, every 250 ms: run ended after 600 reloads without a crashPCF reload, every 250 ms: run ended after 600 reloads without a crashPCF reload, every 250 ms: run ended after 600 reloads without a crashPCF reload, every 250 ms: run ended after 600 reloads without a crashOST reload (20 ms apart: 8/8 crashed)OST reload, every 20 ms: crashed after 15 reloadsOST reload, every 20 ms: crashed after 32 reloadsOST reload, every 20 ms: crashed after 59 reloadsOST reload, every 20 ms: crashed after 100 reloadsOST reload, every 20 ms: crashed after 130 reloadsOST reload, every 20 ms: crashed after 196 reloadsOST reload, every 20 ms: crashed after 505 reloadsOST reload, every 20 ms: crashed after 623 reloadsOST reload (250 ms apart: 5/6 crashed)OST reload, every 250 ms: crashed after 1 reloadsOST reload, every 250 ms: crashed after 15 reloadsOST reload, every 250 ms: crashed after 46 reloadsOST reload, every 250 ms: crashed after 167 reloadsOST reload, every 250 ms: crashed after 533 reloadsOST reload, every 250 ms: run ended after 600 reloads without a crashdot: the run crashed after this many reloads;square: the run ended (600 reloads) without acrash1101001000reloads while audio ran (log scale)

No recordings: the effect is a crashed process or a read of freed memory.

How big, how often

live simulationPCF reloads until a crash10 crashes in 6605 reloads (about 1 in 660)
live simulationOST reloads until a crash13 crashes in 3022 reloads (about 1 in 232)
live simulationParameter changes read after free (AddressSanitizer)pitchShiftRatio, fn1, pvocFrameLen, PCF reload with the phase vocoder

Data: data.json from the export script.

Cause

Nothing serialises the MATLAB thread and the audio callback. Reading a PCF frees the per-state arrays and sets them to NULL before allocating new ones, while pertCfg.n keeps the old count; the OST reader does the same with stat0. Several parameter setters delete and rebuild DSP objects that the callback may be using.

// Free previously existing fields of ostTabif (pitchShift) {    free(pitchShift);    pitchShift = NULL;}if (intShift) {    free(intShift);    intShift = NULL;}

PERT_CFG::readFromFile frees the arrays first...

n = atoi(items[0].c_str()); if ((pitchShift = (float *)calloc(n, sizeof(float))) == NULL) {    printf("ERROR: failed to allocate memor for pertCfg.pitchShift\n");    return;}

...and allocates the new ones only after parsing the file.

if (pertCfg.n > 0) {    if (stat < pertCfg.n) {        p.pitchShiftRatio[0] = pow(2.0, pertCfg.pitchShift[stat] / 12.0);        intShiftRatio = pow(10, pertCfg.intShift[stat] / 20.0);

The callback reads it on every frame while n > 0: the ASan crash site.

// Free previously existing fields of ostTabif (stat0) {    free(stat0);    stat0 = NULL;}

The OST reader frees and NULLs stat0 the same way...

for (i = 0; i < n; i++) {    if (stat >= stat0[i] && stat < stat0[i + 1]) {

...which osTrack reads every frame: the TSan SEGV site.

else if (ns == string("pitchshiftratio")) {    ptr = (void *)p.pitchShiftRatio;    if (bSet) {        if (nPars != p.nFB) {            mexErrMsgTxt("Erroneous length of input delayFrames");        }        bRemakeTimeDomainShifter = true;    }

Setting pitchshiftratio always marks the time-domain shifter for rebuilding...

if (bRemakeTimeDomainShifter) {    // TODO(cais): Check incompatible parameter values, e.g., p.nFB > 1.    timeDomainShifter.reset(        new audapter::TimeDomainShifter(            p.timeDomainPitchShiftAlgorithm,            p.sr, p.frameLen, p.timeDomainPitchShiftSchedule));}

...and the old object is deleted while the callback may be inside it.

if ( bRemakePVoc ) {    PhaseVocoder::operMode t_operMode;    if ( !pVocs.empty() ) {        t_operMode = pVocs[0]->getMode();        pVocs.clear();    }

The same for the phase vocoders (pvocFrameLen, pvocHop, nFB); readPertCfg clears them on every PCF load (line 3205).

AudapterIO('ost', ost, [], 0);if DEBUG_PS    fprintf(pitchShiftLogF, 'Done.\n');end if DEBUG_PS    fprintf(pitchShiftLogF, 'Loading pcf file: %s...\n', ost); % DEBUGendAudapterIO('pcf', pcf, [], 0);

ALWAYS_ON: both files are reloaded for every trial while audio runs.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Refuse ost, pcf and object-rebuilding parameter changes while audio runs, or build into temporaries and swap them in under a lock the callback also takes. Workaround: stop audio before loading files or changing these parameters.

How we know

  • D live simulation driver, plain -O2 build: live/run.sh crash results/crash.txt plus the earlier run kept in live/work/out/crash_*.txt, pooled
  • D live simulation driver, ASan build: live/run.sh uaf heap-use-after-free for pitchshiftratio, fn1, pvocFrameLen and PCF reload with pvoc
  • D ThreadSanitizer inventory: live/run.sh tsan SEGV at ost.cpp:361 during OST reload; 195 distinct racing site pairs
  • D report export (crash counts, sanitizer lines): report/live/export_live.py live12

Reproduce audit/live/run.sh crash uaf tsan && python3 audit/report/live/export_live.py live12

reset() while the participant is speaking can send a loud, clipped click to the headphones

Not triggered by blab's per-trial flow, which calls reset() while audio is stopped. Affects designs that call reset() with audio running while the participant may be speaking.
Relevant blab studies safe as run. Safe as run: no blab runner keeps audio running across trials, so reset() is never called during speech.

What happens

Audapter('reset') runs on the MATLAB thread while the audio callback may be filtering a frame. It clears the resampling filters' state in the middle of that frame, and the filter can then ring for a few milliseconds far above the speech level. The output is clipped at the sound card, so the participant hears a click.

When it matters

Affects designs that keep audio running across trials and call reset while the participant may be voicing, such as always-on designs that reset at each trial start. It happens on roughly 1 in 1000–2000 resets during voicing. The size varies from run to run; the observed range is +17 to +61 dB above the loudest normal output sample. The usual start–stop–reset flow per trial is not affected. These numbers come from a simulation of the live path (the real audio code, built with GCC on Linux, with a modelled ASIO driver), so rates are indicative.

Example

A looped vowel with reset called about every 62 ms for 120 s: 1945 resets, 1014 of them during voicing. One produced a burst of +17.0 dB above the normal peak, 6.5 ms long, with 21 samples beyond full scale. Every other reset during voicing produced only a 6 ms dropout.

Settings used

Source
Hypothetical always-on design, simulated on the live audio path.
Input
a looped vowel
Between trials
reset() about every 62 ms for 120 s while audio runs
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

none

Output around the reset that raced the callback, and around another reset at the same point of the vowel; time relative to the reset.

Headphone signal around a reset() that raced the audio callbackTime 0 is where the output falls silent because reset() cleared Audapter's buffers. Normally the output then resumes at its usual level (thin line, another reset at the same point of the vowel). Here the resampling filter state was torn and the output jumps to 1.22 times digital full scale (+17.0 dB above the loudest normal sample) for about 6.5 ms.Inputmic, looped vowelInputOutput peak1 ms blocks0 dBFS-20 dBFS-40 dBFSloudest normal sample, -15.3 dBFSreset(): output silent for 6 msburst: +17.0 dB re the normal peak, 6.5 ms, clipped-60-40-200+20+40+60time relative to the reset (ms)Waveform of the burst, clipped at full scaleSample by sample: the burst swings to full scale in both directions; 21 samples exceed it and are clipped.Headphone signalevery sampledigital full scalenormal peakanother reset, no burstthe racing reset21 samples beyond full scale-40+4+8+12time relative to the reset (ms)
Headphone signal around a reset() that raced the audio callbackTime 0 is where the output falls silent because reset() cleared Audapter's buffers. Normally the output then resumes at its usual level (thin line, another reset at the same point of the vowel). Here the resampling filter state was torn and the output jumps to 1.22 times digital full scale (+17.0 dB above the loudest normal sample) for about 6.5 ms.Input (mic, looped vowel)InputOutput peak (1 ms blocks)0 dBFS-20 dBFS-40 dBFSloudest normal sample, -15.3 dBFSreset(): outputsilent for 6 msburst: +17.0 dB re thenormal peak, 6.5 ms,clipped-60-40-200+20+40+60time relative to the reset (ms)Waveform of the burst, clipped at full scaleSample by sample: the burst swings to full scale in both directions; 21 samples exceed it and are clipped.Headphone signal (every sample)digital full scalenormal peakanother reset, no burstthe racing reset21 samples beyondfull scale-40+4+8+12time relative to the reset (ms)

Listen

  • Input. the looped vowel around the reset 1.0 s

    No playhead: this clip is not drawn in the figure.

  • Expected. Output around another reset at the same point of the vowel: no click 1.0 s

    No playhead: this clip is not drawn in the figure.

  • Observed. Output around the reset that raced the callback 1.0 s

    Loud click. Turn the volume down first.

    No playhead: this clip is not drawn in the figure.

The clips share one playback gain, set so the burst clip peaks at −1 dBFS; the other clips play at their true level relative to it, so the speech is quiet. 48 kHz, as it reaches the sound card, clipped at full scale. Synthetic only: the live-path simulation was run with this vowel; re-running it needs the live rig simulation, not the offline harness.

How big, how often

live simulationResets during voicing / bursts, this run1014 / 1
live simulationBurst peak re the loudest normal sample, this run+17.0 dB
live simulationBurst peak over three independent runs+17 to +61 dB
live simulationRate over three runsabout 1 in 1000–2000 resets during voicing
live simulationOutput after an ordinary reset during voicing6 ms dropout

Data: data.json from the export script.

Cause

Nothing serialises reset() with the audio callback. reset() zeroes the 48 ↔ 16 kHz resampling filters' delay lines while the callback may be inside IIR_Filter::filter; the remaining state no longer cancels and the high-order filter rings.

void Audapter::reset(){// resets all     int i0,j0;    bTransReset             = true;     bFrameLenShown          = false; //*****************************************************  BUFFERS   *****************************************************    // Initialize input, output and filter buffers (at original sample rate!!!)    for(i0 = 0; i0 < maxFrameLen * downSampFact_default; i0++) {        inFrameBuf[i0] = 0.0;        downSampBuffer[i0] = 0.0;        upSampBuffer[i0] = 0.0;    }     downSampFilter.reset();    upSampFilter.reset();

reset() zeroes the resampling filter states. Nothing stops the callback from running at the same time.

void reset() {          for (int i = 0; i < filtLen - 1; ++i) {        delay[i] = 0.0;    }}

The reset clears the delay line element by element...

for(m = 0; m < nr; m++) {    y[m] = g * b[0] * x[m] + delay[0];    for(k = 0; k < filtLen - 2; k++) {// start delay recursion        delay[k] = g * b[k + 1] * x[m] + delay[k + 1] - a[k + 1] * y[m];    }       delay[filtLen - 2] = g * b[filtLen - 1] * x[m] - a[filtLen - 1] * y[m]; }

...while the callback's filter loop reads and writes the same delay line for every sample.

// initialize recording countersframe_counter=0;frame_counter_nowarp = 0;data_counter=0;

The frame counters are cleared last. A callback between the two counter increments leaves the new trial with an offset frame clock.

case 6:             //SC manually reset audapter object    audapter.reset();    break;

The MEX 'reset' action calls it directly, whether or not audio is running.

if uiConfig.trialStartWithAnim == 0    if handles.bAlwaysOn == 1        AudapterIO('reset');

The always-on caller: every trial start while audio runs.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Put one lock around every MEX action that changes Audapter state; the callback tries the lock and outputs silence if it is busy. Or have reset set a flag that the callback acts on at the next frame. Workaround: stop audio before reset, or reset only while the participant is silent.

How we know

  • D live simulation driver (fake ASIO, float64 samples): live/run.sh burst 1 burst in 1945 resets in the run shown; independent reproductions: 2 in 1901 (+42.9, +48.8 dB) and 1 in 1955 (+61.1 dB); race-free control max 0.171
  • D live simulation driver: live/run.sh resetcheck 1 of 1000 always-on trial starts with a zeroed first data row
  • D ThreadSanitizer inventory: live/run.sh tsan 356 reports, 195 distinct site pairs; reset ↔ IIR_Filter::filter among them
  • D report export (burst, dropouts, comparison reset, clips): report/live/export_live.py live3 checks its excerpt against results/reset_race_burst.wav

Reproduce audit/live/run.sh burst && python3 audit/report/live/export_live.py live3

If the sound card's buffer is not exactly frameLen × downFact samples, the participant hears their raw voice an octave up, chopped

Could affect any rig, including blab's: it happens whenever the sound card's buffer size differs from frameLen × downFact (96 samples at blab's settings). blab's Audapter README warns that newer Focusrite drivers crash MATLAB; whether that is related is untested.
Relevant blab studies n.a.. Depends on the rig, not the study: any study whose sound-card buffer differs from 96 samples at blab's settings.

What happens

Audapter needs audio buffers of exactly frameLen × downFact samples (96 by default). If the sound card delivers another size, Audapter silently skips processing, and the headphones get an uninitialised copy of the microphone signal read at the wrong rate: the raw voice an octave up, silent in half of every buffer.

When it matters

Affects rigs whose ASIO driver does not grant the requested buffer size; RtAudio then takes the driver's preferred size without telling Audapter. Nothing warns, and getData returns almost nothing. With a matching buffer size everything is normal. These numbers come from a simulation of the live path (the real audio code, built with GCC on Linux, with a modelled ASIO driver), so rates are indicative.

Example

A 128-sample device buffer instead of 96: over 752 callbacks, Audapter processed nothing, getData returned 1 row, and the participant heard the unprocessed voice decimated by 2 and gated on and off 375 times a second, -2.3 dBA relative to the microphone.

Settings used

Source
blab's default frame settings (frameLen 32, downFact 3: 96 samples per buffer) on a sound card that delivers 128-sample buffers, simulated on the live audio path.
Input
a vowel
Between trials
one trial
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

none

Three 128-sample device buffers: what the callback does with each, and what reaches the headphones.

Three 128-sample device buffers with Audapter's 96-sample frameWith a 128-sample device buffer, handleBuffer returns without processing. Each buffer then plays the raw mic samples of two buffers earlier, read as interleaved stereo: the left ear gets every second sample in the first half of the buffer (double speed, one octave up) and silence in the second half, 375 times a second.Device bufferDevice buffer: buffer 225: 128 samples from 0.600 to 0.603 sbuffer 225: 128 samplesDevice buffer: buffer 226: 128 samples from 0.603 to 0.605 sbuffer 226: 128 samplesDevice buffer: buffer 227: 128 samples from 0.605 to 0.608 sbuffer 227: 128 sampleshandleBufferAudapter.cpp:1667handleBuffer: 128 ≠ 96: returns at once from 0.600 to 0.603 s128 ≠ 96: returns at oncehandleBuffer: 128 ≠ 96: returns at once from 0.603 to 0.605 s128 ≠ 96: returns at oncehandleBuffer: 128 ≠ 96: returns at once from 0.605 to 0.608 s128 ≠ 96: returns at onceMic5.33 ms earlierMicExpectedbuffer 96: Audapter outputExpectedHeardbuffer 128, left earHeardfirst half of each buffer: the raw mic at double speed; second half (shaded): silence600.0602.7605.3608.0time in the trial (ms); one device buffer = 2.67 ms
Three 128-sample device buffers with Audapter's 96-sample frameWith a 128-sample device buffer, handleBuffer returns without processing. Each buffer then plays the raw mic samples of two buffers earlier, read as interleaved stereo: the left ear gets every second sample in the first half of the buffer (double speed, one octave up) and silence in the second half, 375 times a second.Device bufferDevice buffer: buffer 225: 128 samples from 0.600 to 0.603 sDevice buffer: buffer 226: 128 samples from 0.603 to 0.605 sDevice buffer: buffer 227: 128 samples from 0.605 to 0.608 shandleBuffer (Audapter.cpp:1667)handleBuffer: 128 ≠ 96: returns at once from 0.600 to 0.603 shandleBuffer: 128 ≠ 96: returns at once from 0.603 to 0.605 shandleBuffer: 128 ≠ 96: returns at once from 0.605 to 0.608 sMic (5.33 ms earlier)MicExpected (buffer 96: Audapter output)ExpectedHeard (buffer 128, left ear)Heardfirst half of each buffer: the raw mic at doublespeed; second half (shaded): silence600.0602.7605.3608.0time in the trial (ms); one device buffer = 2.67 ms

Listen

  • Input. the vowel at the microphone 1.7 s

    No playhead: this clip is not drawn in the figure.

  • Expected. Output with a 96-sample device buffer (= frameLen × downFact): Audapter's processed output 2.0 s

    No playhead: this clip is not drawn in the figure.

  • Observed. Output with a 128-sample device buffer: what the participant hears (left channel) 2.0 s

    harsh buzz: the sound is gated on and off 375 times a second

    No playhead: this clip is not drawn in the figure.

All clips share one playback gain, so level differences you hear are real. 48 kHz, 16-bit, the signal at the sound card (left channel; the right channel gets the odd-numbered mic samples). The expected clip is the same trial with a 96-sample buffer. Synthetic only: the live-path simulation was run with this vowel.

How big, how often

live simulationDevice buffer / Audapter frame128 / 96 samples
live simulationCallbacks / rows returned by getData752 / 1
live simulationHeard level re microphone-2.3 dBA, gated at 375 Hz

Data: data.json from the export script.

Cause

audioIO::startdev requests frameLen·downFact samples and passes the variable to RtAudio, which may change it; nobody compares the result. handleBuffer then sees the wrong size and returns at once, leaving RtAudio's output buffer holding the microphone data.

if ( *bufferSize < minSize ) *bufferSize = minSize;else if ( *bufferSize > maxSize ) *bufferSize = maxSize;else if ( granularity == -1 ) {  // Make sure bufferSize is a power of two.  double power = log10( (double) *bufferSize ) / log10( 2.0 );  *bufferSize = (int) pow( 2.0, floor(power+0.5) );  if ( *bufferSize < minSize ) *bufferSize = minSize;  else if ( *bufferSize > maxSize ) *bufferSize = maxSize;  else *bufferSize = preferSize;

Granularity −1: the requested size is replaced by the driver's preferred size.

buffer_size = algosize; //int(floor(input_dev->fs*0.008));//printf("startdev: buffer_size = %d\n", buffer_size);try {    if (audio == NULL)        //printf("startdev: creating new audio object with buffer_size = %d\n", buffer_size);        audio = new RtAudio(output_dev->num, output_dev->chans, input_dev->num, input_dev->chans,FORMAT, input_dev->fs, &buffer_size, 1);

buffer_size is passed by pointer and may come back changed; it is never checked against algosize.

if (frame_size != p.downFact * p.frameLen)  //SC This ought to be satisfied. Just for safeguard.    return 1;

"Just for safeguard": the callback returns without touching the output buffer.

// Invoke user callback first, to get fresh output data.if ( info->usingCallback ) {  RtAudioCallback callback = (RtAudioCallback) info->callback;  if ( callback(stream_.userBuffer, stream_.bufferSize, info->userData) )    handle->stopStream = true;}

The callback runs before the new input is copied in, so the untouched user buffer still holds the previous period's mono input, which is then played.

devpar.fs = *((int *) audapter.getParam("srate")) * t_downFact; //SC device sampling rate devpar.num = activeDeviceNum;devpar.chans = 1;audio_obj.setdevparams(&devpar, 1);devpar.num = activeDeviceNum;devpar.chans = 2;audio_obj.setdevparams(&devpar, 2);

One input channel, two output channels: the mono input samples are read as interleaved left/right pairs.

else{    plhs[0] = mxCreateDoubleMatrix(1,1, mxREAL);    plhs[1] = mxCreateDoubleMatrix(1,1, mxREAL);    plhs[2] = mxCreateDoubleMatrix(1,1, mxREAL);}

With no frames processed, getData returns 1 × 1 and writes a third output (I-11).

Present since at least Audapter 2.1.5.

Safe pattern and fix

After opening the device, fail if the buffer size differs from what Audapter needs, and make Audapter('start') report the failure with the required size. Workaround: set the ASIO driver's buffer to exactly frameLen × downFact.

How we know

  • D live simulation driver (fake ASIO, buffer 128) and JACK dummy server (period 128): live/run.sh mismatch heard signal = raw-input model; getData 1 × 1, plhs[2] written
  • D report export (model check, levels, clips; JACK model check added here): report/live/export_live.py live6

Reproduce audit/live/run.sh equiv mismatch && python3 audit/report/live/export_live.py live6

Shifted formant targets have no upper bound, so a unit mix-up produces targets far above Nyquist and loud, clipped output

Not triggered by current blab designs, which set bRatioShift 0 and bMelShift 1 explicitly for 125 mel shifts. Affects replays of old parameter files under today's defaults, where bRatioShift is 1.
Relevant blab studies n.a.. Not applicable: current blab designs set bRatioShift 0 and bMelShift 1 explicitly.

What happens

Audapter does not limit a shifted formant to the range a filter can represent. If the perturbation amplitude is given in the wrong units, for example mel values read as ratios, the target can be tens of kilohertz and the output becomes loud and distorted.

When it matters

Happens when an old parameter set in mel units (with no bRatioShift field) is merged into today's defaults, which are ratio shift, or with any mistaken pertAmp. The C++ defaults (absolute, mel) and the MATLAB defaults (ratio) differ, which makes this easy. The logged sfmts show the absurd targets afterwards.

Example

Audapter's own 2008 example trial diao1_female, replayed with its saved parameters (mel-unit pertAmp up to 164) merged into today's defaults. In mel mode, as in 2008, F1 is raised ×1.214. Under today's ratio default F1 is multiplied by 102.8, targets reach 129992 Hz with a 6000 Hz Nyquist frequency, and the output is +13.3 dBA louder than the input, peaking at 4.6 times full scale.

Settings used

Source
Audapter's own 2008 example trial (example_data diao1_female), replayed with its saved parameters merged into today's defaults.
Input
blab_diao1_female
Between trials
one trial per shift mode
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.nLPC11
p.fn1646
p.fn21511
p.downFact4
p.frameLen16
p.dScale1
p.sr12000
p.nDelay7
p.frameShift16
p.bufLen208
p.anaLen208
p.bCepsLift1
p.cepsWinWidth28
p.rmsThresh0.0104895
p.rmsRatioThresh1.3
p.rmsMeanPeak0.041958
p.bShift1
p.trialLen2.5
p.rampLen0.25
p.F2Min1000
p.F2Max2000
p.F1Min507.824
p.F1Max1336.96
p.pertF2257 values, 1000 to 2000
p.pertAmp257 values, -9.09495e-13 to 163.787

One trial: tracked F1 and the shifted F1 target in mel mode and in ratio mode, on a log frequency axis.

Shifted F1 target in ratio mode (today's MATLAB default) and in mel mode (as in 2008)With the 2008 parameters under today's ratio default, the shifted F1 target is about 100 times the tracked F1, far above the Nyquist frequency; under mel mode it is about 1.2 times, as the 2008 session logged.F1 (log scale)thin: tracked and mel300 Hz1 kHz10 kHz100 kHzNyquist, 6 kHz: no formant can exist above this lineratio mode (blue): F1 × 103, targets up to 130 kHz; thin lines: tracked F1 and the mel-mode target0 s0.5 s1 s1.5 s2 s2.5 stime in trial (s)
Shifted F1 target in ratio mode (today's MATLAB default) and in mel mode (as in 2008)With the 2008 parameters under today's ratio default, the shifted F1 target is about 100 times the tracked F1, far above the Nyquist frequency; under mel mode it is about 1.2 times, as the 2008 session logged.F1 (log scale) (thin: tracked and mel)300 Hz1 kHz10 kHz100 kHzNyquist, 6 kHz: no formant can exist above this lineratio mode (blue): F1 × 103, targets up to 130kHz; thin lines: tracked F1 and the mel-modetarget0 s0.5 s1 s1.5 s2 s2.5 stime in trial (s)

Listen

  • Input. the 2008 recording (Mandarin syllable) 2.7 s

    No playhead: this clip is not drawn in the figure.

  • Expected. Output with mel shift, as in the 2008 session 2.7 s

    No playhead: this clip is not drawn in the figure.

  • Observed. Output with the same parameters under today's ratio default 2.7 s

    Very loud: the ratio-mode output is about +13 dBA above the input and clips; the clip is scaled down so its peak is at -1 dBFS.

    No playhead: this clip is not drawn in the figure.

All clips share one playback gain, chosen so the loud ratio-mode clip peaks at −1 dBFS; the others are therefore quiet. Real recording from Audapter's example_data (diao1_female.mat, MIT licence, © 2006–2021 Audapter Authors), processed by Audapter for this report (modified). 12 kHz internal rate. The input is itself a real recording: Audapter's own 2008 example trial.

How big, how often

real recordingF1 ratio: mel mode / ratio mode / the 2008 session's log1.214 / 102.8 / 1.223
real recordingHighest F1 target, ratio mode129992 Hz (Nyquist 6000 Hz)
real recordingOutput re input, ratio mode+13.3 dBA, peak 4.56 × full scale

Data: data.json from the export script.

Cause

The shift is f1 × (1 + pertAmp·cos φ) in ratio mode or f1 + pertAmp·cos φ otherwise, and is converted to a filter angle without checking that it lies below Nyquist.

if (!p.bRatioShift){    // Absolute shift                       sf1m = f1m + mamp * cos(mphi);  // Shifting imposed    sf2m = f2m + mamp * sin(mphi);}else{   // Ratio shift                      //mamp=p.pertAmp[locint]+locfrac*(p.pertAmp[locint+1]-p.pertAmp[locint]);                       sf1m = f1m * (1 + mamp * cos(mphi));    sf2m = f2m * (1 + mamp * sin(mphi));} if (p.bMelShift){    newPhis[0]=mel2hz(sf1m)/p.sr*2*M_PI;    // Convert back to Hz    newPhis[1]=mel2hz(sf2m)/p.sr*2*M_PI;}else{    newPhis[0]=sf1m/p.sr*2*M_PI;    newPhis[1]=sf2m/p.sr*2*M_PI;}

No bound on sf1m or sf2m before they become filter angles.

p.bRatioShift       = 0;    //SC(2009/01/20)p.bMelShift         = 1;    //SC(2009/01/20)

C++ defaults: absolute (mel) shift.

p.bRatioShift   = 1;    %0 = using a shift in Hz or mels. 1 = the shift is a ratio of the current formant value (e.g. 1.3*current value).p.bMelShift     = 0;    %0 = specifying the shift in Hz.  1 = specifying the shift in mels.

MATLAB defaults: ratio shift.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Clamp or reject targets outside (0, 0.45·sr), and warn at load when ratio mode meets |pertAmp| above about 2. Workaround: set bRatioShift and bMelShift explicitly whenever old parameter files are reused.

How we know

Reproduce cd audit/harness && ./run-oct.sh report_corpus11.m

Reprocessing one recording under two conditions applies the second condition to the first condition's output

Not triggered by blab's offline helpers (audapter_viewer, check_audapterLPC), which build fresh frames for every run. Affects offline reprocessing that reuses one frame array for several conditions.
Relevant blab studies safe as run. Safe as run: blab's offline helpers (audapter_viewer, check_audapterLPC) build fresh frames for every run.

What happens

Audapter('runFrame', x) writes its output back into the array it is given. A script that runs the same stored frames through Audapter twice, for two conditions, feeds the first condition's output into the second.

When it matters

Affects offline reprocessing that reuses the same MATLAB variables, for example the cell array of frames in test_audapter.m, and anything else sharing that data. Scripts that build fresh frames for each run are unaffected. Live experiments are unaffected.

Example

One stored /a/ (F1 735 Hz) processed as condition 1 (F1 +20 %) and then condition 2 (F1 −20 %) from the same cell array. Condition 2 is heard at 715 Hz instead of 599 Hz, because it lowered F1 of an already raised vowel. Its logged input starts with 128 zero samples, the first condition's output delay.

Settings used

Source
Hypothetical design: a plausible design, not one blab runs; blab's default settings (getAudapterDefaultParams) unless listed.
Input
synthetic vowel
Between trials
offline: the same frame array passed to runFrame for two conditions
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.rmsThresh0.005

Two runs of the same stored recording, one after the other: condition 1, then condition 2 from the same frames (observed) or from fresh frames (expected).

Condition 2 (F1 -20 %) run on fresh frames vs on the sigInCell already used for condition 1 (F1 +20 %)Run on the reused cell array, condition 2 tracks F1 at 889 Hz (condition 1's output) instead of 748 Hz and targets 711 Hz instead of 598 Hz; the heard F1 is 715 Hz, close to the unshifted input (735 Hz), instead of 599 Hz.Inputstored /a/ recordingInputF1 trackedcondition 2, fmts600 Hz900 HzF1 targetcondition 2, sfmts600 Hz900 Hztarget from the reused frames: 711 Hz (blue); from fresh frames: 598 Hz (thin line). The trackersees condition 1's raised F1.0 s0.2 s0.4 s0.6 s0.8 s1 s1.2 stime in trial (s)
Condition 2 (F1 -20 %) run on fresh frames vs on the sigInCell already used for condition 1 (F1 +20 %)Run on the reused cell array, condition 2 tracks F1 at 889 Hz (condition 1's output) instead of 748 Hz and targets 711 Hz instead of 598 Hz; the heard F1 is 715 Hz, close to the unshifted input (735 Hz), instead of 599 Hz.Input (stored /a/ recording)InputF1 tracked (condition 2, fmts)600 Hz900 HzF1 target (condition 2, sfmts)600 Hz900 Hztarget from the reused frames: 711 Hz (blue);from fresh frames: 598 Hz (thin line). Thetracker sees condition 1's raised F1.0 s0.2 s0.4 s0.6 s0.8 s1 s1.2 stime in trial (s)

Listen

  • Input. /a/ 1.3 s

    No playhead: this clip is not drawn in the figure.

  • Observed. Condition 1: F1 +20 % 1.3 s

    No playhead: this clip is not drawn in the figure.

  • Expected. Condition 2 from fresh frames: F1 −20 % 1.3 s

    No playhead: this clip is not drawn in the figure.

  • Observed. Condition 2 from the reused frames: the shifts cancel 1.3 s

    No playhead: this clip is not drawn in the figure.

All clips share one playback gain. 16 kHz, 16-bit, as recorded by Audapter. Synthetic only: the effect is exact sample reuse, the same for any input; the figure's numbers come from this vowel.

How big, how often

syntheticCondition 2 heard F1: fresh frames / reused frames599 / 715 Hz
syntheticCondition 2 logged F1 (tracker): fresh / reused748 / 889 Hz
syntheticLeading zero samples in condition 2's logged input: fresh / reused0 / 128

Data: data.json from the export script.

Cause

The MEX gateway passes the pointer to MATLAB's input array straight to audapterCallback, which calls handleBuffer with the same pointer as input and output. MATLAB arrays that share data with the variable are changed too.

data_ptr = (double*)mxGetPr(prhs[1]);   //SC pointer to buffer audapter.actionMode = Audapter::PROC_AUDIO_INPUT_OFFLINE;audapterCallback((char *)data_ptr, frame_len * t_downFact, (void *)&audapter);

The MATLAB input array (prhs[1]) is handed over as the audio buffer, without a copy.

if (audapter->actionMode == Audapter::PROC_AUDIO_INPUT_ONLINE)    audapter->handleBuffer((dtype *)buffer, (dtype *)buffer, buffer_size, false);if (audapter->actionMode == Audapter::PROC_AUDIO_INPUT_OFFLINE)    audapter->handleBuffer((dtype *)buffer, (dtype *)buffer, buffer_size, true);

Input and output are the same buffer.

if (bSingleOutputBuffer) {    for (n = 0; n < frame_size; n++) {        outFrame_ptr[n] = outputBuf[n];    }

The processed frame overwrites it.

for n = 1 : length(sigInCell)    Audapter('runFrame', sigInCell{n});end

The pattern in the bundled scripts (also audapterDemo_triphthong.m:90, time_domain_shift_demo.m:109): fine once, wrong if sigInCell is used again.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Copy the input in the gateway before processing, and return the output as a result. Workaround: pass a copy, e.g. Audapter('runFrame', frame + 0), or rebuild the frames for each condition.

How we know

  • H harness test: t_inplace.m FAIL “runFrame leaves input variable untouched”: max|change| 0.0297
  • H determinism with input copies (NEG-1): t_repeat.m PASS “repeat: identical signalOut”: max|diff| 0
  • H report export: two conditions, fresh vs reused frames: report_i04.m condition 2 on the reused cell array tracks condition 1's output
  • H interface check: scratch/interface/t_iface.m T4 the input and an alias of it were both modified

Reproduce cd audit/harness && ./run-oct.sh report_i04.m

In feedback mode 5, the speech-modulated noise is scaled by dScale twice, so the noise mix changes with the rig's calibration

Not triggered: no blab experiment we inspected uses fb 5, which was added to blab in August 2026. Affects any fb 5 design with dScale other than 1.
Relevant blab studies affected. Affected: sponAdapt3 uses fb 5 (the only blab study that does, apart from a demo); its speech-to-noise balance depends on dScale.

What happens

Feedback mode 5 plays a constant noise plus a noise that follows the speaker's loudness. The speech-modulated part is multiplied by dScale twice and the constant part once, so their balance moves by 20·log10(dScale) dB instead of staying at fb5GainDB_speech.

When it matters

Affects fb 5 only. dScale comes from closedLoopGain and the lab's calibration, so two setups, or one setup at two gains, get different mixes from the same parameter. A different microphone sensitivity is compensated by the input level, so the error follows closedLoopGain. fb 4 multiplies by dScale twice as well, but has only one component, so it is a level offset there. The logged fb5GainDB_speech is the nominal value.

Example

fb 5 with babble noise and fb5GainDB_speech +20 dB. At closedLoopGain 15 dB (dScale 0.795) the speech-modulated part sits -0.7 dB relative to the constant part; at 21 dB (dScale 1.587) it sits +5.3 dB. The 6 dB gain step raises the constant noise by 6 dB and the speech-modulated noise by 12 dB.

Settings used

Source
Hypothetical design: a plausible design, not one blab runs; blab's default settings (getAudapterDefaultParams) unless listed.
Input
synthetic vowels
Between trials
one trial per setting (fb 5 with dScale 1 and 2)
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.rmsThresh0.005

One trial per setting; the balance between the two components, expected and observed.

fb 5: level of the speech-modulated component relative to the constant component, at two closedLoopGain settingsWith the same input and parameters, the speech-modulated component sits -2.0 dB off the intended mix at closedLoopGain 15 dB and +4.0 dB off at 21 dB: the mix moves by 6.0 dB.Inputthree vowelsInputMix at 15 dBdScale 0.795-6 dB0 dB6 dB-2.0 dB off the intended mix (thin line: the same run at dScale 1, where scaling once or twice is thesame)Mix at 21 dBdScale 1.587-6 dB0 dB6 dB+4.0 dB off the intended mix (thin line: the same run at dScale 1, where scaling once or twice is thesame)0 s1 s2 s3 s4 stime in trial (s)
fb 5: level of the speech-modulated component relative to the constant component, at two closedLoopGain settingsWith the same input and parameters, the speech-modulated component sits -2.0 dB off the intended mix at closedLoopGain 15 dB and +4.0 dB off at 21 dB: the mix moves by 6.0 dB.Input (three vowels)InputMix at 15 dB (dScale 0.795)-6 dB0 dB6 dB-2.0 dB off the intended mix (thin line: the samerun at dScale 1, where scaling once or twice isthe same)Mix at 21 dB (dScale 1.587)-6 dB0 dB6 dB+4.0 dB off the intended mix (thin line: the samerun at dScale 1, where scaling once or twice isthe same)0 s1 s2 s3 s4 stime in trial (s)

Listen

  • Input. vowels 4.0 s

    No playhead: this clip is not drawn in the figure.

  • Observed. fb 5 at closedLoopGain 15 dB 4.0 s

    No playhead: this clip is not drawn in the figure.

  • Observed. fb 5 at closedLoopGain 21 dB: the speech-modulated noise grows twice as much 4.0 s

    No playhead: this clip is not drawn in the figure.

All clips share one playback gain. Heard output at the device rate, decimated to 16 kHz.

Run it in your browser

This runs the same input, babble and parameters as the harness through the real Audapter C++ core compiled to WebAssembly from the pinned commit, as released and with the one-line fix below, at the two dScale values. With the fix the balance no longer moves. Feedback mode 4 is unchanged.

The change in the “fix-i-02” build: report/patches/i-02-dscale-once.patch
Audapter.cppdtype speech_modulated_component = data_pb[pbCounter] * rms_fb * p.fb5Gain_speech * p.dScale;dtype speech_modulated_component = data_pb[pbCounter] * rms_fb * p.fb5Gain_speech;  /* audit fix I-02: no * p.dScale here; upSampSig applies dScale to the whole output once (fb 4 left as upstream) */

How big, how often

syntheticMix (speech-modulated re constant), dScale 1+1.28 dB
syntheticMix at closedLoopGain 15 / 21 dB-0.71 / +5.29 dB
syntheticLevel step for a 6 dB gain step: constant / speech-modulated6 / 12 dB
real speechMix change at dScale 0.795 / 1.587-2.0 / +4.0 dB

Level and loudness: for closedLoopGain 15 vs 21 dB, the heard level changes by +9.1 dBA and the loudness level by +8.7 to +9.3 phon (ISO 532-1, 60–85 dB SPL): at least 1 phon at every presentation level, above the ~1 dB just-noticeable difference. Level and loudness agree here: the change is close to a pure gain.

All level and loudness estimates for this card, and the assumptions
Comparison (expected → observed)Level, dBALoudness level, phon (ISO 532-1, 60–85 dB SPL)Plain RMS levelNoticeable?
I-02 fb 5 output: closedLoopGain 15 vs 21 dB+9.1 dBA+8.7 to +9.3 phon (loudness ratio 1.83–1.91)+9.1 dBat least 1 phon at every presentation level, above the ~1 dB just-noticeable difference

dBA: the A-weighted level difference, measured on the audio. Phon: model estimates (ISO 532-1, Zwicker) for a normal-hearing listener on headphones with a flat response, the expected clip presented at 60, 70, 80 and 85 dB SPL (A-weighted) and the observed clip at the same gain. Plain RMS: the unweighted level difference, for reference. Older or clinical listeners and real headphones may differ. How this is computed.

Data: data.json from the export script.

Cause

fb 5 builds its speech-modulated component as data_pb · rms_fb · fb5Gain_speech · dScale and its constant component without dScale. upSampSig then multiplies the whole output by dScale again.

else if (p.fb == 5) { // Mode 5: constant amplitude playback + speech-modulated playback    dtype playback_component = data_pb[pbCounter] * p.fb5Gain_playback;    dtype speech_modulated_component = data_pb[pbCounter] * rms_fb * p.fb5Gain_speech * p.dScale;    outFrameBufSum[n + p.pvocFrameLen - p.frameLen] = speech_modulated_component + playback_component;}

fb 5: dScale on the speech-modulated term only.

upSampSig(upSampFilter, outFrameBufSum + p.pvocFrameLen - p.frameLen,           outputBuf, p.frameLen * p.downFact,           p.downFact, p.dScale);

Every output frame then goes through upSampSig with dScale as its scale factor...

usFilt.filter(upSampBuffer, r, nr, upfact * scalefact);

...which multiplies the frame by it.

else if (p.fb == 4) // Speech-modulated noise       outFrameBufSum[n + p.pvocFrameLen - p.frameLen] = data_pb[pbCounter] * rms_fb * p.fb4Gain * p.dScale;

fb 4 has the same extra factor; with one component it only offsets the level.

p.dScale        = 10 ^ ((p.closedLoopGain - calcClosedLoopGain) / 20);

dScale follows closedLoopGain and the rig calibration.

% feedback mode 5 is continuous playback (noise) + speech-amplitude modulated playback (noise)p.fb5GainDB_speech = 0.0;   % gain multiplier (in dB) for the speech-amplitude modulated playback component. Actual scaling factor is 10^(p.fb5GainDB_speech / 20). So if p.fb5GainDB is set to 0, the gain multiplier is set to 1.p.fb5Gain_playback = 1.0;   % gain multiplier (linear scaling factor) for the constant playback component

The documented meaning of fb5GainDB_speech says nothing about dScale.

Introduced in the blab-lab fork.

Safe pattern and fix

Drop * p.dScale from the fb 5 speech-modulated term (report/patches/i-02-dscale-once.patch, one line). Workaround: set fb5GainDB_speech to the intended value minus 20·log10(dScale).

How we know

Reproduce cd audit/harness && ./run-oct.sh report_i02.m

A trial longer than 30 s loses its beginning, and the OST, trialLen mute and onset ramp restart at 30 s

Not triggered: blab's longest recorded trials are 4.5 s (vsaSentence). Affects trials longer than 30 s, for example sustained phonation or passage reading with an OST.
Relevant blab studies n.a.. Not applicable: blab's longest recorded trials are 4.5 s (vsaSentence).

What happens

Audapter's recorders hold 30 s at 16 kHz. When they are full, Audapter silently restarts its frame and data counters and keeps recording over the start. getData then returns only the part since the restart, and everything driven by those counters starts again: OST timing, the trialLen mute and the onset ramp.

When it matters

Affects any trial longer than 30 s, such as reading passages, continuous speech or long always-on blocks. Time-based OST rules after the restart may never fire. Shorter trials are unaffected.

Example

A 35 s trial with a vowel every second, an OST that should enter state 2 at 32 s, trialLen 34 s and a 50 ms ramp. getData returns 5.0 s (2500 rows), starting at 30 s. State 2 never comes. At 30 s the onset ramp restarts mid-vowel (-5.2 dB), and nothing is muted after 34 s.

Settings used

Source
Hypothetical design: a plausible design, not one blab runs; blab's default settings (getAudapterDefaultParams) unless listed.
Input
synthetic vowels over 35 s
Between trials
one 35 s trial
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.rmsThresh0.005
p.trialLen34
p.rampLen0.05
OST file
rmsSlopeWin = 0.030000window for the level-slope estimate n = 33 rules follow0 ELAPSED_TIME 10 NaN {}state 0 → 1: moves on 10 s after this state began; does not record its end time1 ELAPSED_TIME 22 NaN {}state 1 → 2: moves on 22 s after this state began; does not record its end time2 OST_END NaN NaN {}state 2: final state n = 0no maxIOI timeouts
PCF file
00 time-warp sections 33 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0, 0, 0, 0state 0: no change1, 0, 0, 0, 0state 1: no change2, 0, 0, 0, 0state 2: no change

One 35 s trial: what happened, and what getData returns afterwards.

A 35 s trial: what happened, and what getData returnsThe 35 s trial ran, but getData returns only 30–35 s. The OST entered state 1 at 10.0 s and never reached state 2, due at 32 s. The output was not muted after trialLen = 34 s.0–30 s not returned (signalIn, signalOut, ost_stat, fmts, …)not muted after trialLen (34 s)Inputa vowel every secondInputTrialwhat was runTrial: 35 s of speech and data from 0.000 to 35.000 s35 s of speech and datagetData returnsafter the trialgetData returns: 5.0 s from 30.000 to 35.000 s5.0 s0–30 s not returned (signalIn, signalOut, ost_stat, fmts, …)OST stateELAPSED_TIME 10 s, 22 sOST state: state 0 from 0.000 to 10.000 sstate 0OST state: state 1 (e.g. perturbation on) from 10.000 to 32.000 sstate 1 (e.g. perturbation on)OST state: state 2 from 32.000 to 35.000 sstate 2OST statewhat Audapter didOST state: state 0 from 0.000 to 10.010 sstate 0OST state: state 1 from 10.010 to 30.000 sstate 1OST state: state 1 from 30.000 to 35.000 sstate 1state 2, due at 32 s, never comes (checked to 65 s)HeardtrialLen 34 sHeardrecorder full at 30 s: counters restartnot muted after trialLen (34 s)0 s5 s10 s15 s20 s25 s30 s35 stime in trial (s)Heard output around the recorder wrapAt the wrap the 50 ms onset ramp restarts from zero in the middle of a vowel.onset ramp re-applied: -5.2 dB over 50 ms, mid-vowelHeard29.995–30.060 sonset ramp re-applied: -5.2 dB over 50 ms, mid-vowel30.000 s30.010 s30.020 s30.030 s30.040 s30.050 stime in trial (s)
A 35 s trial: what happened, and what getData returnsThe 35 s trial ran, but getData returns only 30–35 s. The OST entered state 1 at 10.0 s and never reached state 2, due at 32 s. The output was not muted after trialLen = 34 s.0–30 s not returned (signalIn, signalOut, ost_stat, fmts, …)not muted after trialLen (34 s)Input (a vowel every second)InputTrial (what was run)Trial: 35 s of speech and data from 0.000 to 35.000 s35 s of speech and datagetData returns (after the trial)getData returns: 5.0 s from 30.000 to 35.000 s5.0 s0–30 s not returned (signalIn, signalOut,ost_stat, fmts, …)OST state (ELAPSED_TIME 10 s, 22 s)OST state: state 0 from 0.000 to 10.000 sstate 0OST state: state 1 (e.g. perturbation on) from 10.000 to 32.000 sstate 1 (e.g. perturbation on)OST state: state 2 from 32.000 to 35.000 sOST state (what Audapter did)OST state: state 0 from 0.000 to 10.010 sstate 0OST state: state 1 from 10.010 to 30.000 sstate 1OST state: state 1 from 30.000 to 35.000 sstate 2, due at 32 s, never comes (checkedto 65 s)Heard (trialLen 34 s)Heardrecorder full at 30 s: counters restartnot muted after trialLen (34 s)0 s5 s10 s15 s20 s25 s30 s35 stime in trial (s)Heard output around the recorder wrapAt the wrap the 50 ms onset ramp restarts from zero in the middle of a vowel.onset ramp re-applied: -5.2 dB over 50 ms, mid-vowelHeard (29.995–30.060 s)onset ramp re-applied: -5.2 dB over 50 ms,mid-vowel30.000 s30.010 s30.020 s30.030 s30.040 s30.050 stime in trial (s)

Listen

  • Observed. Heard, 27-35 s of the trial (trialLen 34 s, rampLen 0.05 s) 8.0 s

    At 30.00 s the onset ramp restarts (a 50 ms fade-in mid-vowel), and the output is not muted at 34 s.

    No playhead: this clip is not drawn in the figure.

Heard output from 27 to 35 s: the fade-in at 30 s and the missing mute at 34 s. Synthetic only: the finding is about a 35 s recording, longer than any clip in the open corpus.

How big, how often

syntheticRecorder size / wrap time480000 samples / 30 s at 16000 Hz
syntheticReturned by getData after a 35 s trial5.0 s
syntheticState 2 (due at 32 s)never reached
syntheticOnset ramp at 30 s / mute after trialLen-5.2 dB / not muted

Data: data.json from the export script.

Cause

After each frame, handleBuffer checks whether the recorder is full and, if so, sets frame_counter and data_counter to 0. getSignal and getData return only the first frame_counter frames, and the OST, mute and ramps read the same counters.

if (((frame_counter)*p.frameLen>=maxRecSize) || ((data_counter+p.nWin)>=maxDataSize)) //avoid segmentation violation{       frame_counter=0;    data_counter=0;    

The silent wrap: both counters go back to 0; no flag is set.

static const int maxRecSize = 480000;static const int maxDataSize = 480000;

480000 samples: 30 s at 16 kHz.

const dtype* Audapter::getSignal(int & size) const {    size = frame_counter*p.frameLen;    //SC Update size    return signal_recorder[0];          //SC return the

getData returns only what was recorded since the last wrap.

if ( (p.trialLen > 0.0) &&      (static_cast<dtype>(frame_counter) * static_cast<dtype>(p.frameLen) / static_cast<dtype>(p.sr) > p.trialLen) ) {           for (n = 0; n < frame_size * nMult; ++n) {        outFrame_ptr[n] = 0.0;    } }if ( (p.trialLen > 0.0) && (p.rampLen > 0.0) &&      (static_cast<dtype>(frame_counter) * static_cast<dtype>(p.frameLen) / static_cast<dtype>(p.sr) <= p.rampLen) ) {    for (n = 0; n < frame_size * nMult; ++n)        outFrame_ptr[n] *= static_cast<dtype>((frame_counter - 1) * frame_size + n) / static_cast<dtype>(p.sr) / static_cast<dtype>(p.downFact) / p.rampLen;}

The trialLen mute and the onset ramp use frame_counter as the trial clock.

if (t_mode == ELAPSED_TIME) { // (+1) Elapsed time from previous state. prm1: duration (s)    if ((data_counter - statOnsetIndices[stat]) * frameDur > prm1[k]) {        stat_out = stat + 1;        statOnsetIndices[stat_out] = frame_counter;

ELAPSED_TIME compares the restarted data_counter with a state onset stored before the wrap, so the difference stays negative.

Present since at least Audapter 2.1.5; the recorder grew from 14.4 s to 30 s in the blab-lab fork (commit 3c90a3a).

Safe pattern and fix

Stop recording at the end of the buffer but keep a separate trial clock, and report an overflow through getData. At minimum, have AudapterIO('getData') warn when the returned length is shorter than the time since reset. Workaround: keep trials under 30 s.

How we know

  • H sanitizer scenario (35.2 s trial): t_mem.m SCEN=long input 35.2 s, getData returned 5.20 s
  • H interface check: scratch/interface/t_wrap.m after 31 s of input, signalIn has 16000 samples (1.0 s)
  • H report export (OST, trialLen, heard output): report_i03.m 35 s trial with OST, trialLen and ramps
  • F CBMC on the verbatim recorder index code: formal/cbmc/ring.c the related recorder overrun (I-12): the write overflows exactly when frameLen does not divide 480000 and a trial reaches about 30 s; of common sizes only frameLen 512 is affected (others: 9, 18, 36, 45, 72, 90, 144, 180, …); 16, 32, 48, 64, 80, 96, 120, 128, 160, 192, 256 are safe (FORMAL-5)

Reproduce cd audit/harness && ./run-oct.sh report_i03.m

Written the way the manual describes, the INTENSITY_AND_RATIO OST rules have no hold time

Not triggered: no blab OST we inspected uses the AND_RATIO rules. Affects OSTs written with the manual's {} placeholder in field 5.
Relevant blab studies n.a.. Not applicable: no blab OST we inspected uses the AND_RATIO rules.

What happens

The INTENSITY_AND_RATIO_ABOVE_THRESH and _BELOW_THRESH rules (modes 40 and 45) read their hold time from the fifth field of the OST line, where the file format puts {}. {} reads as 0, so the rule fires on the second qualifying frame, without the intended debounce.

When it matters

Affects OST files that use modes 40 or 45 with {} in field 5, as the manual's format suggests. With a number in field 5 the hold works. How much the missing hold matters depends on the thresholds and the speaker: with the thresholds used here, the rule fired early on a synthetic fricative, but on real /sh/ onsets it fired in only 2 of 4 sentences and the held version in none, so a lab's own thresholds decide the effect.

Example

OST 0 INTENSITY_AND_RATIO_ABOVE_THRESH 0.01 1.5 <field 5> on a synthetic "sa", with +10 dB in state 2. With field 5 = 0.05 the rule holds 25 frames and state 2 starts at 0.486 s; with {} it holds 1 frame and starts at 0.438 s. A 20 ms click before the word triggers state 2 at 0.116 s with {}; with the hold, the click is ignored.

Settings used

Source
Hypothetical design: a plausible design, not one blab runs; blab's default settings (getAudapterDefaultParams) unless listed.
Input
synthetic /s/-like noise onset followed by a vowel
Between trials
one trial per OST variant
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.rmsThresh0.005
OST file
rmsSlopeWin = 0.030000window for the level-slope estimate n = 22 rules follow0 INTENSITY_AND_RATIO_ABOVE_THRESH 0.01 1.5 {}state 0 → 2: level above 0.01 and ratio above 1.5 for the hold in field 5 ({} s)2 OST_END NaN NaN {}state 2: final state n = 0no maxIOI timeouts
OST file, safe variant
rmsSlopeWin = 0.030000window for the level-slope estimate n = 22 rules follow0 INTENSITY_AND_RATIO_ABOVE_THRESH 0.01 1.5 0.05state 0 → 2: level above 0.01 and ratio above 1.5 for the hold in field 5 (0.05 s)2 OST_END NaN NaN {}state 2: final state n = 0no maxIOI timeouts
PCF file
00 time-warp sections 33 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0, 0, 0, 0state 0: no change1, 0, 0, 0, 0state 1: no change2, 0, 10, 0, 0state 2: level +10 dB

One trial per setting: OST state with field 5 = 0.05 (expected) and with {} (observed), without and with a click before the word.

INTENSITY_AND_RATIO_ABOVE_THRESH with the hold in field 5 (0.05) vs the documented {}With the hold written in field 5, state 2 begins at 0.486 s, 86 ms into the /s/. With the documented {} in field 5 it begins at 0.438 s. With a 20 ms click 0.3 s before the word, {} lets the click trigger state 2 at 0.116 s; with the hold the click enters state 1 and falls back.state 2 begins 48 ms early: the 50 ms hold is 0the 20 ms click alone triggers state 2, 0.28 s before the /s/Input"sa"Input/s//a/OST statefield 5 = 0.05OST state: state 0 from 0.000 to 0.440 sstate 0OST state: state 1 from 0.440 to 0.490 sOST state: state 2 · +10 dB from 0.490 to 1.200 sstate 2 · +10 dBOST statefield 5 = {}OST state: state 0 from 0.000 to 0.440 sstate 0OST state: state 2 · +10 dB from 0.440 to 1.200 sstate 2 · +10 dBstate 2 begins 48 ms early: the 50 ms hold is 0Inputclick + "sa"Inputclick/s//a/OST statefield 5 = 0.05OST state: state 0 from 0.000 to 0.120 sstate 0OST state: state 1 from 0.120 to 0.160 sOST state: state 0 from 0.160 to 0.440 sstate 0OST state: state 1 from 0.440 to 0.490 sOST state: state 2 · +10 dB from 0.490 to 1.200 sstate 2 · +10 dBOST statefield 5 = {}OST state: state 0 from 0.000 to 0.120 sstate 0OST state: state 2 · +10 dB from 0.120 to 1.200 sstate 2 · +10 dBthe 20 ms click alone triggers state 2, 0.28 s before the /s/0 s0.2 s0.4 s0.6 s0.8 s1 s1.2 stime in trial (s)
INTENSITY_AND_RATIO_ABOVE_THRESH with the hold in field 5 (0.05) vs the documented {}With the hold written in field 5, state 2 begins at 0.486 s, 86 ms into the /s/. With the documented {} in field 5 it begins at 0.438 s. With a 20 ms click 0.3 s before the word, {} lets the click trigger state 2 at 0.116 s; with the hold the click enters state 1 and falls back.state 2 begins 48 ms early: the 50 ms hold is 0the 20 ms click alone triggers state 2, 0.28 s before the /s/Input ("sa")Input/s//a/OST state (field 5 = 0.05)OST state: state 0 from 0.000 to 0.440 sstate 0OST state: state 1 from 0.440 to 0.490 sOST state: state 2 · +10 dB from 0.490 to 1.200 sstate 2 · +10 dBOST state (field 5 = {})OST state: state 0 from 0.000 to 0.440 sstate 0OST state: state 2 · +10 dB from 0.440 to 1.200 sstate 2 · +10 dBstate 2 begins 48 ms early: the50 ms hold is 0Input (click + "sa")Inputclick/s//a/OST state (field 5 = 0.05)OST state: state 0 from 0.000 to 0.120 sOST state: state 1 from 0.120 to 0.160 sOST state: state 0 from 0.160 to 0.440 sstate 0OST state: state 1 from 0.440 to 0.490 sOST state: state 2 · +10 dB from 0.490 to 1.200 sstate 2 · +10 dBOST state (field 5 = {})OST state: state 0 from 0.000 to 0.120 sOST state: state 2 · +10 dB from 0.120 to 1.200 sstate 2 · +10 dBthe 20 ms click alone triggers state 2, 0.28s before the /s/0 s0.2 s0.4 s0.6 s0.8 s1 s1.2 stime in trial (s)

Listen

  • Input. a click, then "sa" 1.2 s

    No playhead: this clip is not drawn in the figure.

  • Expected. Field 5 = 0.05: +10 dB from the /s/ 1.2 s

    No playhead: this clip is not drawn in the figure.

  • Observed. Field 5 = {}: +10 dB from the click 1.2 s

    No playhead: this clip is not drawn in the figure.

  • Observed. Field 5 = {}, no click: +10 dB 48 ms early 1.2 s

    No playhead: this clip is not drawn in the figure.

All clips share one playback gain. 16 kHz, 16-bit, as recorded by Audapter.

Run it in your browser

Planned: type field 5 ({}, 0.02, 0.05, 0.1) and the click level, and see and hear when state 2 starts, on the released build and on one that rejects a non-numeric field 5 for modes 40 and 45 at load.

How big, how often

syntheticState 2 onset: field 5 = 0.05 / {}0.486 / 0.438 s
syntheticFrames held before firing25 / 1
syntheticWith a 20 ms click before the word0.486 / 0.116 s
real speechSentences starting with /sh/ where the rule fired: hold / {}0 of 4 / 2 of 4

Data: data.json from the export script.

Cause

Modes 40 and 45 take their hold from field 5. To allow that, the parser no longer checks that field 5 is {}, and atof("{}") returns 0. The verbose printout does not show the value.

for (i0 = 0; i0 < n; i0++) {    items = removeComments(splitStringToVector(*(++lit)), commentChar);    if (items.size() != 5)        throw ostFileSyntaxError(*lit);

Only the field count is checked; field 5 is not required to be {}.

prm1[i0] = atof(items[2].c_str());prm2[i0] = atof(items[3].c_str());prm3[i0] = atof(items[4].c_str()); if (bVerbose)    printf("\tSeg %d: stat0=%d; mode=%d; prm1=%f; prm2=%f\n",            i0, stat0[i0], mode[i0], prm1[i0], prm2[i0]);

atof("{}") is 0, without an error. The verbose line prints prm1 and prm2 only.

else {    minDurN = (int)floor(prm3[k] / frameDur + 0.5);     if ((rms_s > prm1[k]) && (1. / rms_ratio > prm2[k])) {        stretchCnt++;        if (stretchCnt > minDurN) {            stat_out = stat + 1;            statOnsetIndices[stat_out] = frame_counter;

The hold for mode 40 (mode 45 at line 702 is the same). With prm3 = 0 the second qualifying frame advances.

Introduced in the blab-lab fork with modes 40 and 45 (commit 9039c07).

Safe pattern and fix

Give modes 40 and 45 an explicit parameter layout: reject a non-numeric field 5 for them at load, keep the {} check for other modes, document the hold, and print it in the verbose summary. Workaround: put the hold time in field 5.

How we know

  • H harness test: t_ost.m FAIL “OST F8: AND_RATIO field-4 hold (50 ms) honoured”: onset 0.200; with {}: 0.226 s, with field5=0.05: 0.274 s
  • H report export (fricative + click, audible): report_ost_f8.m modes 40 and 45 exist only in this version
  • D standalone driver of ost.cpp: scratch/ost-pcf/t/drv.cpp hold field 5 = 0.05: 25 frames; {}: 1 frame

Reproduce cd audit/harness && ./run-oct.sh report_ost_f8.m

Further pitfalls

PT-2 / I-15 Medium H

The RMS-clip loudness protection mutes a stale 2 ms slot instead of the loud frame

What happens. When the frame RMS exceeds rmsClipThresh, the code zeroes the start of the circular output buffer instead of the current frame, so loud output passes through unchanged.

When it matters. Matters only for experiments that rely on bRMSClip for participant safety. It is off by default on the MATLAB side, and the MATLAB setters for it are commented out, so data.params reports the C++ default (on), not what the experiment script asked for.

if (rms_s > p.rmsClipThresh && p.bRMSClip == 1){    //SC(2009/02/06) RMS clipping protection    for(n = 0; n < frame_size / p.downFact; n++){        outFrameBuf[n]=0;    }

Present since at least Audapter 2.1.5.

Fix. Zero the frame at outFrameBuf_circPtr, and restore the MATLAB setters. Do not rely on this protection until then.

How we know. Harness (scratch/pitch-time/rmsclip.m): threshold 0.01, a 0.5-amplitude sine; output unchanged. Audit notes

PT-3 Medium H

The time-domain pitch-shift schedule runs on voiced time only, so shift onsets come later after every pause

What happens. The time-domain shifter's schedule clock advances only on frames above the RMS threshold. A schedule written in trial time runs late by the total time spent below threshold, and frames below threshold pass through unshifted.

When it matters. Affects time-domain pitch shifting with a time-varying schedule on speech with pauses. A 168 ms dip below threshold moved a 1.04 s onset to 1.20 s.

if (above_rms && p.bTimeDomainShift) {    f0BandpassFilter(        pBuf + (p.nDelay - 1) * p.frameLen + si, f0Buf + si, fmtTracker->getLatestPitchHz(),        static_cast<dtype>(p.sr), p.frameShift);    timeDomainShifter->processFrame(        f0Buf + si, pBuf + (p.nDelay - 1) * p.frameLen + si, outBuf, p.frameShift);}

const dtype nowSec = static_cast<dtype>(totalLen) / sr;

Present since at least Audapter 2.1.5.

Fix. Advance the schedule clock on every frame, or document that the schedule is in voiced time.

How we know. Harness (scratch/pitch-time/tdsched.m). Audit notes

PT-4 Medium H

The logged pitchShiftRatio is wrong whenever the time-domain shifter is used

What happens. The pitchShiftRatio data column logs p.pitchShiftRatio[0], which the time-domain shifter does not use; it follows its own schedule. During a +100 cent time-domain shift the log says 1.0.

When it matters. Affects every analysis that reads the logged ratio from time-domain pitch-shift sessions. With a PCF loaded, the PCF's pitch values are logged although the shifter ignores them.

offs++;data_recorder[offs][data_counter] = p.pitchShiftRatio[0];

Present since at least Audapter 2.1.5.

Fix. Log the ratio the shifter actually applies.

How we know. Harness: the logged column during a scheduled +100 cent shift. Audit notes

PT-6 Medium R

A PCF with both a pitch shift and a time warp silently drops the warp

What happens. When a PCF specifies both a pitch shift and a time warp, the phase vocoder is set to pitch-shift-only: the mode that combines both cannot be reached, because its condition repeats another branch's.

When it matters. Affects PCFs that combine pitch shift and time warp. The warp is not applied and nothing warns.

if (pertCfg.warpCfg.size() > 0 && !bIsPitchShift) {    pVocMode = PhaseVocoder::TIME_WARP_ONLY;}else if (pertCfg.warpCfg.size() == 0 && bIsPitchShift) {    pVocMode = PhaseVocoder::PITCH_SHIFT_ONLY;}else if (pertCfg.warpCfg.size() > 0 && !bIsPitchShift) {    pVocMode = PhaseVocoder::TIME_WARP_WITH_FIXED_PITCH_SHIFT;}

Present since at least Audapter 2.1.5.

Fix. Change the third condition to warp > 0 && bIsPitchShift.

How we know. By reading. Audit notes

FMT-F4 Medium R

Clamped formants are logged during silence, where no filtering happened

What happens. The clamp branch turns the perturbation on without checking that the frame is voiced. The logged sfmts show clamp targets in silent and unvoiced frames where the formant filter did not run, and the clamp trajectory advances through silence.

When it matters. Affects clamped-formant experiments; analyses that read sfmts as "what was applied" count silent frames.

if (p.bClampFormants && stat >= p.clamp_osts[0] && stat < p.clamp_osts[1] && p.clamp_f1[clampIx] != 0) {        // taimComp    newPhis[0] = p.clamp_f1[clampIx] * 2 * M_PI / p.sr; // This gets converted back to Hz when sFmts is set    newPhis[1] = p.clamp_f2[clampIx] * 2 * M_PI / p.sr;    during_trans = true;    // TODO(CWN) this is relatively hacky. Should probably just have some way to circumvent during_trans check in L1868    if ((clampIx < maxNClampFrames-2) && (p.clamp_f1[clampIx+1] != 0)) {        clampIx += 1;

Introduced in the blab-lab fork.

Fix. Check above_rms in the clamp branch, and advance the trajectory only on voiced frames if that is the intent.

How we know. By reading. Audit notes

I-09 Medium R

Changing ndelay, frameLen or nWin does not rebuild the formant tracker

What happens. These setters do not mark the formant tracker for rebuilding, so it keeps its old analysis window.

When it matters. AudapterIO('init') usually also changes fn1/fn2, which triggers a rebuild, but a direct Audapter(3, 'ndelay', …) leaves a stale window, and in one real case a stale tracker caused a hang (CORPUS-10).

else if (ns == string("framelen")) {    ptr = (void *)&p.frameLen;    if (bSet && static_cast<int>(*((dtype *)value)) != p.frameLen) {        bRemakePVoc = true;        bRemakeTimeDomainShifter = true;    }}else if (ns == string("ndelay")) {    ptr = (void *)&p.nDelay;}else if (ns == string("nwin")) {    ptr = (void *)&p.nWin;}

Present since at least Audapter 2.1.5.

Fix. Set bRemakeFmtTracker in these setters.

How we know. By reading; see CORPUS-10 for a consequence. Audit notes

CORPUS-3 Medium H

There is no preset for young children: the female preset underestimates their F2 by about 12 %

What happens. For 6–7-year-old children the female preset (nLPC 15) tracks F2 11.9 % low on average, with gross errors on 18 % of frames. With nLPC 11 the error is 1.7–2.0 %.

When it matters. Affects formant experiments with young children (F0 above about 220 Hz); a field or shift indexed by F2 is applied at the wrong place in the vowel space. Children aged 10–12 are tracked well with the female preset.

case 'male'           p.nLPC          = 17;    p.fn1           = 591;    p.fn2           = 1314;case 'female'    p.nLPC          = 15;   %SC-Mod(2008/02/08) Used to be 9    p.fn1           = 675;    p.fn2           = 1392;otherwise    error('specify sex (male / female');

Present since at least Audapter 2.1.5.

Fix. Add a child preset with nLPC 11, or document the choice. Workaround: set nLPC to 11 for young children.

How we know. Harness on 8 real child recordings (harness/oct/corpus_track.m, nLPC sweep); reference = frames where Praat and an independent LPC agree. Audit notes

LIVE-4 Medium D

Audapter('start') reports success when the sound card failed to open

What happens. Audapter('start') ignores the return value of the device open and marks audio as started. With a device that cannot run at 48 kHz, start returns normally, no audio flows, and getData returns a 1 × 1 result.

When it matters. Affects rigs where the driver rejects the sample rate, buffer or format. The error text goes to stdout, which on Windows is not the MATLAB command window. The playback and tone actions behave the same.

if (!started) {    mexPrintf("Action Start:  %d\n", action);    audapter.reset();           //SC Reset audio device params    audio_obj.startdev();   //SC Start audio device. Callback function will be automatically called}else {    mexPrintf("Already started\n");}started = 1;

catch (RtError &error) {    error.printMessage();    //    exit(EXIT_FAILURE);    return 1;}

Present since at least Audapter 2.1.5.

Fix. Return the open result and raise a MATLAB error on failure.

How we know. Live simulation (live/run.sh startfail): a driver without 48 kHz; start OK, 0 callbacks, getData 1 × 1. Live-path simulation: the real audioIO/RtAudio ASIO code built with GCC on Linux and driven by a fake ASIO driver; rates are indicative, not Windows-exact. Audit notes

LIVE-5 Medium D

With no ASIO driver installed, the first Audapter call of any kind crashes, even for offline processing

What happens. The audio object inside the MEX is built on the first call of any action. When no audio device exists, its constructor catches the error and then calls a method on a NULL pointer.

When it matters. Affects analysis machines without an audio interface: offline reprocessing with runFrame crashes MATLAB at the first AudapterIO('init').

try {    audio = new RtAudio();}  catch (RtError &error) {    error.printMessage();}int num_devices = audio->getDeviceCount();

static Audapter audapter;       //SC audapter is a Audapter class object. Notice it is a static pointer.static audioIO audio_obj;   //SC Refer to the audioIO project

Present since at least Audapter 2.1.5.

Fix. Create the audio object only when an audio action needs it, and check for NULL.

How we know. Live simulation, AddressSanitizer build (live/run.sh noasio): SEGV in RtAudio::getDeviceCount() called from audioIO.cpp:27. Live-path simulation: the real audioIO/RtAudio ASIO code built with GCC on Linux and driven by a fake ASIO driver; rates are indicative, not Windows-exact. Audit notes

PB-DSCALE Low H R

playWave plays a stored recording without dScale, so a participant hears it about 2 dBA louder than their own live feedback (a pure gain)

What happens. Live feedback is multiplied by dScale (from the closed-loop-gain calibration) before it reaches the headphones, but Audapter('playWave') sends datapb unscaled. A participant's own recording played back therefore differs in level from what they heard while speaking by −20·log10(dScale): +2.0 dB with the bundled calibration.

When it matters. Matters for speak-versus-listen designs (speech-induced suppression, where listening trials replay the speaking trials) and any comparison of self-produced feedback with played-back speech. Safe if the recording is multiplied by dScale before it is loaded as datapb.

int Audapter::handleBufferWavePB(dtype *inFrame_ptr, dtype *outFrame_ptr, int frame_size)   // Wave playback{   // Wave playback    int n;     for(n=0;n<frame_size;n++){        //outFrame_ptr[n]=data_pb[pbCounter];        outFrame_ptr[2 * n] = outFrame_ptr[2 * n + 1] = data_pb[pbCounter];

Playback writes the stored samples as they are.

upSampSig(upSampFilter, outFrameBufSum + p.pvocFrameLen - p.frameLen,           outputBuf, p.frameLen * p.downFact,           p.downFact, p.dScale);

Live feedback is scaled by dScale on the way out.

Present since at least Audapter 2.1.5.

Fix. Scale datapb by dScale before playWave, or apply dScale in the playback path.

How we know. Harness (report_pitfalls2.m SCEN=pbdscale): with the bundled calibration (dScale 0.7953) the live feedback of a real sentence leaves the device at −1.99 dB re the microphone signal; playWave sends the stored samples unscaled, so playback is +1.99 dB relative to live feedback. The playback path itself runs only on the live audio path, so that half is by reading. Audit notes

REINIT-PARAMS Low H

Re-initialising Audapter from saved data.params fails, or, once the names are fixed up, silently drops the time-domain pitch settings

What happens. data.params is Audapter's own parameter dump: it uses Audapter's names, logs rmsFF_fb empty, and leaves out the time-domain pitch settings. Feeding it back into AudapterIO('init') either fails or, after the names are mapped, rebuilds a different configuration without warning.

When it matters. Matters for offline re-analysis or OST re-tuning tools that replay trials with the saved parameters. Safe if the re-analysis starts from the experiment's own parameter struct (for example expt.audapterParams) rather than data.params.

Audapter(3,'nfmts',p.nFmts, toPrompt);Audapter(3,'ntracks',p.nTracks, toPrompt);        Audapter(3,'scale',p.dScale, toPrompt);Audapter(3,'preemp',p.preempFact, toPrompt);

AudapterIO reads MATLAB field names (dScale, preempFact, ...); data.params stores Audapter names (scale, preemp, ...).

if (isfield(p, 'rmsFF_fb'))    Audapter(3, 'rmsff_fb', p.rmsFF_fb, toPrompt);

rmsFF_fb is logged empty, so it has to be refilled.

Present since at least Audapter 2.1.5.

Fix. Save the struct passed to AudapterIO with the data, or make getData log MATLAB-style names and every parameter.

How we know. Harness (report_pitfalls2.m SCEN=reinit): AudapterIO('init', data.params) stops with "structure has no member 'dScale'"; after mapping the renamed fields and refilling the empty rmsFF_fb (as a re-analysis tool must), init succeeds but bTimeDomainShift and the pitch bounds go to 0, because data.params does not contain them: a +2 st time-domain shift becomes 0 cents. Audit notes

COORD-10 Low H

A zero-length time-warp row anchored on a state that begins in the first ~0.1 s can abort the trial ("predicting the future is impossible")

What happens. A zero-length warp row (dur1 = 0), used for control trials so that every trial keeps a warp section, computes a warp offset that should be exactly 0; floating-point round-off sometimes makes it 1 ulp positive, and Audapter aborts the trial with "Error occurred during phase-vocoder time warping: predicting the future is impossible". Whether it happens depends on the exact frame at which the anchor state begins.

When it matters. Only when the anchor state begins within about the first 0.1–0.12 s of the trial; designs anchored on a detected vowel onset essentially never hit it. On the live audio path the error would be raised in the audio callback (untested; possibly a crash).

/*----------------------*/ dtype warp_t = shift;if (warp_t > 0.0)    throw timeWarpFuturePredError();

A warp offset that rounds to a tiny positive number throws.

Present since at least Audapter 2.1.5.

Fix. Clamp the warp offset to ≤ 0, or compare it with a small tolerance, before the check.

How we know. Harness (report_pitfalls2.m SCEN=warpabort; 24 kHz, frameLen 32, nDelay 3; warp row anchored on state 1, tBegin 0.05, rate 0.5, dur1 0, hold 0.2): with state 1 entered at frame 26, 27, 29, 30, 34, 37 (and 20, 40) the trial aborts; at 21-25, 28, 31-33, 35, 36, 38, 39 it runs, matching the coordinator's C++ replica of the arithmetic (COORD-10). Audit notes

CORPUS-4 Low H

The male preset can report F3 as F2 for a whole vowel

What happens. On a sustained /a/ with F2 near 1000 Hz, the male preset (nLPC 17) logged F2 at 2494 Hz on every frame; nLPC 15 tracked it correctly.

When it matters. Seen on 1 of 4 male /a/ tokens. Any F2-indexed field or F2 shift is then computed from F3.

case 'male'           p.nLPC          = 17;

Present since at least Audapter 2.1.5.

Fix. Check tracked formants against the expected vowel space during setup. Workaround: try nLPC 15 for male speakers with low F2.

How we know. Harness on real speech (harness/oct/corpus_track.m). Audit notes

LIVE-7 Low D

Missed audio callbacks are not detected: the recording is spliced silently and the logged time axis falls behind

What happens. Audapter logs each callback as the next frame, with no timestamp or overrun counter. When the driver drops periods, signalIn is spliced without a marker and the logged time, OST onsets and perturbation onsets run behind wall-clock time.

When it matters. Matters on overloaded machines or drivers without real-time priority. In the simulation, with a normal-priority driver thread on a busy CPU, 109 of 5152 periods were lost and the time axis ended 218 ms behind; with a real-time driver thread none were lost.

// data recordingdata_recorder[0][data_counter] = frame_counter * p.frameLen + si + 1;// matlab intervals

Present since at least Audapter 2.1.5.

Fix. Count overruns from the stream time and log them per frame. Workaround: run the audio driver at real-time priority and keep the machine idle.

How we know. Live simulation (live/run.sh timing): 109 missed periods, 218 ms behind; 0 with SCHED_FIFO. Live-path simulation: the real audioIO/RtAudio ASIO code built with GCC on Linux and driven by a fake ASIO driver; rates are indicative, not Windows-exact. Audit notes

LIVE-8 Low D

Live onset and offset ramps are a 500 Hz sawtooth and end on a negative gain

What happens. With trialLen and rampLen set, the live ramp gain is computed from an index that runs over the interleaved stereo buffer, so within each 2 ms frame the ramp advances twice as fast and then jumps back. The gain is a 500 Hz sawtooth of ±0.040 around the intended ramp and ends at −0.039.

When it matters. Audible only if speech overlaps the first or last rampLen of a trial.

if ( (p.trialLen > 0.0) && (p.rampLen > 0.0) &&      (static_cast<dtype>(frame_counter) * static_cast<dtype>(p.frameLen) / static_cast<dtype>(p.sr) <= p.rampLen) ) {    for (n = 0; n < frame_size * nMult; ++n)        outFrame_ptr[n] *= static_cast<dtype>((frame_counter - 1) * frame_size + n) / static_cast<dtype>(p.sr) / static_cast<dtype>(p.downFact) / p.rampLen;}if ( (p.trialLen > 0.0) && (p.rampLen > 0.0) &&      (static_cast<dtype>(frame_counter) * static_cast<dtype>(p.frameLen) / static_cast<dtype>(p.sr) >= p.trialLen - p.rampLen) ) {    for (n = 0; n < frame_size * nMult; ++n)        outFrame_ptr[n] *= (p.trialLen - static_cast<dtype>((frame_counter - 1) * frame_size + n) / static_cast<dtype>(p.sr) / static_cast<dtype>(p.downFact)) / p.rampLen;}

Present since at least Audapter 2.1.5.

Fix. Compute the ramp from the frame's sample index, not the interleaved index.

How we know. Live simulation (live/run.sh ramp): live vs offline gain, maximum difference 0.040, final live gain −0.039. Live-path simulation: the real audioIO/RtAudio ASIO code built with GCC on Linux and driven by a fake ASIO driver; rates are indicative, not Windows-exact. Audit notes

Further items (low severity or narrow conditions)

IDSeverityFindingVerifiedWhereSince
OST-F9MediumINTENSITY_FALL: the code, its comment and the manual disagree on the rule; the current frame is never examinedR≤ 2.1.5
I-10MediumNo locking between the audio callback and the MATLAB thread; reset() now zeroes about 190 MB. Confirmed and measured on the live path: see LIVE-1 / LIVE-2 and LIVE-3D≤ 2.1.5
OST-F12LowHold durations differ by one frame between OST modes; RATIO modes use floor without roundingD≤ 2.1.5
OST-F13LowThe RMS_FLOOR OST mode hard-codes an absolute RMS floor of 0.0003 and does not set lastStatEndRblab fork
OST-F14LowRMS ratio is NaN or Inf in digital silence; ratio rules then never triggerR≤ 2.1.5
I-05Lowrms_slope is NaN for the first 14 frames of every trial and is fed to slope-based OST rulesH≤ 2.1.5
OST-F16LowWith nWin > 1, osTrack runs nWin times per frame on the same countersR≤ 2.1.5
FMT-F5LowclampIx is a function static that reset() does not clearRblab fork
FMT-F8LowA zero clamp F2 with nonzero F1 moves the F2 pole to DCRblab fork
FMT-F9LowThe tracker's static last_f[] survives trials; no observable effect in our /i/→/a/ test (negative result)H≤ 2.1.5
FMT-F10LowtrackPhi reads stale root candidates when fewer complex roots exist than it expectsR≤ 2.1.5
FMT-F11LowShifted formant targets are not bounded to (0, Nyquist); logged sFmts can disagree with the filterR≤ 2.1.5
FMT-F12LowWith nWin > 1 an unshifted window logs the previous window's shifted formantsR≤ 2.1.5
FMT-F13LowThe 1D field interpolates pertPhi linearly without angle wrapR≤ 2.1.5
FMT-F14LowgainAdapt/gainPerturb keep static gain and lastSample across trialsR≤ 2.1.5
I-11LowA device buffer-size mismatch makes handleBuffer silently do nothingR≤ 2.1.5
I-13LowParameter validation runs after assignment, so a rejected value stays liveR≤ 2.1.5
I-14LowFurther state not reset between trials: pbCounter, wgTime, intShiftRatio, function statics (see OST-F1, I-01)R≤ 2.1.5
I-16LowMATLAB side: p.downfact typo; closedLoopGain also sets mouthMicDist; nFB/nfb mismatch; datapb and rmsFF_fb not logged in data.paramsR≤ 2.1.5
I-17LowgenRandScript.m rewrite: inverted checks and an undefined variableRblab fork
PT-7LowstereoMode 2 'simulated TTL' channel for pitch shifts is always 0R≤ 2.1.5
PT-8LowTime-domain shifter PP_PEAKS/VALLEYS reads about 42 ms stale samples when F0 risesD≤ 2.1.5
PT-10LowTime-domain shifter minor defects: y[0] for y[i], stale audio on resume, ±13 cent quantisation at high F0D≤ 2.1.5
PT-12LowThe resampling filter is chosen by sr, not downFact (aliasing for sr 32000 with downFact 3)Rblab fork
PT-13LowTime-domain shifting silently discards formant perturbation and changes the LPC inputR≤ 2.1.5
FORMAL-7LowBeyond PT-5: with pvocHop = pvocFrameLen/2 the phase-vocoder gain swings between 1× and 2× at the hop rate (audible amplitude modulation), and content below the first FFT bin gets 5/2 (+8 dB) at every hop ratio. Needs a non-default hopF≤ 2.1.5

Robustness

Memory reads and writes past the end of an array, and similar defects that need a malformed file or parameter, or an unusual build, to show up. None changes what a participant hears with well-formed settings; each is worth fixing because the failure, when it happens, is silent or a crash.

A PCF with fewer rows than the OST has states makes Audapter read past its arrays, and memory contents decide whether formants are shifted

Not triggered: blab's PCFs have one row per OST state. Affects any PCF with fewer rows than the OST has states.
Relevant blab studies safe as run. Safe as run: blab's PCFs have one row per OST state.

What happens

The formant path indexes the PCF's amplitude and phase arrays with the current OST state without checking the PCF's length. In OST states beyond the PCF's last row, it reads whatever lies after the arrays in memory, and that value decides whether and how much to shift.

When it matters

Affects OST/PCF pairs where the PCF has fewer rows than the OST has states, which the file format allows and the loader does not check, for example a PCF written only up to the last perturbed state. Pitch and intensity shifts check the length and are not affected. What happens depends on memory contents, which differ between machines and sessions.

Example

An OST with states 0–4 and a 3-row PCF that raises F1 in state 2. From 0.39 s, in states 3 and 4, every frame (404 of 600) reads past the 12-byte arrays; AddressSanitizer stops at the first read. In this Linux run the bytes read were zero, so no extra frames were shifted; on another machine they need not be.

Settings used

Source
Hypothetical design: a plausible design, not one blab runs; blab's default settings (getAudapterDefaultParams) unless listed.
Input
synthetic vowel
Between trials
one trial
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.rmsThresh0.005
p.bShift1
OST file
rmsSlopeWin = 0.030000window for the level-slope estimate n = 44 rules follow0 INTENSITY_RISE_HOLD 0.01 0.02 {}state 0 → 2: level rises above 0.01 (next state) and stays above it for 0.02 s2 ELAPSED_TIME 0.25 NaN {}state 2 → 3: moves on 0.25 s after this state began; does not record its end time3 INTENSITY_FALL 0.01 0.02 {}state 3 → 4: level below 0.01 for 10 ms, once more than 0.02 s have passed since the last recorded state end4 OST_END NaN NaN {}state 4: final state n = 0no maxIOI timeouts
PCF file
00 time-warp sections 33 state rows: state, pitch (semitones), level (dB), formant shift amount, angle0, 0, 0, 0, 0state 0: no change1, 0, 0, 0, 0state 1: no change2, 0, 0, 0.3, 0state 2: formants ×1.3 (F1)

The array Audapter allocated and the entries it reads; below, one trial's OST states and which frames read out of bounds.

PCF rows allocated vs OST states read, and when the out-of-bounds reads happenThe PCF allocates 3 rows (states 0-2), but the OST has 5 states. From 0.392 s on (state 3 and later) every frame reads fmtPertAmp[stat] past the end of the array, 404 of 600 frames in this trial.read past the end404 of 600 frames read past the endMemory: the PCF's arrays and the entries read (not a time axis)fmtPertAmp[ ]PCF: 3 rows, 12 bytesfmtPertAmp[ ]: index 0[0] = 0fmtPertAmp[ ]: index 1[1] = 0fmtPertAmp[ ]: index 2[2] = 0.3fmtPertAmp[stat]read at Audapter.cpp:1811fmtPertAmp[stat]: index 0state 0fmtPertAmp[stat]: index 1state 1fmtPertAmp[stat]: index 2state 2fmtPertAmp[stat]: index 3state 3fmtPertAmp[stat]: index 4state 4past the end: no row for state 3–4One trial: when those entries are readInputInput/a/OST stateost_statOST state: state 0 from 0.000 to 0.120 sstate 0OST state: state 1 from 0.120 to 0.140 sOST state: state 2 from 0.140 to 0.400 sstate 2OST state: state 3 from 0.400 to 1.000 sstate 3OST state: state 4 from 1.000 to 1.200 sstate 4fmtPertAmp[stat]every framefmtPertAmp[stat]: in bounds from 0.000 to 0.120 sin boundsfmtPertAmp[stat]: in bounds from 0.120 to 0.140 sfmtPertAmp[stat]: in bounds from 0.140 to 0.400 sin bounds[3] out of bounds404 of 600 frames read past the end0 s0.2 s0.4 s0.6 s0.8 s1 s1.2 stime in trial (s)
PCF rows allocated vs OST states read, and when the out-of-bounds reads happenThe PCF allocates 3 rows (states 0-2), but the OST has 5 states. From 0.392 s on (state 3 and later) every frame reads fmtPertAmp[stat] past the end of the array, 404 of 600 frames in this trial.read past the end404 of 600 frames read past the endMemory: the PCF's arrays and the entriesread (not a time axis)fmtPertAmp[ ] (PCF: 3 rows, 12 bytes)fmtPertAmp[ ]: index 0[0] = 0fmtPertAmp[ ]: index 1[1] = 0fmtPertAmp[ ]: index 2[2] = 0.3fmtPertAmp[stat] (read at Audapter.cpp:1811)fmtPertAmp[stat]: index 0state 0fmtPertAmp[stat]: index 1state 1fmtPertAmp[stat]: index 2state 2fmtPertAmp[stat]: index 3state 3fmtPertAmp[stat]: index 4state 4past the end: norow for state 3–4One trial: when those entries are readInputInput/a/OST state (ost_stat)OST state: state 0 from 0.000 to 0.120 sOST state: state 1 from 0.120 to 0.140 sOST state: state 2 from 0.140 to 0.400 sstate 2OST state: state 3 from 0.400 to 1.000 sstate 3OST state: state 4 from 1.000 to 1.200 sstate 4fmtPertAmp[stat] (every frame)fmtPertAmp[stat]: in bounds from 0.000 to 0.120 sfmtPertAmp[stat]: in bounds from 0.120 to 0.140 sfmtPertAmp[stat]: in bounds from 0.140 to 0.400 sin bounds[3] out of bounds404 of 600 frames read past theend0 s0.2 s0.4 s0.6 s0.8 s1 s1.2 stime in trial (s)

No recordings: the effect depends on memory contents, so any clip would be one arbitrary instance.

How big, how often

syntheticFrames reading past the arrays404 of 600 (67 %), from 0.39 s
syntheticAllocated / largest index read12 bytes (3 entries) / state 4
syntheticShifted frames outside state 2, this run0

Data: data.json from the export script.

Cause

PERT_CFG::readFromFile allocates one entry per PCF row. handleBuffer indexes the formant arrays with the OST state and checks only that the PCF is not empty.

if (p.bShift) {    if (pertCfg.n == 0) {        /*during_trans = false;*/    }    else {        during_trans = (pertCfg.fmtPertAmp[stat] != 0);    }

Runs on every frame with bShift on; this is the line AddressSanitizer reports.

if (pertCfg.n > 0) {    mamp = pertCfg.fmtPertAmp[stat];            mphi = pertCfg.fmtPertPhi[stat];}

When the value read is nonzero, amplitude and phase are read out of bounds as well.

if (pertCfg.n > 0) {    if (stat < pertCfg.n) {        p.pitchShiftRatio[0] = pow(2.0, pertCfg.pitchShift[stat] / 12.0);        intShiftRatio = pow(10, pertCfg.intShift[stat] / 20.0);    }

For comparison: the pitch/intensity path has the bounds check.

if ((fmtPertAmp = (float *)calloc(n, sizeof(float))) == NULL) {    printf("ERROR: failed to allocate memor for pertCfg.fmtPertAmp\n");    return;}if ((fmtPertPhi = (float *)calloc(n, sizeof(float))) == NULL) {    printf("ERROR: failed to allocate memor for pertCfg.fmtPertPhi\n");    return;}

Sized by the PCF row count only.

Present since at least Audapter 2.1.5.

Safe pattern and fix

Check stat < pertCfg.n in the formant path, as the pitch path does, and reject at load a PCF with fewer rows than the OST has states. Workaround: always write one PCF row per OST state.

How we know

  • H sanitizer scenario (build-asan): t_mem.m SCEN=pcf_short heap-buffer-overflow READ at Audapter.cpp:1811, 0 bytes after an 8-byte region allocated at pcf.cpp:431
  • H report export (ASan run + timeline): report_ost_f4.m run under build-asan first (log to out/report/ost-f4/asan.log), then on build-oct
  • D standalone driver of pcf.cpp: scratch/ost-pcf/t/drvp.cpp oob PCF n = 5: fmtPertAmp[6] = 4.6e-44 (during_trans = 1), fmtPertPhi[8] = 4.3e18

Reproduce cd audit/harness && SCEN=pcf_short ./run-oct.sh t_mem.m build-asan

A clamp trajectory shorter than 2048 values is read as 2048: after it ends, the formant clamp follows whatever lies next in memory

Not triggered by blab's public code, which does no formant clamping; the settings of blab's clamping experiment were not available to us. Affects clamp trajectories shorter than 2048 values.
Relevant blab studies safe as run. Safe as run: taimComp pads its clamp trajectories to 2048 values.

What happens

The setters for clampf1/clampf2 copy 2048 values whatever length MATLAB passes, so a shorter trajectory is followed by memory contents. The clamp runs on into those values until it meets a zero. The 1D-field arrays behave the same way with 257 values.

When it matters

Affects clamped-formant experiments whose trajectories are shorter than 2048 values; the default parameters pad to 2048 and are safe. The values read past the array vary from run to run and between machines, so the effect ranges from harmless to a formant pushed to 0 Hz.

Example

A 100-value trajectory at 700 Hz on a vowel. Audapter copies 2048 values, 1948 of them from past MATLAB's array. In this run the clamp followed the 100 values, then memory values until the first zero at element 104, and held a target of 1.15e-312 Hz: F1 pushed to 0 Hz, and the output peaked at 628 times full scale, a peak 75 dB above the padded control's.

Settings used

Source
Hypothetical design: a plausible design, not one blab runs; blab's default settings (getAudapterDefaultParams) unless listed.
Input
synthetic vowel
Between trials
one trial per clamp array length
Build
blab-lab audapter_mex as shipped (@169cadf)
All settings: parameters, OST and PCF, each line explained

Parameters passed to AudapterIO('init', p) that differ from getAudapterDefaultParams('female'):

p.rmsThresh0.005

The array MATLAB passed and the part Audapter reads; below, one trial's logged F1 target. Values past element 100 are from this run and change between runs.

Array extent: what MATLAB passes for clamp_f1 and what Audapter copiesMATLAB passes 100 values; Audapter copies 2048, reading 1948 past the end of the array. The clamp advances through the passed values into heap contents until it meets a zero at element 104, then holds element 103.MATLAB arrayclamp_f1, 1×100MATLAB array: from 0.000 to 100.000 sallocated: 100 values (800 bytes)Audapter copiessetGetParam, len = 2048Audapter copies: 2048 values, whatever the length from 0.000 to 2048.000 s2048 values, whatever the length1948 values read past the end of the arrayClamp trajectorywhat the clamp walksClamp trajectory: from 0.000 to 100.000 sClamp trajectory: from 100.000 to 104.000 sfirst zero at element 104; the clamp then holds element 103: 1.15e-312 Hz in this run0512102415362048array elementLogged sF1 during the trial: clamp padded to 2048 vs the same 100 values unpaddedWith the padded array the logged sF1 is 700 Hz throughout. With the 100-value array it is 700 Hz for 100 frames, then 300 frames below 1 Hz; the output peaks at 628 (control 0.106).Input/a/, 0.6 sInputsF1 loggedpadded (control)sF1 logged: 700 Hz, as passed from 0.000 to 0.800 s700 Hz, as passedsF1 logged100 valuessF1 logged: 700 Hz, as passed from 0.000 to 0.200 s700 Hz, as passedsF1 logged: below 1 Hz (heap value) from 0.200 to 0.800 sbelow 1 Hz (heap value)0 s0.2 s0.4 s0.6 s0.8 stime in trial (s)
Array extent: what MATLAB passes for clamp_f1 and what Audapter copiesMATLAB passes 100 values; Audapter copies 2048, reading 1948 past the end of the array. The clamp advances through the passed values into heap contents until it meets a zero at element 104, then holds element 103.MATLAB array (clamp_f1, 1×100)MATLAB array: from 0.000 to 100.000 sallocated: 100 values (800 bytes)Audapter copies (setGetParam, len = 2048)Audapter copies: 2048 values, whatever the length from 0.000 to 2048.000 s2048 values, whatever the length1948 values read past the end of the arrayClamp trajectory (what the clamp walks)Clamp trajectory: from 0.000 to 100.000 sClamp trajectory: from 100.000 to 104.000 sfirst zero at element 104; the clamp then holdselement 103: 1.15e-312 Hz in this run0512102415362048array elementLogged sF1 during the trial: clamp padded to 2048 vs the same 100 values unpaddedWith the padded array the logged sF1 is 700 Hz throughout. With the 100-value array it is 700 Hz for 100 frames, then 300 frames below 1 Hz; the output peaks at 628 (control 0.106).Input (/a/, 0.6 s)InputsF1 logged (padded (control))sF1 logged: 700 Hz, as passed from 0.000 to 0.800 s700 Hz, as passedsF1 logged (100 values)sF1 logged: 700 Hz, as passed from 0.000 to 0.200 ssF1 logged: below 1 Hz (heap value) from 0.200 to 0.800 sbelow 1 Hz (heap value)0 s0.2 s0.4 s0.6 s0.8 stime in trial (s)

No recordings: the values read past the array vary between runs and machines, so any clip would be one arbitrary instance.

How big, how often

syntheticValues passed / values copied100 / 2048
syntheticLogged F1 target: frames at 700 Hz / below 1 Hz, this run100 / 300
syntheticOutput peak: padded control / 100 values, this run0.106 / 628
syntheticOutput peak over 15 runs628 to 61143
synthetic1D field: 10-value pertAmp, nonzero values read past element 10, this run200

Data: data.json from the export script.

Cause

In set mode, setGetParam uses maxNClampFrames (2048) as the copy length for the clamp arrays whatever length MATLAB passed. The zero-fill of the rest is commented out. The 1D-field arrays use a fixed 257.

else if (ns == string("clampf1")) {     // taimComp    ptr = (void*)p.clamp_f1;     if (bSet && (nPars > maxNClampFrames))        mexErrMsgTxt("Clamped F1 value array is too long");            // this reads: "if nPars is less than maxNClampFrames, len = nPars. Else, len = maxNClampFrames".    //len = (nPars < maxNClampFrames) ? nPars : maxNClampFrames;    // TODO this should work, but nPars is 0 on subsequent loops (including each trial) after initializing    len = maxNClampFrames; }

The length is forced to 2048 for set and get alike; clampf2 does the same at 1172.

else if (pType == Parameter::TYPE_DOUBLE_ARRAY) {    for (int i = 0; i < len; i++) {        *((dtype *)ptr + i) = static_cast<dtype>(*((dtype *)value + i));    }    if (ns == string("datapb")) { /* Zero out the remaining part */        for (int i = len; i < maxPBSize; i++)            *((dtype*)ptr + i) = 0.0;    }    // This doesn't work for some reason if clamp_f1/2 aren't pfNPoints in length    //else if (ns == string("clampf1") || ns == string("clampf2")) { /* Zero out the remaining part */  // taimComp    //  for (int i = len; i < maxNClampFrames; i++)    //      *((dtype*)ptr + i) = 0.0;    //}}

The copy loop reads len values from MATLAB's array; ASan flags line 1214. The zero-fill for clamp arrays is commented out.

if (p.bClampFormants && stat >= p.clamp_osts[0] && stat < p.clamp_osts[1] && p.clamp_f1[clampIx] != 0) {        // taimComp    newPhis[0] = p.clamp_f1[clampIx] * 2 * M_PI / p.sr; // This gets converted back to Hz when sFmts is set    newPhis[1] = p.clamp_f2[clampIx] * 2 * M_PI / p.sr;    during_trans = true;    // TODO(CWN) this is relatively hacky. Should probably just have some way to circumvent during_trans check in L1868    if ((clampIx < maxNClampFrames-2) && (p.clamp_f1[clampIx+1] != 0)) {        clampIx += 1;    }    else {        // don't increment clampIx. Thus, the final frame will be repeated indefinitely 'til clamp_osts[1] is reached    }

The clamp moves on while the next value is nonzero, so it runs through the copied heap values until it meets a zero.

else if (ns == string("pertf2")) {    ptr = (void *)p.pertF2;    len = pfNPoints;}else if (ns == string("pertamp")) {    ptr = (void *)p.pertAmp;    len = pfNPoints;}else if (ns == string("pertphi")) {    ptr = (void *)p.pertPhi;    len = pfNPoints;}

The 1D field arrays are copied with a fixed length of 257 as well.

Introduced in the blab-lab fork.

Safe pattern and fix

In set mode copy min(nPars, max) values and zero the rest; better, reject arrays of the wrong length. Workaround: always pad clamp trajectories to 2048 values and field arrays to 257.

How we know

  • H ASan scenario: t_mem.m SCEN=clamp_short SUMMARY: AddressSanitizer: heap-buffer-overflow src/Audapter.cpp:1214 in Audapter::setGetParam
  • H ASan scenario: t_mem.m SCEN=pert_short same line, 10-element 1D field arrays
  • H report export: values copied, clamp trajectory, logged sF1, output peak; 1D field on both builds: report_fmt_f1.m
  • H report export under ASan: report_fmt_f1.m build-asan READ of size 8 at Audapter.cpp:1214, 0 bytes after an 800-byte region (100 doubles)

Reproduce cd audit/harness && SCEN=clamp_short ./run-oct.sh t_mem.m build-asan

Further robustness items

I-06 / I-07 Medium H

An oversized tone count is accepted and overwrites neighbouring parameters

What happens. The tsgNTones range check reads the value before it is assigned, so 100 is accepted although the maximum is 64. The next tone-sequence setter then writes past the tone arrays into neighbouring parameters (stereoMode, bShift2D, clamp settings). The tone-sequence recorder also has no bound and overruns after 10 s.

When it matters. Affects scripts that set more than 64 tones or play tone sequences longer than 10 s.

else if (ns == string("tsgntones")) {    ptr = (void *)&p.tsgNTones;     if ( *((int *)ptr) < 0 )        mexErrMsgTxt("Negative value for tsgNTones");    if ( *((int *)ptr) > maxNTones ) {        ostringstream oss;        oss << "Number of tones in the tone sequence is too big (max allowed = "             << maxNTones << ")";        mexErrMsgTxt(oss.str().c_str());    }

Present since at least Audapter 2.1.5.

Fix. Check the new value, not the old one, and bound the recorder.

How we know. Harness (scratch/interface/t_iface.m T5) for the check order; the overflow by reading. Audit notes

OST-F6 Medium D

Reloading an OST file that has no maxIOI section crashes MATLAB

What happens. Loading a new OST frees the maxIOI arrays. If the new file has no maxIOI section, the parser returns without setting their count to 0, and the next frame dereferences the freed arrays.

When it matters. Affects sessions that load one OST with a maxIOI section and then one without; gen_multi_pert_pcf.m writes files without it.

if (++lit == ostLines_1.end())    return;

if (maxIOICfg.n > 0) {    for (i = 0; i < maxIOICfg.n; i++) {        if ((stat >= maxIOICfg.stat0[i]) && (stat < maxIOICfg.stat1[i])) {

Present since at least Audapter 2.1.5.

Fix. Set maxIOICfg.n = 0 before returning. Workaround: always end OST files with an n = 0 maxIOI section.

How we know. Standalone ost.cpp driver (scratch/ost-pcf/t): segfault on the first frame after the reload. Audit notes

H2 / PT-1 Low H F

The first output frame of each trial, and one frame every 108 s, is read from outside the delay buffer

What happens. The read position in the output delay ring uses > 0 where >= 0 is needed. When the write position minus the delay is exactly 0, the frame is read past the end of the buffer. The pitch-shift "back zeroing" on the same ring can also write before the start of the buffer.

When it matters. The read happens at frame delayFrames of every trial and once per 108 s lap. At a trial start the wrongly read frame is near-silent, so it is benign; in continuous sessions longer than 108 s without a reset it gives a 2 ms glitch. The back-zeroing write happens only with pitch shifting when the write position is below delayFrames·frameLen + pvocHop.

for (int h0 = 0; h0 < p.nFB; h0++) {    if (outFrameBuf_circPtr - p.delayFrames[h0] * p.frameLen > 0) {        optr[h0] = outFrameBuf_circPtr - p.delayFrames[h0] * p.frameLen;    }    else {        optr[h0] = outFrameBuf_circPtr - p.delayFrames[h0] * p.frameLen + internalBufLen;    }

Present since at least Audapter 2.1.5.

Fix. Use >= 0 in the read-pointer test and a positive modulo in the back zeroing; both are proven to stay in bounds for every setting and to address the same samples wherever the original was valid.

How we know. UBSan reports the read on every harness run (Audapter.cpp:2124). CBMC on the verbatim source gives the exact conditions and proves both fixes. Audit notes

COORD-5 Low H R

A rejected time-warp row terminates the process on Linux, macOS and WebAssembly builds (portability)

What happens. PERT_CFG::addWarpCfg declares that it throws only overlappingWarpIntervalsError, but it also throws warpCfgInitError when a warp rate is outside its range (rate1 not in (0, 1), or rate2 not above 1). GCC and clang enforce the declaration and terminate the process; MSVC ignores it, so on the lab's Windows rigs MATLAB reports "Failed to initialize time-warping event" instead.

When it matters. Not a crash on Windows. It matters for Linux, macOS and browser builds, and for anyone who writes a rate-1 warp row to mean "no warp": that row is rejected rather than treated as a no-op. Use a zero-length warp row instead.

void PERT_CFG::addWarpCfg(double t_tBegin, double t_rate1,                           double t_dur1, double t_durHold, double t_rate2)    throw(overlappingWarpIntervalsError) {     try {        pvocWarpAtom t_warpCfg(t_tBegin, t_rate1, t_dur1, t_durHold, t_rate2);         /* Check to make sure that there is no overlapping time warp periods */        if ( !checkWarpIntervalsOverlap(t_warpCfg) )            warpCfg.push_back(t_warpCfg);        else            throw overlappingWarpIntervalsError();    }    catch (pvocWarpAtom::rateError) {        throw warpCfgInitError();    }

Present since at least Audapter 2.1.5.

Fix. Drop the dynamic exception specification (it is deprecated since C++11), or declare both exception types.

How we know. Harness: a PCF warp row with rate 1 (a "null warp") terminated Octave on Linux; reading of the exception specification. Audit notes

Other robustness items

IDSeverityFindingVerifiedWhereSince
OST-F3Mediumstat0[i+1] is read past the end on every frame once the last rule is reached; harmless with a trailing OST_END, a silent behaviour change without oneH≤ 2.1.5
OST-F7MediumSparse or mis-numbered OST states write past statOnsetIndices (sized 4·n); state numbers are never range-checkedD≤ 2.1.5
OST-F10MediumTruncated OST/PCF files dereference end() instead of raising a syntax error; failed reloads leave half-built stateD≤ 2.1.5
FMT-F2Mediumpertf1 and the 2D fields ignore the MATLAB size; non-257×257 matrices are read out of bounds (part of FMT-F1 / I-08)Hblab fork
OST-F11LowPCF time warp indexes statOnsetIndices[ostInitState] without a NULL or range checkR≤ 2.1.5
FMT-F7LowTwo equal neighbouring grid points make locateF1/locateF2 return NaN, used as an INT_MIN array index; proven otherwise memory safe for every grid, sorted or not (FORMAL-9)Fblab fork
I-12LowoutputBuf[maxFrameLen] overflows for large frameLen·downFact. The recorder overruns when frameLen does not divide 480000 and a trial reaches about 30 s; proven exact set, of common sizes only 512 (FORMAL-5)F≤ 2.1.5
PT-9LowTime-domain shifter scratch overrun: up to a +21 dB burst after long zero-crossing-free input or strong pitch-downD≤ 2.1.5
PT-11LowPhase-vocoder parameter checks broken (L & hop != 0); hop < frameLen divides by zeroR≤ 2.1.5
FORMAL-1LowpvocFrameLen smaller than frameLen writes before outFrameBufSum on every frame, even with pitch shift off, corrupting the ring pointer; MATLAB segfaults. Practically unreachable: it also needs pvocHop ≥ frameLen, which the broken parameter check lets through (PT-11)F≤ 2.1.5
FORMAL-3LowThe output ring pointer resets to 0 instead of wrapping, so a frameLen that does not divide 1,728,000 writes past the buffer once per 108 s lap and shifts DAF alignment. Very unlikely: every common frameLen divides it, and it needs 108 s without a resetF≤ 2.1.5
H1LowDSPF_dp_blk_move uses memcpy on overlapping ranges (undefined behaviour; works with MSVC's memcpy)H≤ 2.1.5
WASM-1LowThe LPC-coefficient columns of the raw data matrix log uninitialised heap memory until the first voiced frameH≤ 2.1.5
WASM-4LowMembers declared with an extra Audapter:: qualification: MSVC and GCC accept it, clang (macOS, WASM) rejects itR≤ 2.1.5

Methods

Running the real code without MATLAB or audio hardware

The C++ core is compiled as a MEX file for GNU Octave, which implements MATLAB's MEX interface, and driven through the lab's own MATLAB code (getAudapterDefaultParams.m, AudapterIO.m), unmodified. Audio goes in frame by frame through Audapter('runFrame'), which runs the same processing function as the live audio callback. We then read back what Audapter logged and measured the output audio independently: formants with our own LPC, F0 by autocorrelation, and level in 20 ms blocks. The only source changes are two portability edits, one of which makes GCC behave like the Windows compiler. A second build with AddressSanitizer and UBSan reports out-of-bounds memory access at the exact line.

Checks on the test setup

  • Repeated identical trials give bit-identical output, and the tracker agrees with an independent LPC within 5 % on four vowels.
  • An apparent 25 % overshoot of F1 shifts at F0 200 Hz was a bias of our LPC measurement: vowels synthesised at the target formant show the same bias. Formant checks use F0 120 Hz.
  • We ran the original Audapter 2.1.5 through the same harness, on synthetic input and on the real recordings. Output and logged data matched bit for bit, except for the field re-entry rule in F6.
  • The WebAssembly build matches a native build with the same maths library bit for bit, and the MATLAB-compiled build within 3·10−11.

Real speech

Every main finding was repeated on 80 openly licensed recordings (197 s): adult men and women, children aged 6–7, singers, voices with dysphonia, speech in real background noise, and three session recordings shipped with Audapter. A script re-fetches each original from pinned sources and rebuilds the set byte for byte. On these recordings the formant tracker's median error against the hand-corrected measurements of Hillenbrand et al. (1995) is 1.3–2.0 %; formant shifts are logged exactly and measured in the output within 2–3 % of the command; the old sessions replay within 0.2–0.4 % of what was logged online; and the sanitizers found nothing new in 480 clip-scenarios. No voice in the set is above about 350 Hz F0, so high voices are untested.

The live audio path

To test the path used in experiments, the real audio code (audioIO.cpp and RtAudio with its ASIO backend) was built with Audapter and driven by a simulated ASIO driver that calls back at real period boundaries, and cross-checked against a JACK server. Live processing matches offline processing bit for bit, with two buffers of delay, so the offline cards apply to real sessions. The usual reset–start–stop–getData flow per trial showed no data races in 300 runs under ThreadSanitizer. The driver is modelled and the build is GCC on Linux, so crash and click rates are indicative, not Windows figures.

Proofs

For a few pieces of logic we asked for which settings a bug occurs and whether the fix works for all settings. CBMC, a model checker for C, runs on the verbatim, hash-pinned buffer-index code and was checked for every frame length Audapter accepts (1–120) and six common larger ones, with no failures. It gives the exact conditions for the delay-buffer and recorder overruns and proves their fixes. Lean 4 proves, on a line-by-line model of the OST state machine, that with the OST-F1 and OST-F2 fixes no trial depends on earlier trials, and that neither fix alone suffices; the proof is about the model, not the compiled code. Exact algebra gives the phase vocoder's 3/2 gain (PT-5) for every hop ratio of 3 or more. The whole signal-processing chain was not model-checked.

Running Audapter in the browser

Panels on several cards run the real C++ core, compiled to WebAssembly from the same commit, on the card's inputs, with and without a small patch. Each patch is bit-identical to the current code on reference scenarios it should not affect and removes the finding's effect (audit/report/wasm/check-patches.mjs). Panels load on click, work offline, and use 150–300 MB of memory per run; on a phone, run one panel at a time.

Who did this, and reproducing it

The audit was carried out by Claude, an AI model made by Anthropic, with AI sub-agents, at the request of Josh Mandel, who commissioned it and decided what to publish. Nothing here needs to be taken on trust: every finding links the code at a pinned commit and gives a reproduce command. The whole pipeline was rebuilt from a fresh clone; of the 17 full cards the report then had, 13 rebuilt byte-identical, and the other 4 measure quantities that vary by nature (a click's size, crash counts, time to a hang, memory contents). Requires Docker: ./reproduce.sh at the repository root rebuilds everything.

Running the lab's own code

To check findings against the experiments as the lab runs them, we ran each public blab experiment's own entry script, runner and shared helpers, unmodified, under Octave with the real Audapter build behind a virtual sound card on a virtual clock (pause and timers advance the recording instead of waiting), with a participant who says each trial's word or sentence and a rule-driven experimenter answering prompts and dialogs through their real callbacks. Per trial it records every Audapter call, the parameters in effect against the script's own, the OST timeline, the logged shift and the heard formants, pitch, level and lag. 12 of the 13 public entry points run end to end (vsaCentralize's stops because the runner it calls is not in its repository); vsaGeneralize and the modelExpt template ran full sessions. The participant is one of eight AI-generated voices (OpenAI text to speech, four perceived female and four male; every take checked for what was said, clipping, F0 and vowel formants, and checked against real recordings of "head"), each running every experiment; the numbers quoted from these runs are their range across the eight voices. Participant setup is simulated too: the experimenter answers the height question from the voice's gender (the male or female preset) and, where the script opens the lab's LPC check, tries every order the tool offers through the tool itself and keeps the preset unless another order is clearly and consistently better on formant accuracy against an independent reference, track continuity and vowel clustering (the rule was fixed before it was run; it kept the preset in almost every session). Limits: AI-generated voices are studio-clean renditions, not people; no real-time scheduling or sound-card timing, simplified Psychtoolbox, and one recorded load-time change (graphics dot notation) in files that fail without it. Details: audit/labrun/README.md.

Level and loudness

Cards state level changes as A-weighted level differences, in dBA: 20·log10 of the ratio of the A-weighted RMS of two clips, measured on the output audio over the part of the signal the card names (for whole clips, the 20 ms blocks within 40 dB of the loudest). A-weighting (IEC 61672) discounts low and very high frequencies roughly as the ear does at moderate levels; it is standard and needs no assumption about the listening level. The plain, unweighted RMS level overstates changes that mostly move energy at low frequencies: lowering F1, for example, boosts the strong low harmonics, to which the ear is less sensitive, so on the ARCTIC bdl sentence F1 −125 mel raises the plain RMS level by 6.7 dB but the A-weighted level by 2.9 dBA. Plain RMS appears only in evidence tables, labelled RMS. For /s/ and other broadband changes the measures agree closely.

Where a card compares clips we also give the loudness-level difference in phon (ISO 532-1): ISO 532-1 time-varying loudness (Zwicker; mosqito 1.2.1, free field), statistic N5 (the loudness exceeded 5 % of the time), converted to loudness level. The expected clip is presented at 60, 70, 80 and 85 dB SPL (A-weighted), the observed clip at the same gain, and the card gives the range over those levels; we assume diotic headphones with a flat response and a normal-hearing listener. Older or clinical listeners and real headphones may differ. For "noticeable" we use a change of about 1 dB (about 1 phon at these levels), the order of the intensity just-noticeable difference for broadband sounds such as speech and noise at moderate levels (Miller 1947, J. Acoust. Soc. Am. 19:609). Sanity check (equal RMS, +2 semitones, 70 dB SPL): a harmonic complex at 120 Hz gains +0.4 phon, the direction the equal-loudness contours predict.

Limitations

  • Octave, not MATLAB; GCC on Linux, not MSVC on Windows. Out-of-bounds reads can return different values on Windows.
  • The live path is simulated with a modelled ASIO driver.
  • Recordings, not a live talker hearing the feedback; no voices above about 350 Hz F0.
  • Severity is our judgement of experimental impact.

Corpus credits

Real recordings used in this report, their sources and licences. Every clip was cut, converted to mono 48 kHz and, where heard in the report, processed by Audapter: these are modified versions of the originals.