File size: 9,783 Bytes
ebcf60e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
"""Extract prosody features from recorded audio.

Speak instead of drawing. The same two signals come out -- interpolated f0
and intensity at 200 Hz -- so the rest of the chain is unchanged: conformer
to mel, mel to words.

This is the other half of the demo's point. A drawn contour shows that
prosody alone carries recoverable words; real speech put through the
identical pipeline shows what the drawing is being compared against, and how
far a contour can be from a human one and still transcribe.

Ported from the reference implementation's `speech.py`, which in turn ports
the training preprocessor. The settings are not interchangeable with
sensible-looking alternatives: frame_length, nccf_thresh1, the zero-padding,
the six-frame trim and the two clamps all move the contour, and the
conformer only ever saw the combination below.
"""

from __future__ import annotations

import numpy as np

from .features import FEATURE_RATE_HZ, ProsodyFeatures

# Whisper's rate, and what the preprocessor ran at.
TARGET_SAMPLE_RATE = 16000

# pYAAPT settings from the training preprocessor's own config. Every one of
# these differed from the obvious default, and the conformer only ever saw
# contours produced with these.
FRAME_LENGTH_MS = 20.0
FRAME_SPACE_MS = 5.0          # 200 Hz
TDA_FRAME_LENGTH_MS = 25.0
NCCF_THRESH1 = 0.25
# f0_min/f0_max are deliberately not passed: the preprocessor leaves yaapt's
# own defaults (60/400 Hz) in place. Passing 50/500 widened the search and
# changed which frames came back voiced.

# The preprocessor drops the first six frames, which the zero-padding below
# makes spurious.
LEADING_FRAMES_DROPPED = 6
# Clamped after interpolation, as the preprocessor does.
F0_CEILING_HZ = 500.0
INTENSITY_CLAMP_DB = 20.0

# Guards on a posted recording. Audio is far bigger than a drawing, and the
# conformer export takes ten seconds regardless.
MAX_AUDIO_S = 10.0

# How far below the loudest frame still counts as speech.
#
# yaapt tracks a pitch in every frame it can and f0_interp fills the rest, so
# "voiced" is no help at all -- room noise comes back 100% voiced. Intensity
# separates them cleanly: measured on synthetic cases, noise sits 25-45 dB
# under the speech peak while the speech itself spans about 10 dB.
#
# Relative to the peak rather than absolute, because microphone gain varies
# by tens of dB between machines and an absolute floor would trim everything
# on a quiet one and nothing on a hot one.
#
# 18 dB, tightened from 25 after measuring what a stricter cut actually
# costs. The worry with a strict threshold is clipping a quiet trailing
# syllable, so it was tested against speech ending 12 dB down: at 18 dB that
# case still comes back within 0.04 s of its true length, while a loud room
# no longer survives. Below about 12 dB the threshold starts competing with
# the dynamic range of the speech itself.
SPEECH_FLOOR_BELOW_PEAK_DB = 18.0

# Frames of margin kept either side, so an onset is not clipped mid-syllable.
# Four, down from eight: at 20 ms a side the margin was contributing more
# excess than the threshold was, which is a strange way to be generous.
TRIM_MARGIN_FRAMES = 4


def resample(audio: np.ndarray, orig_sr: int, target_sr: int) -> np.ndarray:
    """Linear resample, which is what the reference implementation uses."""
    audio = np.asarray(audio, dtype=np.float64)
    if orig_sr == target_sr or audio.size == 0:
        return audio
    duration = audio.size / orig_sr
    target_n = max(1, int(round(duration * target_sr)))
    source_t = np.arange(audio.size) / orig_sr
    target_t = np.arange(target_n) / target_sr
    return np.interp(target_t, source_t, audio)


