File size: 12,986 Bytes
73a450d | 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 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | """Turn drawn strokes into the prosody features a speech model expects.
The transcription models in `finetune_whisper` were trained on two signals
extracted from real speech at 200 Hz -- interpolated F0 and intensity -- which
is exactly what a stylus produces: vertical position is pitch, pressure is
loudness. This module resamples strokes onto that 200 Hz grid and applies the
same normalisation the training pipeline used, so a drawing can be fed to the
model as if it were speech.
The constants here are not free choices. They mirror the training pipeline's
own FEATURE_SCALES and the checkpoint's `model_config.json`; if those change,
these must change with them. This module is a verbatim port of the reference
implementation's `features.py`, minus code paths the demo never takes, so a
drawing is turned into model input exactly as in the paper.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .f0_interp import interpolate_f0
# Prosody features are extracted at 200 Hz (time_step 0.005 s) and the mel
# target runs at 100 Hz; the model's length_ratio=0.5 encodes that 2:1.
FEATURE_RATE_HZ = 200.0
# Physical-range normalisation, copied from FeaturesToMelDataset.FEATURE_SCALES.
F0_SCALE = 500.0 # Hz, human pitch range ~50-500
INTENSITY_SCALE = 100.0 # dB SPL, typical range 0-100
# Pen pressure is a 0..1 ratio; speech intensity is dB SPL. These bounds are
# measured, not chosen: across the extracted templates (2600 frames of real
# speech from the training distribution) intensity runs p5=51.8, p50=68.6,
# p95=81.0 dB. Mapping 0..1 onto 50..85 dB covers that spread and puts the
# speech median at pressure 0.53, so a natural mid-pressure stroke produces
# typical speech loudness.
#
# The earlier 45-80 dB guess put the median at 0.67 and clipped the top: a
# comfortable touch read ~6 dB quieter than real speech, and the loudest
# frames (84.5 dB) could not be reached at all.
#
# Re-derive with: uv run python scripts/calibrate_intensity.py
INTENSITY_FLOOR_DB = 50.0
INTENSITY_CEIL_DB = 85.0
# Where speech actually lives. These are measured from the extracted
# templates (2600 frames of real speech from the training distribution)
# rather than taken from textbook adult F0 ranges: p1 is MIDI 38.7 and p99.5
# is MIDI 64.9, so real contours reach well outside the "average adult"
# 85-255 Hz band.
#
# An earlier F2-C#4 (41-61) surface clipped 4.8% of frames, and the clipped
# frames are the pitch extremes that carry the most information. B1-G4 clips
# none of the measured 2600.
#
# Re-derive with: uv run python scripts/trace_fidelity.py (which reports
# clipping) after rebuilding templates.json.
SPEECH_MIDI_LOW = 35 # B1, 61.7 Hz
SPEECH_MIDI_HIGH = 67 # G4, 392.0 Hz
# The widest surface the model can be drawn on at all, as opposed to the
# range real speech occupies. F0_SCALE is 500 Hz, which is MIDI 71 -- above
# it the normalised input exceeds 1.0, a value training never contained.
# Downward there is no such wall: a low pitch simply normalises small.
#
# Drawing out here is out of distribution and will transcribe worse, but it
# is a usable surface rather than a broken one, and a wide sweep is sometimes
# what the drawing wants.
# Extended downward rather than upward. The ceiling is a hard wall -- MIDI 71
# is 494 Hz and F0_SCALE is 500, so there is almost nothing above it -- while
# downward a low pitch simply normalises small. Logged drawings sit at a p99
# of MIDI 58, so headroom above 60 is canvas nobody uses; the room worth
# having is underneath.
DRAWABLE_MIDI_LOW = 12 # C0, 16.4 Hz
DRAWABLE_MIDI_HIGH = 71 # B4, 493.9 Hz
def midi_to_hz(midi: float | np.ndarray) -> np.ndarray:
return 440.0 * 2.0 ** ((np.asarray(midi, dtype=np.float64) - 69.0) / 12.0)
@dataclass
class ProsodyFeatures:
"""f0 and intensity on the model's 200 Hz grid.
`raw` holds physical units (Hz, dB) for inspection; `normalised` is what
the model consumes.
"""
f0_hz: np.ndarray
intensity_db: np.ndarray
@property
def duration_s(self) -> float:
return len(self.f0_hz) / FEATURE_RATE_HZ
@property
def num_frames(self) -> int:
return len(self.f0_hz)
def normalised(self) -> np.ndarray:
"""[T, 2] float32 array scaled exactly as in training."""
f0 = np.asarray(self.f0_hz, dtype=np.float32) / F0_SCALE
intensity = np.asarray(self.intensity_db, dtype=np.float32) / INTENSITY_SCALE
return np.stack([f0, intensity], axis=-1).astype(np.float32)
def pressure_to_db(
pressure: np.ndarray,
floor_db: float = INTENSITY_FLOOR_DB,
ceil_db: float = INTENSITY_CEIL_DB,
) -> np.ndarray:
"""Map 0..1 stylus pressure onto a speech-like dB SPL range."""
p = np.clip(np.asarray(pressure, dtype=np.float64), 0.0, 1.0)
return floor_db + (ceil_db - floor_db) * p
def resample_stroke(
times_s: np.ndarray,
values: np.ndarray,
rate_hz: float = FEATURE_RATE_HZ,
) -> tuple[np.ndarray, np.ndarray]:
"""Resample irregular pen samples onto a uniform grid.
Tablet events arrive at an irregular rate, so the raw samples cannot be
used directly: the model assumes a fixed 200 Hz step. Returns the grid
times and the linearly interpolated values.
"""
times_s = np.asarray(times_s, dtype=np.float64)
values = np.asarray(values, dtype=np.float64)
if times_s.size == 0:
return np.zeros(0), np.zeros(0)
if times_s.size == 1:
return times_s.copy(), values.copy()
# Pen events are timestamped by arrival and can repeat or (rarely) go
# backwards; np.interp needs a strictly increasing x.
order = np.argsort(times_s, kind="stable")
times_s, values = times_s[order], values[order]
keep = np.concatenate(([True], np.diff(times_s) > 0))
times_s, values = times_s[keep], values[keep]
if times_s.size == 1:
return times_s.copy(), values.copy()
duration = times_s[-1] - times_s[0]
n = max(2, int(round(duration * rate_hz)) + 1)
grid = times_s[0] + np.arange(n) / rate_hz
# Clamp so the last grid point never extrapolates past the final sample.
grid = np.minimum(grid, times_s[-1])
return grid, np.interp(grid, times_s, values)
def smooth(values: np.ndarray, window_frames: int) -> np.ndarray:
"""Moving-average smoothing with edge padding.
Drawn lines are geometrically smooth but can still have corners that no
speech F0 contour would contain. A short smoothing window keeps the
contour closer to what the model saw in training.
"""
if window_frames <= 1 or values.size == 0:
return np.asarray(values, dtype=np.float64)
window_frames = min(window_frames, values.size)
kernel = np.ones(window_frames) / window_frames
pad = window_frames // 2
padded = np.pad(np.asarray(values, dtype=np.float64), pad, mode="edge")
out = np.convolve(padded, kernel, mode="same")
return out[pad : pad + values.size]
def features_from_stroke(
times_s: np.ndarray,
midi: np.ndarray,
pressure: np.ndarray,
smooth_ms: float = 25.0,
rate_hz: float = FEATURE_RATE_HZ,
) -> ProsodyFeatures:
"""Build 200 Hz prosody features from one pen stroke.
`times_s` are event timestamps in seconds, `midi` the pitch under the pen
(fractional MIDI note), `pressure` the stylus pressure in 0..1.
Pitch is read as a literal note and converted to Hz, so the drawing
surface must already sit in the range speech occupies -- see
SPEECH_MIDI_LOW/HIGH and the roll bounds the app is built with.
"""
times_s = np.asarray(times_s, dtype=np.float64)
midi = np.asarray(midi, dtype=np.float64)
pressure = np.asarray(pressure, dtype=np.float64)
if not (times_s.size == midi.size == pressure.size):
raise ValueError("times, midi and pressure must be the same length")
if times_s.size == 0:
return ProsodyFeatures(np.zeros(0, np.float64), np.zeros(0, np.float64))
grid, midi_grid = resample_stroke(times_s, midi, rate_hz)
_, pressure_grid = resample_stroke(times_s, pressure, rate_hz)
# resample_stroke derives its grid from the data, so both calls agree in
# length; guard anyway rather than silently mis-aligning the channels.
n = min(len(midi_grid), len(pressure_grid))
midi_grid, pressure_grid = midi_grid[:n], pressure_grid[:n]
f0_raw = midi_to_hz(midi_grid)
window = max(1, int(round(smooth_ms / 1000.0 * rate_hz)))
f0_hz = smooth(f0_raw, window)
intensity_db = smooth(pressure_to_db(pressure_grid), window)
return ProsodyFeatures(f0_hz=f0_hz, intensity_db=intensity_db)
# Quiet frames around a phrase, so it neither starts nor stops dead.
#
# Measured, not guessed: across the templates the quiet before and after the
# speech has a median of 0 ms and a maximum of 80 ms -- these dataset clips
# are trimmed tight to the utterance, so training really does start at
# speech. An earlier 250 ms on each side was 3x the observed maximum and
# contributed more frames than the drawing itself on a short phrase.
#
# 40 ms keeps the onset from being a step discontinuity without displacing
# content; it sits inside the observed range rather than beyond it.
LEAD_SILENCE_S = 0.04
TAIL_SILENCE_S = 0.04
def pad_with_silence(
features: ProsodyFeatures,
lead_s: float = LEAD_SILENCE_S,
tail_s: float = TAIL_SILENCE_S,
rate_hz: float = FEATURE_RATE_HZ,
) -> ProsodyFeatures:
"""Surround a phrase with quiet frames so it neither starts nor stops dead.
Pitch is held at the adjacent value rather than zeroed, matching how
f0_interp behaves through unvoiced regions, and intensity sits at the
floor.
"""
if features.num_frames == 0:
return features
lead = max(0, int(round(lead_s * rate_hz)))
tail = max(0, int(round(tail_s * rate_hz)))
if not lead and not tail:
return features
f0_parts, db_parts = [], []
if lead:
f0_parts.append(np.full(lead, features.f0_hz[0]))
db_parts.append(np.full(lead, INTENSITY_FLOOR_DB))
f0_parts.append(features.f0_hz)
db_parts.append(features.intensity_db)
if tail:
f0_parts.append(np.full(tail, features.f0_hz[-1]))
db_parts.append(np.full(tail, INTENSITY_FLOOR_DB))
return ProsodyFeatures(
f0_hz=np.concatenate(f0_parts),
intensity_db=np.concatenate(db_parts),
)
def concatenate(
features: list[ProsodyFeatures],
gap_s: float | list[float] = 0.12,
rate_hz: float = FEATURE_RATE_HZ,
lead_s: float = LEAD_SILENCE_S,
tail_s: float = TAIL_SILENCE_S,
) -> ProsodyFeatures:
"""Join strokes into one utterance, with a quiet gap between them.
Each stroke is a "note"; the gaps are what give the model something it can
read as separate syllables rather than one continuous tone.
`gap_s` may be a single duration or one per gap. Per-gap is what the
canvas passes: the pen's actual lift time is recorded, so there is no
reason to substitute a constant for it. A fixed gap also compounds -- at
120 ms across the 3-8 strokes a phrase typically has, the inserted silence
can outweigh the drawing itself.
Across a gap the pitch is **interpolated by pYAAPT's own routine**, not
held and not linearly ramped. The conformer was trained on f0_interp,
which median filters and fits a shape-preserving PCHIP spline through the
unvoiced regions; holding the previous value gives a flat shelf then a
step, and a straight line gives a shape speech never has. Gap frames are
marked unvoiced (f0 = 0) and handed to interpolate_f0, so they are filled
the same way training filled them. Intensity drops to the floor, which is
what makes the gap read as a pause.
"""
usable = [f for f in features if f.num_frames > 0]
if not usable:
return ProsodyFeatures(np.zeros(0, np.float64), np.zeros(0, np.float64))
if isinstance(gap_s, (int, float)):
gaps = [float(gap_s)] * max(0, len(usable) - 1)
else:
gaps = [float(g) for g in gap_s]
if len(gaps) < len(usable) - 1:
gaps += [0.12] * (len(usable) - 1 - len(gaps))
f0_parts: list[np.ndarray] = []
db_parts: list[np.ndarray] = []
for i, feat in enumerate(usable):
gap_frames = max(0, int(round(gaps[i - 1] * rate_hz))) if i > 0 else 0
if i > 0 and gap_frames:
# Zero marks the gap as unvoiced; interpolate_f0 fills it below,
# exactly as yaapt filled unvoiced regions in training.
f0_parts.append(np.zeros(gap_frames))
db_parts.append(np.full(gap_frames, INTENSITY_FLOOR_DB))
f0_parts.append(np.asarray(feat.f0_hz, dtype=np.float64))
db_parts.append(np.asarray(feat.intensity_db, dtype=np.float64))
joined = ProsodyFeatures(
f0_hz=interpolate_f0(np.concatenate(f0_parts)),
intensity_db=np.concatenate(db_parts),
)
return pad_with_silence(joined, lead_s, tail_s, rate_hz)
|