Upload speech.py with huggingface_hub
Browse files
speech.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extract prosody features from recorded audio.
|
| 2 |
+
|
| 3 |
+
Speak instead of drawing. The same two signals come out -- interpolated f0
|
| 4 |
+
and intensity at 200 Hz -- so the rest of the chain is unchanged: conformer
|
| 5 |
+
to mel, mel to words.
|
| 6 |
+
|
| 7 |
+
This is the other half of the demo's point. A drawn contour shows that
|
| 8 |
+
prosody alone carries recoverable words; real speech put through the
|
| 9 |
+
identical pipeline shows what the drawing is being compared against, and how
|
| 10 |
+
far a contour can be from a human one and still transcribe.
|
| 11 |
+
|
| 12 |
+
Ported from the reference implementation's `speech.py`, which in turn ports
|
| 13 |
+
the training preprocessor. The settings are not interchangeable with
|
| 14 |
+
sensible-looking alternatives: frame_length, nccf_thresh1, the zero-padding,
|
| 15 |
+
the six-frame trim and the two clamps all move the contour, and the
|
| 16 |
+
conformer only ever saw the combination below.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
from .features import FEATURE_RATE_HZ, ProsodyFeatures
|
| 24 |
+
|
| 25 |
+
# Whisper's rate, and what the preprocessor ran at.
|
| 26 |
+
TARGET_SAMPLE_RATE = 16000
|
| 27 |
+
|
| 28 |
+
# pYAAPT settings from the training preprocessor's own config. Every one of
|
| 29 |
+
# these differed from the obvious default, and the conformer only ever saw
|
| 30 |
+
# contours produced with these.
|
| 31 |
+
FRAME_LENGTH_MS = 20.0
|
| 32 |
+
FRAME_SPACE_MS = 5.0 # 200 Hz
|
| 33 |
+
TDA_FRAME_LENGTH_MS = 25.0
|
| 34 |
+
NCCF_THRESH1 = 0.25
|
| 35 |
+
# f0_min/f0_max are deliberately not passed: the preprocessor leaves yaapt's
|
| 36 |
+
# own defaults (60/400 Hz) in place. Passing 50/500 widened the search and
|
| 37 |
+
# changed which frames came back voiced.
|
| 38 |
+
|
| 39 |
+
# The preprocessor drops the first six frames, which the zero-padding below
|
| 40 |
+
# makes spurious.
|
| 41 |
+
LEADING_FRAMES_DROPPED = 6
|
| 42 |
+
# Clamped after interpolation, as the preprocessor does.
|
| 43 |
+
F0_CEILING_HZ = 500.0
|
| 44 |
+
INTENSITY_CLAMP_DB = 20.0
|
| 45 |
+
|
| 46 |
+
# Guards on a posted recording. Audio is far bigger than a drawing, and the
|
| 47 |
+
# conformer export takes ten seconds regardless.
|
| 48 |
+
MAX_AUDIO_S = 10.0
|
| 49 |
+
|
| 50 |
+
# How far below the loudest frame still counts as speech.
|
| 51 |
+
#
|
| 52 |
+
# yaapt tracks a pitch in every frame it can and f0_interp fills the rest, so
|
| 53 |
+
# "voiced" is no help at all -- room noise comes back 100% voiced. Intensity
|
| 54 |
+
# separates them cleanly: measured on synthetic cases, noise sits 25-45 dB
|
| 55 |
+
# under the speech peak while the speech itself spans about 10 dB.
|
| 56 |
+
#
|
| 57 |
+
# Relative to the peak rather than absolute, because microphone gain varies
|
| 58 |
+
# by tens of dB between machines and an absolute floor would trim everything
|
| 59 |
+
# on a quiet one and nothing on a hot one.
|
| 60 |
+
#
|
| 61 |
+
# 18 dB, tightened from 25 after measuring what a stricter cut actually
|
| 62 |
+
# costs. The worry with a strict threshold is clipping a quiet trailing
|
| 63 |
+
# syllable, so it was tested against speech ending 12 dB down: at 18 dB that
|
| 64 |
+
# case still comes back within 0.04 s of its true length, while a loud room
|
| 65 |
+
# no longer survives. Below about 12 dB the threshold starts competing with
|
| 66 |
+
# the dynamic range of the speech itself.
|
| 67 |
+
SPEECH_FLOOR_BELOW_PEAK_DB = 18.0
|
| 68 |
+
|
| 69 |
+
# Frames of margin kept either side, so an onset is not clipped mid-syllable.
|
| 70 |
+
# Four, down from eight: at 20 ms a side the margin was contributing more
|
| 71 |
+
# excess than the threshold was, which is a strange way to be generous.
|
| 72 |
+
TRIM_MARGIN_FRAMES = 4
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def resample(audio: np.ndarray, orig_sr: int, target_sr: int) -> np.ndarray:
|
| 76 |
+
"""Linear resample, which is what the reference implementation uses."""
|
| 77 |
+
audio = np.asarray(audio, dtype=np.float64)
|
| 78 |
+
if orig_sr == target_sr or audio.size == 0:
|
| 79 |
+
return audio
|
| 80 |
+
duration = audio.size / orig_sr
|
| 81 |
+
target_n = max(1, int(round(duration * target_sr)))
|
| 82 |
+
source_t = np.arange(audio.size) / orig_sr
|
| 83 |
+
target_t = np.arange(target_n) / target_sr
|
| 84 |
+
return np.interp(target_t, source_t, audio)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def extract_features(
|
| 88 |
+
audio: np.ndarray, sr: int = TARGET_SAMPLE_RATE, trim: bool = True
|
| 89 |
+
) -> ProsodyFeatures:
|
| 90 |
+
"""Audio -> the f0 and intensity the conformer consumes.
|
| 91 |
+
|
| 92 |
+
Returns raw f0_hz and intensity_db; call `.normalised()` for the model
|
| 93 |
+
input, exactly as a drawing does.
|
| 94 |
+
|
| 95 |
+
`trim` cuts the quiet lead-in and tail, which a live microphone always
|
| 96 |
+
has and the training clips never did -- see `trim_to_speech`. Pass False
|
| 97 |
+
to reproduce the reference implementation's output exactly, which is what
|
| 98 |
+
the equivalence test does.
|
| 99 |
+
"""
|
| 100 |
+
import parselmouth
|
| 101 |
+
from amfm_decompy import basic_tools, pYAAPT
|
| 102 |
+
|
| 103 |
+
from ._yaapt_patch import patched_interpolate
|
| 104 |
+
|
| 105 |
+
audio = np.asarray(audio, dtype=np.float64)
|
| 106 |
+
if audio.ndim > 1:
|
| 107 |
+
audio = audio.squeeze()
|
| 108 |
+
if audio.size == 0:
|
| 109 |
+
return ProsodyFeatures(np.zeros(0), np.zeros(0))
|
| 110 |
+
|
| 111 |
+
if sr != TARGET_SAMPLE_RATE:
|
| 112 |
+
audio = resample(audio, sr, TARGET_SAMPLE_RATE)
|
| 113 |
+
sr = TARGET_SAMPLE_RATE
|
| 114 |
+
|
| 115 |
+
# The preprocessor patches pYAAPT's interpolation before every call and
|
| 116 |
+
# reads samp_interp off the result rather than interpolating the voiced
|
| 117 |
+
# values afterwards. Patch the class, not a stub, so samp_interp is
|
| 118 |
+
# produced the same way.
|
| 119 |
+
pYAAPT.PitchObj.interpolate = patched_interpolate
|
| 120 |
+
|
| 121 |
+
# Zero-pad by half a frame each side before tracking. This shifts every
|
| 122 |
+
# frame boundary, so without it the whole contour sits offset against
|
| 123 |
+
# what training saw.
|
| 124 |
+
pad = int(FRAME_LENGTH_MS / 1000 * sr) // 2
|
| 125 |
+
padded = np.pad(audio, (pad, pad), "constant", constant_values=0)
|
| 126 |
+
|
| 127 |
+
signal = basic_tools.SignalObj(padded, sr)
|
| 128 |
+
pitch = pYAAPT.yaapt(
|
| 129 |
+
signal,
|
| 130 |
+
frame_length=FRAME_LENGTH_MS,
|
| 131 |
+
frame_space=FRAME_SPACE_MS,
|
| 132 |
+
nccf_thresh1=NCCF_THRESH1,
|
| 133 |
+
tda_frame_length=TDA_FRAME_LENGTH_MS,
|
| 134 |
+
)
|
| 135 |
+
f0 = np.asarray(pitch.samp_interp, dtype=np.float64)
|
| 136 |
+
f0 = f0[LEADING_FRAMES_DROPPED:]
|
| 137 |
+
f0[f0 > F0_CEILING_HZ] = 0.0
|
| 138 |
+
f0[f0 < 0.0] = 0.0
|
| 139 |
+
|
| 140 |
+
# No minimum_pitch: the preprocessor passes only time_step, and
|
| 141 |
+
# parselmouth's default pitch floor (100 Hz) sets the analysis window.
|
| 142 |
+
# Passing 50 Hz instead doubles that window and smooths the envelope.
|
| 143 |
+
sound = parselmouth.Sound(audio, sampling_frequency=sr, start_time=0)
|
| 144 |
+
intensity = sound.to_intensity(time_step=1.0 / FEATURE_RATE_HZ)
|
| 145 |
+
intensity_db = np.asarray(intensity.values.T.flatten(), dtype=np.float64)
|
| 146 |
+
|
| 147 |
+
frames = min(len(f0), len(intensity_db))
|
| 148 |
+
f0 = f0[:frames]
|
| 149 |
+
intensity_db = intensity_db[:frames]
|
| 150 |
+
intensity_db[intensity_db < INTENSITY_CLAMP_DB] = INTENSITY_CLAMP_DB
|
| 151 |
+
|
| 152 |
+
if trim:
|
| 153 |
+
f0, intensity_db = trim_to_speech(f0, intensity_db)
|
| 154 |
+
|
| 155 |
+
return ProsodyFeatures(f0_hz=f0, intensity_db=intensity_db)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def trim_to_speech(
|
| 159 |
+
f0: np.ndarray,
|
| 160 |
+
intensity_db: np.ndarray,
|
| 161 |
+
below_peak_db: float = SPEECH_FLOOR_BELOW_PEAK_DB,
|
| 162 |
+
margin: int = TRIM_MARGIN_FRAMES,
|
| 163 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 164 |
+
"""Cut the quiet lead-in and tail off a recording.
|
| 165 |
+
|
| 166 |
+
A live microphone hands over whatever was in the room before and after
|
| 167 |
+
the words, and the model has no way to tell that from speech: yaapt finds
|
| 168 |
+
a pitch in noise, and f0_interp fills whatever it misses, so the contour
|
| 169 |
+
arrives looking fully voiced throughout. Feeding that in means the mel --
|
| 170 |
+
and the transcription -- describe several hundred milliseconds of nothing
|
| 171 |
+
alongside the utterance.
|
| 172 |
+
|
| 173 |
+
The reference implementation never needed this because its dataset clips
|
| 174 |
+
came trimmed to the utterance already. A recording does not.
|
| 175 |
+
|
| 176 |
+
Returns the arrays unchanged when there is no clear peak to measure
|
| 177 |
+
against, which is the honest answer for a recording that is quiet
|
| 178 |
+
throughout: better to pass it on and let the result show that than to
|
| 179 |
+
invent a span.
|
| 180 |
+
"""
|
| 181 |
+
if intensity_db.size == 0:
|
| 182 |
+
return f0, intensity_db
|
| 183 |
+
|
| 184 |
+
peak = float(intensity_db.max())
|
| 185 |
+
# Nothing to separate: a recording that never rose above the clamp is
|
| 186 |
+
# silence, and any threshold would pick an arbitrary window out of it.
|
| 187 |
+
if peak <= INTENSITY_CLAMP_DB:
|
| 188 |
+
return f0, intensity_db
|
| 189 |
+
|
| 190 |
+
loud = np.flatnonzero(intensity_db > peak - below_peak_db)
|
| 191 |
+
if loud.size == 0:
|
| 192 |
+
return f0, intensity_db
|
| 193 |
+
|
| 194 |
+
first = max(0, int(loud[0]) - margin)
|
| 195 |
+
last = min(len(intensity_db) - 1, int(loud[-1]) + margin)
|
| 196 |
+
return f0[first:last + 1], intensity_db[first:last + 1]
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def decode_wav(data: bytes) -> tuple[np.ndarray, int]:
|
| 200 |
+
"""A WAV blob -> (mono float64 in -1..1, sample rate).
|
| 201 |
+
|
| 202 |
+
The browser records to WebM/Opus by default, which needs a decoder this
|
| 203 |
+
package should not carry; the page converts to WAV in an AudioContext
|
| 204 |
+
first, so only the one format arrives here. `wave` is in the standard
|
| 205 |
+
library, which keeps the dependency list where it is.
|
| 206 |
+
"""
|
| 207 |
+
import io
|
| 208 |
+
import wave
|
| 209 |
+
|
| 210 |
+
try:
|
| 211 |
+
with wave.open(io.BytesIO(data), "rb") as handle:
|
| 212 |
+
channels = handle.getnchannels()
|
| 213 |
+
width = handle.getsampwidth()
|
| 214 |
+
rate = handle.getframerate()
|
| 215 |
+
frames = handle.readframes(handle.getnframes())
|
| 216 |
+
except Exception as error:
|
| 217 |
+
# A malformed body is the caller's fault, so raise the kind of error
|
| 218 |
+
# the route turns into a 400. Letting wave's own exception through
|
| 219 |
+
# produced a 500, which says the server broke.
|
| 220 |
+
raise ValueError(f"not a readable WAV: {error}") from error
|
| 221 |
+
|
| 222 |
+
if width == 2:
|
| 223 |
+
samples = np.frombuffer(frames, dtype="<i2").astype(np.float64) / 32768.0
|
| 224 |
+
elif width == 4:
|
| 225 |
+
samples = np.frombuffer(frames, dtype="<f4").astype(np.float64)
|
| 226 |
+
elif width == 1:
|
| 227 |
+
samples = (np.frombuffer(frames, dtype="u1").astype(np.float64) - 128) / 128.0
|
| 228 |
+
else:
|
| 229 |
+
raise ValueError(f"unsupported WAV sample width: {width} bytes")
|
| 230 |
+
|
| 231 |
+
if channels > 1:
|
| 232 |
+
samples = samples.reshape(-1, channels).mean(axis=1)
|
| 233 |
+
|
| 234 |
+
seconds = samples.size / max(1, rate)
|
| 235 |
+
if seconds > MAX_AUDIO_S:
|
| 236 |
+
raise ValueError(
|
| 237 |
+
f"recording is {seconds:.1f}s; the conformer export takes at "
|
| 238 |
+
f"most {MAX_AUDIO_S:.0f}s"
|
| 239 |
+
)
|
| 240 |
+
return samples, rate
|