Synchrosqueezed Wavelet Transform is an option. I have developed a complete algorithm for this task, which scores 100% train accuracy and 86% test accuracy with 0.05 sec tolerance, without machine learning, on the provided dataset of 14 samples. Its features can be reused with ML.
I explain my algorithm, suggest improvements, and recommend ML-oriented alternatives.
Overview
"Detection" can be seen as a case of classification, and classification is about balancing discriminativeness and invariance - or simply, maximizing sensitivity to relevant variability. Sparsity and selective activation are excellent properties for this, and SSQ on clean audio excels at this.
The "abrupt change" to detect here is the immediate insertion of a new audio segment, and silencing of previous - a sharp discontinuity. Equivalently, it's the superposition of two audios with zero-padding - and zero-padding is well-studied, by me. It causes predictable distortions in time-frequency, lowering SNR - but for us, this noise is the signal.
Quick inspection: CWT vs STFT
The log scale is favored for many natural signals, especially audio, but check anyway:
CWT richly maps out the variability that's compactified in STFT. Mel-spectrogram is also an option but not explored here.
Inspect regions of interest
We zoom around 1.55
sec and 3.50
sec,
SSQ is sparse when, in a 2D time-frequency slice, complex-valued oscillations share a frequency. Wavelet inner products, faced with a discontinuity, stop having this property - so SSQ fails to remap to a common frequency bin, and is instead spread out smoothly.
Amplify discriminative features / Attenuate invariants
"Noise is signal", so "signal is noise" - so we want to get rid of the signal, which here is transformed into tight curves/lines in 2D.
We need some way to target the ridges (curves); ridge extraction is an option but can be hard to work with. Instead we can exploit the tight concentration of ridges with an otherwise terrible derivative operator - diff
, i.e. $x[n] - x[n - 1]$, along the frequency axis, which acts as a highpass filter with extreme time resolution ($h[n] = [1, -1]$). Sharp transitions are high-frequency structures, which will be amplified - and the smooth zero-padding artifacts will be attenuated - all while preserving the original spread of features per high time resolution (not so with e.g. Gaussian highpass filter).
So, diff(Tx, axis=0)
where Tx = abs(ssq_cwt(x))
:
Now we apply a thresholding upon Tx_diff
, to zero points in Tx
. For this I use the sparse_mean
algorithm I developed - a more robust alternative to mean
in that it's unaffected by the pure size of input, so sparse_mean(zero_pad(x)) == sparse_mean(x)
while mean(zero_pad(x)) != mean(x)
. This makes "mean" of Tx
of one sine, same as mean for two or more sines, for example.
So, Tx[Tx_diff > sparse_mean(Tx_diff)] = 0
, and call it Tx_carved
:
Ah-ha! It's red where we want it. But not just there - there's many dots scattered around as thresholding leftovers, that we can't get rid of by re-thresholding. The distinguishing feature here is the size of area that's red, which can be measured by aggregation. Design as follows:
- There's two simple options - one being 2D filtering, as 2D local sum. But, a key piece of the puzzle is that the impulse response of CWT & STFT is vertically concentrated, and response to impulse resembles response to general spikes and sharp deformations. So, we favor vertical summation over all frequencies.
- But we notice that the feature is also spread out in time - so we also spread in time, by the expected spread amount, which should approximately be the average size of the time-frequency kernel's temporal envelope.
- Lastly, we can sum absolute value, or its square (energy). Energy can be shown to accurately reflect "amount of information" (as I discovered), and is also the conserved quantity between time and frequency domains, so we favor it.
All together: (1) sum Tx_carved**2
along frequency; (2) compute running sum by convolving with brickwall - where red vertical lines are our labels (ground truth):
Good enough, I'd say.
Making predictions
The error scores were computed as abs(true_index - pred_index) / len(x)
. Two kinds of predictions were made:
one_peak_pred = peak
; peak = argmax(feature_1d)
, self-explanatory.
two_peaks_pred
. We zero the interval in feature_1d
from peak - pwidth
to peak + pwidth
, where pwidth
is the width of the wavelet computed earlier, and the width of our brickwall. Then we repeat argmax(feature_1d)
, that's peak2
, and take (peak + peak2) / 2
.
I'll comment on which one's best later.
The case of one prediction is clear, there's only one argmax
- but what about two or more? If we zero peak
then take argmax
again, the result could be (and almost certainly will be) right next to it. The previous visual has a major lapse, in that predictions were made by first zooming around the labels - the full vector:
Ouch! The top two distinct peaks are both wrong.
Amplify discriminants (2): template matching
Observe Tx_carved
again:
We note that the features of interest have a particular shape. Indeed, it's the impulse response of the CWT filterbank, which SSQ completely fails to reassign (as described earlier). So, instead of a naive local energy aggregation, we try convolving with the 2D impulse response - but only once, along time, left to right (not full 2D convolution):
Much better. And the updated zoom:
I call it Subtle Impulse Intensity (SII) vector: "subtle" since its norm in original CWT is much lower than the signal's; "impulse" since its shape is CWT's impulse response; "intensity" since it derives from inner product (similarity) with CWT magnitude (intensity).
Making predictions (2)
By eye, there's clearly two distinct peaks. How to code?
Observe that both peaks have approximately the same width - that's not coincidence, it's the effective temporal width, pwidth
, of the 2D impulse response. So we measure this width, and after finding peak
, zero the interval around it: SII[peak - pwidth:peak + pwidth] = 0
:
The next argmax(SII)
will be exactly where we want.
Amplify discriminants (3): second derivative carving
We apply a second derivative, this time upon Tx_carved
, then carve it again:
and SII is now
This is something I discovered by accident and haven't fully understood. It appears to clean up the sharp leftovers of the first pass - but further passes do not yield improvements.
Amplify discriminants (4): energy-frequency scaling
Here's the full CWT and SSQ_CWT:
Minimal low frequency content - that's expected. But also, the last two octaves - $2000\ \text{Hz}$ to $4000\ \text{Hz}$ to $8000\ \text{Hz}$ - are less bright. That's not because there's less actual information there - it's the "square power law" of audio: $P \sim 1/f^2$ (or so I've read in a few places... a reference):
Note we must scale via np.logspace()
rather than np.arange()
per CWT. Applying, we obtain:
Looks ideal.
Except, while I did favor higher frequencies, I didn't quite do it like this - see Addendum.
Attenuate invariants (2): lessen template confounding
I said CWT is better, but it's not better at everything. The algorithm up to this point fails at another example:
It misses the label near 1.5
and favors one around 0.7
, so let's inspect both (energy-scaled):
Indeed there's high-frequency signal content that's not sufficiently reassigned by SSQ, so carving ignores it. If we listen to the file, T2_D_00004326.wav
, near 0.7
, we hear a sharply spoken "tya"; there's also many "sh" later, also high frequency.
SSQ_STFT (energy-scaled):
Much more informative - it's the reverse case compared to our opening example, where STFT richly maps out the frequency interval that's compactified by CWT. Note that STFT can perfectly track LFM and CWT EFM (linear / exponential frequency modulation), and over sufficiently large frequency intervals, it's impossible for one transform to match the performance of the other just by tuning the wavelet/window.
So, we go ahead and carve, this time without the derivative (the ridges aren't as sharp), then invert (via one-integral), and then pass it to our existing algorithm. But here we should be conservative and carve little, as eliminating too much of the signal will also eliminate the resulting impulse. New results with this "STFT pre-filtering":
Still leftovers, but much less. New SII:
Other steps
It's too much for one post, so I summarize:
Ignore silence: regions of approximate silence are detected and omitted from (1) generating SII; (2) generating predictions.
- The minimum contiguous interval to qualify as "ignorable silence" is set to be
silence_interval = 0.2
, and minimum distance to a prediction as silence_proximity = 0.2
, both in seconds. This is based on an estimation as to what comprises the greatest possible interval between "transition" of audios, and is useful for any model, as such an interval must exist, otherwise it's impossible to distinguish a "transition" from simply silence.
- It was motivated by SII having large peaks due to sharp behavior in silences as audio is just starting; since SII is based on small-norm time-frequency patterns, and silence is small norm by definition, it makes false positives likelier.
Duration-based exclusion: I wrote that SII
around peak
is carved according to pwidth
, but that was only originally so. As long as we've not accounted for all invariants, there will be false positives, so it helps to eliminate as large an interval around peak
as possible, and I set that to min_audio_interval = 0.5
, which is an estimate of the shortest possible audio segment, before or after transition.
Multi-stage energy scaling: for various reasons, I found it better to rescale not just the original SSQ_CWT, but also SSQ_STFT and the 2D impulse response, as well as Tx_carved
: four scalings in total.
Zero-padding up to scoring: I zero pad and don't unpad until it's time to find peak
. Zero-padding is the correct prior (assume no audio before or after the segment), and operations between scoring and ssq_cwt
are subject to boundary effects (left edge influencing right edge and vice versa).
Moving sum of energy of SII: after cross-correlating with the 2D impulse response, I square the result (for reasons described earlier), and convolve with a rectangular window of width pwidth
, as a way of generating a vector of local sums of energies over an interval of a relevant size. That's because we expect the most of the impulse feature's energy to be confined within pwidth
in SSQ_CWT.
Favor one-peak prediction: originally it was two-peak, but after all the refinings, I no longer found any two peaks, and the carving around peak
degraded two-peak performance.
All S.I.I. vectors
Performance improvement suggestions
I rewrote the code a bit, now it's 17 sec CPU and 6 sec GPU for a 5 sec file (i7-7700HQ, GTX 1070). This can be made way faster by (1) moving everything to GPU, basically finding torch
equivalents to np
; (2) batching; (3) intelligently cutting down operations. (1) is easy, (2) tedious, (3) hard.
ELI5
SSQ upon speech makes pictures with sharp curves. Speech transitions are cones in those pictures. Speech is bright but cones are dim, so remove speech by erasing what's sharp. Put a transparent cone over the picture, slide it left to right, and underneath draw a line with a pen; if the picture looks more like that cone, draw higher. Once that's done, the two highest points drawn are our predictions.
Possible followup questions
How can SSQ_STFT be used for this task? -- There's more than I described; it managed to produce better discriminants on where my algorithm failed, but also much worse invariants, and I've not had the time to work it out. This approach has the potential to be much faster than the fastest version of my algorithm.
How to reliably find mean of sparse data? -- For thresholding or other uses. Outside this answer, I use sparse_mean
to find c
in log norm: log(1 + x/c)
.
How is 1D cross-correlation with 2D impulse response efficiently computed? -- Actually I already did it.
ML recommendations
Success with ML + features requires not only properly configuring the feature extractor with respect to data, but also the ML algorithm with respect to the transform. This is critical, and applicable to all below.
- Normalize properly: "log norm" almost never means
log(x)
, it's log(1 + x/c)
, and the right c
can make all the difference. Definitely avoid x /= max(abs(x))
, favor x /= x.std()
.
- Mel-spectrogram: I know little of it besides "take STFT and log-scale it", but that's plenty. OP stated they've tried and failed, I point to my note above.
- SSQ_CWT: this is a troubling feature to pair with ML but can be done
- Carved SSQ_CWT: it can either be used as a feature itself, or be inverted as a form of highly nonlinear "de-signaling", then fed to other feature extractors
- SII: I don't recall seeing any false negatives; failed predictions were only a result of false ranking. Hence it makes for an excellent "attention" vector to feed alongside other features to an NN.
- Spectrogram: while I spoke against it, recently I found something very convenient that I've not had the time to fully explore
- ??? This is likely my top recommendation, but for reasons, I can't disclose it at the moment. Will update in the near future if I remember.
Other answers
In short, I doubt any of the suggestions will work on their own, and will require significant further feature engineering. I'll comment on one of the answers right now:
Royi: at first I thought this may work, but not after inspecting the data. Some transitions are difficult to tell even by ear: the speaker, tone, tempo, and kind of words spoken can all be same - so the nonparametric models, which from I understand are anomaly detectors, are unlikely to work. The issue is that the norm of the transition event that constitutes the signal is very low even in time-frequency, so working with raw data will be catastrophic. I know nothing about Kalman but same concern applies.
Addendum
Energy vs frequency scaling
Checking the code while I'm writing the post, I notice that my scheme differs tremendously from a direct $1/f^2$ scaling:
- I scale at four separate stages - three CWT, one STFT. For STFT it's actually exactly $1/f^2$.
- For CWT, firstly, I manually set scaling limits at
0.1
and 1
, rather than according to the actual wavelet frequencies - and that's with respect to the CWT trimmed according to fmax_idx_frac
.
- I squared the scaler that's applied to amplitudes (modulus), not energies (modulus squared).
- One of the stages involves scaling the impulse response that's convolved (cross-correlated) with, and that was scaled by the square of the square - unintended, coding error.
So, I went ahead and fixed everything - and accuracy dropped (+2 missed predictions), and SII's were much worse. Possible explanations:
- The "square law" isn't exact nor agnostic to high vs low regimes; see Stokes's law of sound attenuation. For one, ratio of min to max frequency in CWT here was x32,000 - this completely obliterated non-high frequencies, and mind that this is applied thrice. So whatever I did may have simply coincided better with reality.
- Even if the law were exact, there may be other factors - namely, greater importance of high frequencies per the impulse response being less subject to distortions there. Indeed, before I implemented the STFT high frequency pre-filtering, there were more false positives - so perhaps we also need a low frequency pre-filtering.
- I've not changed any thresholding while testing. The ones in carving may certainly have required it.
- (Realized later) I've not actually "fixed everything", I probably should rescale filters according to Littlewood-Paley sum, I've not tried. It doesn't make sense for it to work for STFT but not CWT.
Citation
If you use a significant portion of this answer or code, please cite it as
John Muradeli, 2023. Identify abrupt changes in an audio waveform. URL: https://dsp.stackexchange.com/a/87512/50076
Note the code is MIT licensed, which requires attribution.
Code
Available at Github. Algorithm outputs are in test_algo.py
.
librosa 0.9.2
. The segment timestamps are 0.00-1.55-3.50-5.66. Please get the audio file here $\endgroup$0.00-1.89-A/1.89-2.32-B/2.32-3.12-A
.A
is the label of the first audio, andB
is the label of the second audio. So I assume the worst case should be at least0.5
to0.7
. If you're asking aboutabs(A-B)
then I expect it can be a bit worst. $\endgroup$dc = {"T2_T_00043244": (1.21, 1.74), "T2_T_00043245": (0.76, 3.38)}
. Can use pastebin for that. $\endgroup$