One vowel, four envelopes, and the formants LPC finds
The vowel of "hod" at 100 Hz from so.klatt_synthesize, one time window in its middle. The
dots are its harmonics; the dashed lines are the formants it was given. Four envelopes are
drawn over them, each moved up or down to sit on the harmonics: LPC (order 18), CheapTrick, the
cepstrum liftered at half a period, and thirteen MFCCs, all from the same pre-emphasized
sound. LPC follows the peaks closely. Its roots are not all formants: with 18 poles at 16 kHz
some fit the slope of the source or the harmonics instead, which is why so.formant_track
fits ten poles below a 5000 Hz ceiling and then picks among them. The triangles are its F1 to
F3, within a few hertz of the given formants (the cell prints them). The cepstral envelope
keeps some ripple from the harmonics, so it has extra peaks below F1; thirteen MFCCs smooth F1
and F2 into one hump. MFCCs are not meant to find formants; the figure shows what each
envelope keeps.
Code
raw = so.klatt_synthesize(0.5, FS, F0=100, **hod)
vowel = preemphasize(raw)
time = 0.25
freqs = np.arange(0, 5001, 2.0)
envelopes = {}
lpc = so.LPC(vowel, win_dur=0.040, hop_dur=0.005)
column = int(np.argmin(np.abs(lpc.t - time)))
envelopes["LPC"] = lpc.envelope(freqs)[0, :, column]
envelopes["CheapTrick"] = so.cheaptrick(vowel, ([time], [100.0]))([time], freqs)[0, :, 0]
stft = so.STFT(vowel, win_dur=0.040, hop_dur=0.005)
cepstrum = so.Cepstrum(stft).lifter(0.5 / 100)
cep_freqs = np.arange(cepstrum.n_fft // 2 + 1) * FS / cepstrum.n_fft
envelopes["cepstrum"] = np.interp(freqs, cep_freqs, cepstrum.envelope()[0, :, column] ** 2)
mfcc = so.MFCC(vowel, triangles="area", hop_dur=0.005)
envelopes["13 MFCCs"] = mfcc.envelope(freqs)[0, :, int(np.argmin(np.abs(mfcc.t - time)))]
spectrum = np.abs(np.fft.rfft(vowel.data[FS // 4 - 320 : FS // 4 + 320, 0] * np.hanning(640), 8192)) ** 2
spectrum_freqs = np.fft.rfftfreq(8192, 1 / FS)
harmonic_freqs = np.arange(1, 50) * 100.0
harmonic_db = 10 * np.log10(np.interp(harmonic_freqs, spectrum_freqs, spectrum))
fig, ax = plt.subplots(figsize=(10, 3.6), layout="constrained")
ax.plot(harmonic_freqs, harmonic_db, "o", ms=3, color="k", label="harmonics")
for (name, envelope), color in zip(envelopes.items(), ["C3", "C0", "C2", "C4"], strict=True):
level_db = 10 * np.log10(envelope)
shift = np.mean(harmonic_db - np.interp(harmonic_freqs, freqs, level_db))
ax.plot(freqs, level_db + shift, color=color, lw=1.2 if name == "LPC" else 0.9, label=name)
found = peaks(freqs, level_db)[:3]
print(f"{name:10} first three peaks: " + ", ".join(f"{f:.0f}" for f in found) + " Hz")
roots, _ = lpc.candidates()
print("Order-18 LPC roots: " + ", ".join(f"{f:.0f}" for f in roots[0, :, column] if f < 5000) + " Hz")
track = so.formant_track(raw) # pre-emphasizes inside, as Praat does
lpc_formants = track.frequencies[0][:, int(np.argmin(np.abs(track.t - time)))]
print("so.formant_track: " + ", ".join(f"{f:.0f}" for f in lpc_formants) + " Hz (given 730, 1090, 2440)")
for formant, _ in HOD[:3]:
ax.axvline(formant, color="0.5", ls="--", lw=0.8)
ax.plot(
lpc_formants,
np.interp(lpc_formants, freqs, 10 * np.log10(envelopes["LPC"])) + 3,
"v",
color="C3",
ms=6,
label="so.formant_track",
)
ax.set(xlabel="Frequency (Hz)", ylabel="Level (dB)", xlim=(0, 5000))
ax.set_ylim(harmonic_db.max() - 70, harmonic_db.max() + 8)
ax.set_title("The vowel of 'hod' at 100 Hz: four envelopes, and the formants given (dashed)")
ax.legend(loc="upper right", fontsize=8, ncols=3)