# Copyright 2026 The HuggingFace Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Feature extractor class for LFM3-Audio."""

import os
from dataclasses import dataclass

import numpy as np
import torch

from transformers.audio_utils import mel_filter_bank
from transformers.configuration_utils import PreTrainedConfig
from transformers.feature_extraction_sequence_utils import SequenceFeatureExtractor
from transformers.feature_extraction_utils import BatchFeature
from transformers.utils import FEATURE_EXTRACTOR_NAME, PROCESSOR_NAME, TensorType, cached_file, logging


logger = logging.get_logger(__name__)


@dataclass
class Lfm3AudioFeatureStreamingCache:
    """Unconsumed mono samples and frame count for one input stream."""

    samples: torch.Tensor | None = None
    num_samples: int = 0
    num_frames: int = 0
    finished: bool = False


class Lfm3AudioFeatureExtractor(SequenceFeatureExtractor):
    r"""
    Computes the log-mel spectrogram the LFM3-Audio speech encoder was trained on: a 25 ms Hann window every 10 ms
    without centering, 128 Slaney mel filters, a base-10 logarithm clamped from below and an affine normalization.
    Clips are right-padded so that no trailing samples are dropped, and every 8 frames become one encoder token.

    Args:
        feature_size (`int`, *optional*, defaults to 128):
            The number of mel filters.
        sampling_rate (`int`, *optional*, defaults to 16000):
            The sampling rate of the audio.
        n_fft (`int`, *optional*, defaults to 400):
            The FFT size.
        hop_length (`int`, *optional*, defaults to 160):
            The hop between frames, in samples.
        win_length (`int`, *optional*, defaults to 400):
            The window length, in samples.
        log_floor (`float`, *optional*, defaults to -12.0):
            The lower clamp of the log-mel values.
        normalization_offset (`float`, *optional*, defaults to 4.0):
            The value added to the log-mel features before dividing by `normalization_divisor`.
        normalization_divisor (`float`, *optional*, defaults to 4.0):
            The divisor of the normalization.
        encoder_stride (`int`, *optional*, defaults to 8):
            The number of spectrogram frames per encoder token.
        padding_value (`float`, *optional*, defaults to 0.0):
            The value used to pad the spectrograms of a batch.
    """

    model_input_names = ["input_features", "feature_attention_mask"]

    def __init__(
        self,
        feature_size: int = 128,
        sampling_rate: int = 16000,
        n_fft: int = 400,
        hop_length: int = 160,
        win_length: int = 400,
        log_floor: float = -12.0,
        normalization_offset: float = 4.0,
        normalization_divisor: float = 4.0,
        encoder_stride: int = 8,
        padding_value: float = 0.0,
        **kwargs,
    ):
        super().__init__(feature_size=feature_size, sampling_rate=sampling_rate, padding_value=padding_value, **kwargs)
        self.n_fft = n_fft
        self.hop_length = hop_length
        self.win_length = win_length
        self.log_floor = log_floor
        self.normalization_offset = normalization_offset
        self.normalization_divisor = normalization_divisor
        self.encoder_stride = encoder_stride
        self.mel_filters = mel_filter_bank(
            num_frequency_bins=n_fft // 2 + 1,
            num_mel_filters=feature_size,
            min_frequency=0.0,
            max_frequency=sampling_rate / 2,
            sampling_rate=sampling_rate,
            norm="slaney",
            mel_scale="slaney",
        )

    @classmethod
    def get_feature_extractor_dict(cls, pretrained_model_name_or_path, **kwargs):
        if os.path.isfile(pretrained_model_name_or_path):
            return super().get_feature_extractor_dict(pretrained_model_name_or_path, **kwargs)
        hub_kwargs = {
            key: value
            for key, value in kwargs.items()
            if key in ("cache_dir", "force_download", "local_files_only", "token", "revision", "subfolder", "proxies")
        }
        for filename in (PROCESSOR_NAME, FEATURE_EXTRACTOR_NAME):
            if (
                cached_file(
                    pretrained_model_name_or_path, filename, **hub_kwargs, _raise_exceptions_for_missing_entries=False
                )
                is not None
            ):
                return super().get_feature_extractor_dict(pretrained_model_name_or_path, **kwargs)
        config, remaining = PreTrainedConfig.get_config_dict(pretrained_model_name_or_path, **kwargs)
        if config.get("model_type") != "lfm3_audio" or "preprocessor" not in config:
            return super().get_feature_extractor_dict(pretrained_model_name_or_path, **kwargs)
        legacy = config["preprocessor"]
        fixed = {
            "feature_type": "log_mel",
            "window": "hann",
            "window_periodic": True,
            "center": False,
            "normalized": False,
            "onesided": True,
            "power": 2.0,
            "f_min": 0.0,
            "mel_scale": "slaney",
            "mel_norm": "slaney",
            "log_base": 10,
        }
        for key, expected in fixed.items():
            if legacy.get(key, expected) != expected:
                raise ValueError(f"Unsupported LFM3-Audio preprocessor setting: {key}={legacy[key]!r}")
        normalization = legacy.get("normalization", {})
        if normalization.get("type", "affine") != "affine":
            raise ValueError("LFM3-Audio requires affine log-mel normalization")
        sampling_rate = legacy.get("sample_rate", 16000)
        if legacy.get("f_max", sampling_rate / 2) != sampling_rate / 2:
            raise ValueError("LFM3-Audio requires mel filters spanning the full Nyquist interval")
        return {
            "feature_size": legacy.get("n_mels", 128),
            "sampling_rate": sampling_rate,
            "n_fft": legacy.get("n_fft", 400),
            "hop_length": legacy.get("hop_length", 160),
            "win_length": legacy.get("win_length", 400),
            "log_floor": legacy.get("logmel_floor", -12.0),
            "normalization_offset": normalization.get("offset", 4.0),
            "normalization_divisor": normalization.get("divisor", 4.0),
        }, remaining

    def num_frames(self, num_samples: int) -> int:
        padded = self.pad_length(num_samples)
        return 1 + (padded - self.n_fft) // self.hop_length

    def pad_length(self, num_samples: int) -> int:
        if num_samples < self.n_fft:
            return self.n_fft
        return num_samples + (-(num_samples - self.n_fft) % self.hop_length)

    def num_encoder_tokens(self, num_frames: int) -> int:
        return -(num_frames // -self.encoder_stride)

    def _log_mel(self, waveform: torch.Tensor) -> torch.Tensor:
        window = torch.hann_window(self.win_length, device=waveform.device)
        spectrogram = torch.stft(
            waveform,
            n_fft=self.n_fft,
            hop_length=self.hop_length,
            win_length=self.win_length,
            window=window,
            center=False,
            return_complex=True,
        )
        power = spectrogram.abs().square()
        mel_filters = torch.from_numpy(self.mel_filters).to(device=waveform.device, dtype=power.dtype)
        log_mel = (mel_filters.T @ power).log10().clamp(min=self.log_floor)
        return (log_mel + self.normalization_offset) / self.normalization_divisor

    @torch.no_grad()
    def stream(
        self,
        raw_speech: np.ndarray | torch.Tensor | list[float],
        cache: Lfm3AudioFeatureStreamingCache | None = None,
        is_final: bool = False,
        sampling_rate: int | None = None,
    ) -> tuple[BatchFeature, Lfm3AudioFeatureStreamingCache]:
        """Extract completed windows from a mono chunk, retaining overlap. Finalization pads the last window."""
        if sampling_rate is not None and sampling_rate != self.sampling_rate:
            raise ValueError(f"Expected {self.sampling_rate} Hz, got {sampling_rate} Hz")
        if cache is None:
            cache = Lfm3AudioFeatureStreamingCache()
        if cache.finished:
            raise ValueError("This stream is finalized; start with a new cache")
        chunk = torch.as_tensor(raw_speech, dtype=torch.float32)
        if chunk.ndim != 1:
            raise ValueError("stream expects one mono waveform")
        samples = chunk if cache.samples is None else torch.cat((cache.samples, chunk))
        num_samples = cache.num_samples + chunk.numel()
        if is_final:
            count = self.num_frames(num_samples) - cache.num_frames
            if count:
                size = (count - 1) * self.hop_length + self.n_fft
                samples = torch.nn.functional.pad(samples, (0, size - samples.numel()))
        else:
            count = max(0, 1 + (samples.numel() - self.n_fft) // self.hop_length)
        features = self._log_mel(samples) if count else samples.new_empty((self.feature_size, 0))
        # the cache changes only once the chunk's features exist, so a failed call can be retried
        cache.samples = samples[count * self.hop_length :].clone() if not is_final else None
        cache.num_samples = num_samples
        cache.num_frames += count
        cache.finished = is_final
        return BatchFeature(
            {
                "input_features": features[None],
                "feature_attention_mask": torch.ones((1, count), device=chunk.device, dtype=torch.long),
            }
        ), cache

    def __call__(
        self,
        raw_speech: np.ndarray | torch.Tensor | list[np.ndarray] | list[torch.Tensor] | list[float],
        sampling_rate: int | None = None,
        return_tensors: str | TensorType | None = None,
        **kwargs,
    ) -> BatchFeature:
        r"""
        raw_speech (`np.ndarray`, `torch.Tensor`, `list[np.ndarray]`, `list[torch.Tensor]`):
            One mono clip or a list of mono clips, as float samples at `sampling_rate`.
        sampling_rate (`int`, *optional*):
            The sampling rate of `raw_speech`, checked against the extractor's.
        return_tensors (`str` or [`~utils.TensorType`], *optional*):
            If set, will return tensors instead of numpy arrays.
        """
        if kwargs:
            raise TypeError(
                f"{self.__class__.__name__} does not implement {sorted(kwargs)}: clips are always right-padded to "
                "whole frames and `feature_attention_mask` is always returned"
            )
        if sampling_rate is not None and sampling_rate != self.sampling_rate:
            raise ValueError(
                f"{self.__class__.__name__} expects audio sampled at {self.sampling_rate} Hz, got {sampling_rate} Hz"
            )
        if sampling_rate is None:
            logger.warning_once(
                f"It is strongly recommended to pass the `sampling_rate` argument to `{self.__class__.__name__}()` "
                "to avoid silent errors."
            )
        if isinstance(raw_speech, (list, tuple)) and len(raw_speech) == 0:
            raise ValueError(f"{self.__class__.__name__} needs at least one clip")
        is_batched = isinstance(raw_speech, (list, tuple)) and hasattr(raw_speech[0], "__len__")
        is_batched = is_batched or (isinstance(raw_speech, (np.ndarray, torch.Tensor)) and raw_speech.ndim == 2)
        clips = list(raw_speech) if is_batched else [raw_speech]
        clips = [
            clip.detach().cpu().float()
            if isinstance(clip, torch.Tensor)
            else torch.as_tensor(np.asarray(clip, dtype=np.float32))
            for clip in clips
        ]
        for clip in clips:
            if clip.ndim != 1:
                raise ValueError(
                    f"{self.__class__.__name__} expects mono clips, got a clip of shape {tuple(clip.shape)}"
                )

        features = []
        for clip in clips:
            clip = torch.nn.functional.pad(clip, (0, self.pad_length(clip.shape[0]) - clip.shape[0]))
            features.append(self._log_mel(clip))
        lengths = [feature.shape[-1] for feature in features]
        max_length = max(lengths)
        input_features = torch.full((len(features), self.feature_size, max_length), float(self.padding_value))
        feature_attention_mask = torch.zeros((len(features), max_length), dtype=torch.long)
        for index, feature in enumerate(features):
            input_features[index, :, : feature.shape[-1]] = feature
            feature_attention_mask[index, : feature.shape[-1]] = 1

        data = {
            "input_features": input_features.numpy(),
            "feature_attention_mask": feature_attention_mask.numpy(),
        }
        return BatchFeature(data=data, tensor_type=return_tensors)


__all__ = ["Lfm3AudioFeatureExtractor", "Lfm3AudioFeatureStreamingCache"]