def extract_features(
    audio: np.ndarray, sr: int = TARGET_SAMPLE_RATE, trim: bool = True
) -> ProsodyFeatures:
    """Audio -> the f0 and intensity the conformer consumes.

    Returns raw f0_hz and intensity_db; call `.normalised()` for the model
    input, exactly as a drawing does.

    `trim` cuts the quiet lead-in and tail, which a live microphone always
    has and the training clips never did -- see `trim_to_speech`. Pass False
    to reproduce the reference implementation's output exactly, which is what
    the equivalence test does.
    """
    import parselmouth
    from amfm_decompy import basic_tools, pYAAPT

    from ._yaapt_patch import patched_interpolate

    audio = np.asarray(audio, dtype=np.float64)
    if audio.ndim > 1:
        audio = audio.squeeze()
    if audio.size == 0:
        return ProsodyFeatures(np.zeros(0), np.zeros(0))

    if sr != TARGET_SAMPLE_RATE:
        audio = resample(audio, sr, TARGET_SAMPLE_RATE)
        sr = TARGET_SAMPLE_RATE

    # The preprocessor patches pYAAPT's interpolation before every call and
    # reads samp_interp off the result rather than interpolating the voiced
    # values afterwards. Patch the class, not a stub, so samp_interp is
    # produced the same way.
    pYAAPT.PitchObj.interpolate = patched_interpolate

    # Zero-pad by half a frame each side before tracking. This shifts every
    # frame boundary, so without it the whole contour sits offset against
    # what training saw.
    pad = int(FRAME_LENGTH_MS / 1000 * sr) // 2
    padded = np.pad(audio, (pad, pad), "constant", constant_values=0)

    signal = basic_tools.SignalObj(padded, sr)
    pitch = pYAAPT.yaapt(
        signal,
        frame_length=FRAME_LENGTH_MS,
        frame_space=FRAME_SPACE_MS,
        nccf_thresh1=NCCF_THRESH1,
        tda_frame_length=TDA_FRAME_LENGTH_MS,
    )
    f0 = np.asarray(pitch.samp_interp, dtype=np.float64)
    f0 = f0[LEADING_FRAMES_DROPPED:]
    f0[f0 > F0_CEILING_HZ] = 0.0
    f0[f0 < 0.0] = 0.0

    # No minimum_pitch: the preprocessor passes only time_step, and
    # parselmouth's default pitch floor (100 Hz) sets the analysis window.
    # Passing 50 Hz instead doubles that window and smooths the envelope.
    sound = parselmouth.Sound(audio, sampling_frequency=sr, start_time=0)
    intensity = sound.to_intensity(time_step=1.0 / FEATURE_RATE_HZ)
    intensity_db = np.asarray(intensity.values.T.flatten(), dtype=np.float64)

    frames = min(len(f0), len(intensity_db))
    f0 = f0[:frames]
    intensity_db = intensity_db[:frames]
    intensity_db[intensity_db < INTENSITY_CLAMP_DB] = INTENSITY_CLAMP_DB

    if trim:
        f0, intensity_db = trim_to_speech(f0, intensity_db)

    return ProsodyFeatures(f0_hz=f0, intensity_db=intensity_db)


def trim_to_speech(
    f0: np.ndarray,
    intensity_db: np.ndarray,
    below_peak_db: float = SPEECH_FLOOR_BELOW_PEAK_DB,
    margin: int = TRIM_MARGIN_FRAMES,
) -> tuple[np.ndarray, np.ndarray]:
    """Cut the quiet lead-in and tail off a recording.

    A live microphone hands over whatever was in the room before and after
    the words, and the model has no way to tell that from speech: yaapt finds
    a pitch in noise, and f0_interp fills whatever it misses, so the contour
    arrives looking fully voiced throughout. Feeding that in means the mel --
    and the transcription -- describe several hundred milliseconds of nothing
    alongside the utterance.

    The reference implementation never needed this because its dataset clips
    came trimmed to the utterance already. A recording does not.

    Returns the arrays unchanged when there is no clear peak to measure
    against, which is the honest answer for a recording that is quiet
    throughout: better to pass it on and let the result show that than to
    invent a span.
    """
    if intensity_db.size == 0:
        return f0, intensity_db

    peak = float(intensity_db.max())
    # Nothing to separate: a recording that never rose above the clamp is
    # silence, and any threshold would pick an arbitrary window out of it.
    if peak <= INTENSITY_CLAMP_DB:
        return f0, intensity_db

    loud = np.flatnonzero(intensity_db > peak - below_peak_db)
    if loud.size == 0:
        return f0, intensity_db

    first = max(0, int(loud[0]) - margin)
    last = min(len(intensity_db) - 1, int(loud[-1]) + margin)
    return f0[first:last + 1], intensity_db[first:last + 1]


def decode_wav(data: bytes) -> tuple[np.ndarray, int]:
    """A WAV blob -> (mono float64 in -1..1, sample rate).

    The browser records to WebM/Opus by default, which needs a decoder this
    package should not carry; the page converts to WAV in an AudioContext
    first, so only the one format arrives here. `wave` is in the standard
    library, which keeps the dependency list where it is.
    """
    import io
    import wave

    try:
        with wave.open(io.BytesIO(data), "rb") as handle:
            channels = handle.getnchannels()
            width = handle.getsampwidth()
            rate = handle.getframerate()
            frames = handle.readframes(handle.getnframes())
    except Exception as error:
        # A malformed body is the caller's fault, so raise the kind of error
        # the route turns into a 400. Letting wave's own exception through
        # produced a 500, which says the server broke.
        raise ValueError(f"not a readable WAV: {error}") from error

    if width == 2:
        samples = np.frombuffer(frames, dtype="<i2").astype(np.float64) / 32768.0
    elif width == 4:
        samples = np.frombuffer(frames, dtype="<f4").astype(np.float64)
    elif width == 1:
        samples = (np.frombuffer(frames, dtype="u1").astype(np.float64) - 128) / 128.0
    else:
        raise ValueError(f"unsupported WAV sample width: {width} bytes")

    if channels > 1:
        samples = samples.reshape(-1, channels).mean(axis=1)

    seconds = samples.size / max(1, rate)
    if seconds > MAX_AUDIO_S:
        raise ValueError(
            f"recording is {seconds:.1f}s; the conformer export takes at "
            f"most {MAX_AUDIO_S:.0f}s"
        )
    return samples, rate