Daporte commited on
Commit
ebcf60e
·
verified ·
1 Parent(s): cc73002

Upload speech.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. speech.py +240 -0
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