BlueMagpie-TTS-Demo / audio_wsola.py
voidful's picture
Replace phase vocoder with guarded WSOLA
086aff2 verified
Raw History Blame
9.83 kB
"""Deterministic mono WSOLA time-scale modification for production speech.
The implementation deliberately has no optional native dependency. It keeps
pitch by moving and overlap-adding waveform frames in the time domain, while a
bounded similarity search aligns each new frame to the already synthesized
waveform. The public ``rate`` follows the librosa convention: values below
one make audio longer and values above one make it shorter.
"""
from __future__ import annotations
import math
import operator
from typing import Sequence
import numpy as np
WSOLA_MIN_RATE = 0.50
WSOLA_MAX_RATE = 2.00
WSOLA_FRAME_MS = 40.0
WSOLA_SYNTHESIS_HOP_MS = 10.0
WSOLA_SEARCH_MS = 6.0
WSOLA_MIN_SAMPLES = 256
WSOLA_CORRELATION_FLOOR = 0.05
def _positive_sample_count(value: object, name: str) -> int:
if isinstance(value, (bool, np.bool_)):
raise ValueError(f"{name} must be a positive integer")
try:
parsed = operator.index(value)
except (TypeError, ValueError, OverflowError) as error:
raise ValueError(f"{name} must be a positive integer") from error
if parsed <= 0:
raise ValueError(f"{name} must be a positive integer")
return int(parsed)
def _finite_positive(value: object, name: str) -> float:
try:
parsed = float(value)
except (TypeError, ValueError, OverflowError) as error:
raise ValueError(f"{name} must be finite and positive") from error
if not math.isfinite(parsed) or parsed <= 0.0:
raise ValueError(f"{name} must be finite and positive")
return parsed
def _mono_waveform(audio: np.ndarray | Sequence[float]) -> np.ndarray:
waveform = np.asarray(audio)
if waveform.ndim == 2 and 1 in waveform.shape:
waveform = waveform.reshape(-1)
if waveform.ndim != 1:
raise ValueError("audio must be mono")
waveform = np.ascontiguousarray(waveform, dtype=np.float32)
if waveform.size < WSOLA_MIN_SAMPLES:
raise ValueError("audio is too short for WSOLA")
if not np.isfinite(waveform).all():
raise ValueError("audio must contain only finite samples")
return waveform
def _frame_positions(target_length: int, frame_length: int, hop: int) -> tuple[int, ...]:
final_start = target_length - frame_length
if final_start <= 0:
return (0,)
positions = list(range(0, final_start + 1, hop))
if positions[-1] != final_start:
positions.append(final_start)
return tuple(positions)
def _best_source_position(
signal: np.ndarray,
synthesized: np.ndarray,
weight: np.ndarray,
*,
output_start: int,
current_end: int,
predicted_start: int,
previous_start: int,
predicted_step: int,
frame_length: int,
search_radius: int,
) -> int:
"""Return a monotonic, waveform-similar source position."""
source_limit = signal.size - frame_length
lower = max(
0,
predicted_start - search_radius,
previous_start + max(1, int(round(0.35 * predicted_step))),
)
upper = min(source_limit, predicted_start + search_radius)
if lower > upper:
return int(np.clip(predicted_start, 0, source_limit))
overlap = min(frame_length, max(0, current_end - output_start))
if overlap < 32 or lower == upper:
return int(np.clip(predicted_start, lower, upper))
overlap_weights = weight[output_start : output_start + overlap]
usable = overlap_weights > 1.0e-4
if int(np.count_nonzero(usable)) < 32:
return int(np.clip(predicted_start, lower, upper))
first = int(np.flatnonzero(usable)[0])
last = int(np.flatnonzero(usable)[-1]) + 1
if last - first < 32:
return int(np.clip(predicted_start, lower, upper))
reference = (
synthesized[output_start + first : output_start + last]
/ np.maximum(
weight[output_start + first : output_start + last],
np.float32(1.0e-7),
)
)
# Pre-emphasis makes the search follow speech fine structure rather than
# matching only the slowly changing loudness envelope.
reference = np.diff(reference, prepend=reference[0]).astype(
np.float64,
copy=False,
)
reference -= float(np.mean(reference))
reference_norm = float(np.linalg.norm(reference))
if not math.isfinite(reference_norm) or reference_norm <= 1.0e-7:
return int(np.clip(predicted_start, lower, upper))
segment_length = last - first
search = signal[
lower + first : upper + first + segment_length
].astype(np.float64, copy=False)
search = np.diff(search, prepend=search[0])
candidate_count = upper - lower + 1
if search.size < segment_length + candidate_count - 1:
return int(np.clip(predicted_start, lower, upper))
dots = np.correlate(search, reference, mode="valid")
if dots.size != candidate_count:
return int(np.clip(predicted_start, lower, upper))
squared = np.square(search, dtype=np.float64)
cumulative = np.concatenate(
(np.zeros(1, dtype=np.float64), np.cumsum(squared, dtype=np.float64))
)
norms = np.sqrt(
np.maximum(
cumulative[segment_length:] - cumulative[:-segment_length],
0.0,
)
)
scores = dots / np.maximum(norms * reference_norm, 1.0e-12)
finite = np.isfinite(scores)
if not finite.any():
return int(np.clip(predicted_start, lower, upper))
scores = np.where(finite, scores, -np.inf)
best_offset = int(np.argmax(scores))
if float(scores[best_offset]) < WSOLA_CORRELATION_FLOOR:
return int(np.clip(predicted_start, lower, upper))
return lower + best_offset
def wsola_time_stretch(
audio: np.ndarray | Sequence[float],
*,
rate: float,
sample_rate: int,
frame_ms: float = WSOLA_FRAME_MS,
synthesis_hop_ms: float = WSOLA_SYNTHESIS_HOP_MS,
search_ms: float = WSOLA_SEARCH_MS,
) -> np.ndarray:
"""Return a deterministic, pitch-preserving WSOLA time stretch.
The output length is exactly ``round(input_samples / rate)``. Invalid,
non-finite, multi-channel or pathologically short inputs raise instead of
falling back to a lower-quality transform.
"""
waveform = _mono_waveform(audio)
source_rate = _positive_sample_count(sample_rate, "sample_rate")
stretch_rate = _finite_positive(rate, "rate")
if not WSOLA_MIN_RATE <= stretch_rate <= WSOLA_MAX_RATE:
raise ValueError(
f"rate must be between {WSOLA_MIN_RATE:.2f} and {WSOLA_MAX_RATE:.2f}"
)
frame_duration = _finite_positive(frame_ms, "frame_ms")
hop_duration = _finite_positive(synthesis_hop_ms, "synthesis_hop_ms")
search_duration = _finite_positive(search_ms, "search_ms")
if abs(stretch_rate - 1.0) < 1.0e-3:
return waveform.copy()
target_length = max(1, int(round(waveform.size / stretch_rate)))
minimum_extent = min(waveform.size, target_length)
if minimum_extent < WSOLA_MIN_SAMPLES:
raise ValueError("stretched audio is too short for WSOLA")
default_frame = max(
64,
int(round(frame_duration * source_rate / 1000.0)),
)
# Keep at least two synthesis frames even for unusually short utterances.
frame_length = min(default_frame, max(128, minimum_extent // 2))
frame_length = min(frame_length, minimum_extent)
default_hop = max(
16,
int(round(hop_duration * source_rate / 1000.0)),
)
synthesis_hop = min(default_hop, max(16, frame_length // 3))
if synthesis_hop >= frame_length:
synthesis_hop = max(1, frame_length // 4)
search_radius = max(
1,
int(round(search_duration * source_rate / 1000.0)),
)
output_positions = _frame_positions(
target_length,
frame_length,
synthesis_hop,
)
source_limit = waveform.size - frame_length
output_limit = target_length - frame_length
window = np.hanning(frame_length + 2)[1:-1].astype(np.float32)
synthesized = np.zeros(target_length, dtype=np.float32)
weight = np.zeros(target_length, dtype=np.float32)
previous_source = -1
previous_predicted = 0
current_end = 0
for frame_index, output_start in enumerate(output_positions):
if frame_index == 0:
predicted_source = 0
source_start = 0
elif frame_index + 1 == len(output_positions):
predicted_source = source_limit
source_start = source_limit
else:
predicted_source = int(
round(output_start * source_limit / max(1, output_limit))
)
predicted_step = max(1, predicted_source - previous_predicted)
source_start = _best_source_position(
waveform,
synthesized,
weight,
output_start=output_start,
current_end=current_end,
predicted_start=predicted_source,
previous_start=previous_source,
predicted_step=predicted_step,
frame_length=frame_length,
search_radius=search_radius,
)
output_end = output_start + frame_length
source_end = source_start + frame_length
synthesized[output_start:output_end] += (
waveform[source_start:source_end] * window
)
weight[output_start:output_end] += window
current_end = max(current_end, output_end)
previous_source = source_start
previous_predicted = predicted_source
valid = weight > 1.0e-7
if not valid.all():
raise RuntimeError("WSOLA overlap-add left uncovered output samples")
output = synthesized / weight
if not np.isfinite(output).all():
raise RuntimeError("WSOLA produced non-finite output")
return np.ascontiguousarray(output, dtype=np.float32)