CoolFace
Apppublic

softwareweaver/MusicGen

sourceHugging Facecc-by-nc-4.0updated 11mo agoView on Hugging Face
0likes
manager.py387 linesDownload Raw Back to samples
1# Copyright (c) Meta Platforms, Inc. and affiliates.2# All rights reserved.3#4# This source code is licensed under the license found in the5# LICENSE file in the root directory of this source tree.6 7"""8API that can manage the storage and retrieval of generated samples produced by experiments.9 10It offers the following benefits:11* Samples are stored in a consistent way across epoch12* Metadata about the samples can be stored and retrieved13* Can retrieve audio14* Identifiers are reliable and deterministic for prompted and conditioned samples15* Can request the samples for multiple XPs, grouped by sample identifier16* For no-input samples (not prompt and no conditions), samples across XPs are matched17  by sorting their identifiers18"""19 20from concurrent.futures import ThreadPoolExecutor21from dataclasses import asdict, dataclass22from functools import lru_cache23import hashlib24import json25import logging26from pathlib import Path27import re28import typing as tp29import unicodedata30import uuid31 32import dora33import torch34 35from ...data.audio import audio_read, audio_write36 37 38logger = logging.getLogger(__name__)39 40 41@dataclass42class ReferenceSample:43    id: str44    path: str45    duration: float46 47 48@dataclass49class Sample:50    id: str51    path: str52    epoch: int53    duration: float54    conditioning: tp.Optional[tp.Dict[str, tp.Any]]55    prompt: tp.Optional[ReferenceSample]56    reference: tp.Optional[ReferenceSample]57    generation_args: tp.Optional[tp.Dict[str, tp.Any]]58 59    def __hash__(self):60        return hash(self.id)61 62    def audio(self) -> tp.Tuple[torch.Tensor, int]:63        return audio_read(self.path)64 65    def audio_prompt(self) -> tp.Optional[tp.Tuple[torch.Tensor, int]]:66        return audio_read(self.prompt.path) if self.prompt is not None else None67 68    def audio_reference(self) -> tp.Optional[tp.Tuple[torch.Tensor, int]]:69        return audio_read(self.reference.path) if self.reference is not None else None70 71 72class SampleManager:73    """Audio samples IO handling within a given dora xp.74 75    The sample manager handles the dumping and loading logic for generated and76    references samples across epochs for a given xp, providing a simple API to77    store, retrieve and compare audio samples.78 79    Args:80        xp (dora.XP): Dora experiment object. The XP contains information on the XP folder81            where all outputs are stored and the configuration of the experiment,82            which is useful to retrieve audio-related parameters.83        map_reference_to_sample_id (bool): Whether to use the sample_id for all reference samples84            instead of generating a dedicated hash id. This is useful to allow easier comparison85            with ground truth sample from the files directly without having to read the JSON metadata86            to do the mapping (at the cost of potentially dumping duplicate prompts/references87            depending on the task).88    """89    def __init__(self, xp: dora.XP, map_reference_to_sample_id: bool = False):90        self.xp = xp91        self.base_folder: Path = xp.folder / xp.cfg.generate.path92        self.reference_folder = self.base_folder / 'reference'93        self.map_reference_to_sample_id = map_reference_to_sample_id94        self.samples: tp.List[Sample] = []95        self._load_samples()96 97    @property98    def latest_epoch(self):99        """Latest epoch across all samples."""100        return max(self.samples, key=lambda x: x.epoch).epoch if self.samples else 0101 102    def _load_samples(self):103        """Scan the sample folder and load existing samples."""104        jsons = self.base_folder.glob('**/*.json')105        with ThreadPoolExecutor(6) as pool:106            self.samples = list(pool.map(self._load_sample, jsons))107 108    @staticmethod109    @lru_cache(2**26)110    def _load_sample(json_file: Path) -> Sample:111        with open(json_file, 'r') as f:112            data: tp.Dict[str, tp.Any] = json.load(f)113        # fetch prompt data114        prompt_data = data.get('prompt')115        prompt = ReferenceSample(id=prompt_data['id'], path=prompt_data['path'],116                                 duration=prompt_data['duration']) if prompt_data else None117        # fetch reference data118        reference_data = data.get('reference')119        reference = ReferenceSample(id=reference_data['id'], path=reference_data['path'],120                                    duration=reference_data['duration']) if reference_data else None121        # build sample object122        return Sample(id=data['id'], path=data['path'], epoch=data['epoch'], duration=data['duration'],123                      prompt=prompt, conditioning=data.get('conditioning'), reference=reference,124                      generation_args=data.get('generation_args'))125 126    def _init_hash(self):127        return hashlib.sha1()128 129    def _get_tensor_id(self, tensor: torch.Tensor) -> str:130        hash_id = self._init_hash()131        hash_id.update(tensor.numpy().data)132        return hash_id.hexdigest()133 134    def _get_sample_id(self, index: int, prompt_wav: tp.Optional[torch.Tensor],135                       conditions: tp.Optional[tp.Dict[str, str]]) -> str:136        """Computes an id for a sample given its input data.137        This id is deterministic if prompt and/or conditions are provided by using a sha1 hash on the input.138        Otherwise, a random id of the form "noinput_{uuid4().hex}" is returned.139 140        Args:141            index (int): Batch index, Helpful to differentiate samples from the same batch.142            prompt_wav (torch.Tensor): Prompt used during generation.143            conditions (dict[str, str]): Conditioning used during generation.144        """145        # For totally unconditioned generations we will just use a random UUID.146        # The function get_samples_for_xps will do a simple ordered match with a custom key.147        if prompt_wav is None and not conditions:148            return f"noinput_{uuid.uuid4().hex}"149 150        # Human readable portion151        hr_label = ""152        # Create a deterministic id using hashing153        hash_id = self._init_hash()154        hash_id.update(f"{index}".encode())155        if prompt_wav is not None:156            hash_id.update(prompt_wav.numpy().data)157            hr_label += "_prompted"158        else:159            hr_label += "_unprompted"160        if conditions:161            encoded_json = json.dumps(conditions, sort_keys=True).encode()162            hash_id.update(encoded_json)163            cond_str = "-".join([f"{key}={slugify(value)}"164                                 for key, value in sorted(conditions.items())])165            cond_str = cond_str[:100]  # some raw text might be too long to be a valid filename166            cond_str = cond_str if len(cond_str) > 0 else "unconditioned"167            hr_label += f"_{cond_str}"168        else:169            hr_label += "_unconditioned"170 171        return hash_id.hexdigest() + hr_label172 173    def _store_audio(self, wav: torch.Tensor, stem_path: Path, overwrite: bool = False) -> Path:174        """Stores the audio with the given stem path using the XP's configuration.175 176        Args:177            wav (torch.Tensor): Audio to store.178            stem_path (Path): Path in sample output directory with file stem to use.179            overwrite (bool): When False (default), skips storing an existing audio file.180        Returns:181            Path: The path at which the audio is stored.182        """183        existing_paths = [184            path for path in stem_path.parent.glob(stem_path.stem + '.*')185            if path.suffix != '.json'186        ]187        exists = len(existing_paths) > 0188        if exists and overwrite:189            logger.warning(f"Overwriting existing audio file with stem path {stem_path}")190        elif exists:191            return existing_paths[0]192 193        audio_path = audio_write(stem_path, wav, **self.xp.cfg.generate.audio)194        return audio_path195 196    def add_sample(self, sample_wav: torch.Tensor, epoch: int, index: int = 0,197                   conditions: tp.Optional[tp.Dict[str, str]] = None, prompt_wav: tp.Optional[torch.Tensor] = None,198                   ground_truth_wav: tp.Optional[torch.Tensor] = None,199                   generation_args: tp.Optional[tp.Dict[str, tp.Any]] = None) -> Sample:200        """Adds a single sample.201        The sample is stored in the XP's sample output directory, under a corresponding epoch folder.202        Each sample is assigned an id which is computed using the input data. In addition to the203        sample itself, a json file containing associated metadata is stored next to it.204 205        Args:206            sample_wav (torch.Tensor): sample audio to store. Tensor of shape [channels, shape].207            epoch (int): current training epoch.208            index (int): helpful to differentiate samples from the same batch.209            conditions (dict[str, str], optional): conditioning used during generation.210            prompt_wav (torch.Tensor, optional): prompt used during generation. Tensor of shape [channels, shape].211            ground_truth_wav (torch.Tensor, optional): reference audio where prompt was extracted from.212                Tensor of shape [channels, shape].213            generation_args (dict[str, any], optional): dictionary of other arguments used during generation.214        Returns:215            Sample: The saved sample.216        """217        sample_id = self._get_sample_id(index, prompt_wav, conditions)218        reuse_id = self.map_reference_to_sample_id219        prompt, ground_truth = None, None220        if prompt_wav is not None:221            prompt_id = sample_id if reuse_id else self._get_tensor_id(prompt_wav.sum(0, keepdim=True))222            prompt_duration = prompt_wav.shape[-1] / self.xp.cfg.sample_rate223            prompt_path = self._store_audio(prompt_wav, self.base_folder / str(epoch) / 'prompt' / prompt_id)224            prompt = ReferenceSample(prompt_id, str(prompt_path), prompt_duration)225        if ground_truth_wav is not None:226            ground_truth_id = sample_id if reuse_id else self._get_tensor_id(ground_truth_wav.sum(0, keepdim=True))227            ground_truth_duration = ground_truth_wav.shape[-1] / self.xp.cfg.sample_rate228            ground_truth_path = self._store_audio(ground_truth_wav, self.base_folder / 'reference' / ground_truth_id)229            ground_truth = ReferenceSample(ground_truth_id, str(ground_truth_path), ground_truth_duration)230        sample_path = self._store_audio(sample_wav, self.base_folder / str(epoch) / sample_id, overwrite=True)231        duration = sample_wav.shape[-1] / self.xp.cfg.sample_rate232        sample = Sample(sample_id, str(sample_path), epoch, duration, conditions, prompt, ground_truth, generation_args)233        self.samples.append(sample)234        with open(sample_path.with_suffix('.json'), 'w') as f:235            json.dump(asdict(sample), f, indent=2)236        return sample237 238    def add_samples(self, samples_wavs: torch.Tensor, epoch: int,239                    conditioning: tp.Optional[tp.List[tp.Dict[str, tp.Any]]] = None,240                    prompt_wavs: tp.Optional[torch.Tensor] = None,241                    ground_truth_wavs: tp.Optional[torch.Tensor] = None,242                    generation_args: tp.Optional[tp.Dict[str, tp.Any]] = None) -> tp.List[Sample]:243        """Adds a batch of samples.244        The samples are stored in the XP's sample output directory, under a corresponding245        epoch folder. Each sample is assigned an id which is computed using the input data and their batch index.246        In addition to the sample itself, a json file containing associated metadata is stored next to it.247 248        Args:249            sample_wavs (torch.Tensor): Batch of audio wavs to store. Tensor of shape [batch_size, channels, shape].250            epoch (int): Current training epoch.251            conditioning (list of dict[str, str], optional): List of conditions used during generation,252                one per sample in the batch.253            prompt_wavs (torch.Tensor, optional): Prompts used during generation. Tensor of shape254                [batch_size, channels, shape].255            ground_truth_wav (torch.Tensor, optional): Reference audio where prompts were extracted from.256                Tensor of shape [batch_size, channels, shape].257            generation_args (dict[str, Any], optional): Dictionary of other arguments used during generation.258        Returns:259            samples (list of Sample): The saved audio samples with prompts, ground truth and metadata.260        """261        samples = []262        for idx, wav in enumerate(samples_wavs):263            prompt_wav = prompt_wavs[idx] if prompt_wavs is not None else None264            gt_wav = ground_truth_wavs[idx] if ground_truth_wavs is not None else None265            conditions = conditioning[idx] if conditioning is not None else None266            samples.append(self.add_sample(wav, epoch, idx, conditions, prompt_wav, gt_wav, generation_args))267        return samples268 269    def get_samples(self, epoch: int = -1, max_epoch: int = -1, exclude_prompted: bool = False,270                    exclude_unprompted: bool = False, exclude_conditioned: bool = False,271                    exclude_unconditioned: bool = False) -> tp.Set[Sample]:272        """Returns a set of samples for this XP. Optionally, you can filter which samples to obtain.273        Please note that existing samples are loaded during the manager's initialization, and added samples through this274        manager are also tracked. Any other external changes are not tracked automatically, so creating a new manager275        is the only way detect them.276 277        Args:278            epoch (int): If provided, only return samples corresponding to this epoch.279            max_epoch (int): If provided, only return samples corresponding to the latest epoch that is <= max_epoch.280            exclude_prompted (bool): If True, does not include samples that used a prompt.281            exclude_unprompted (bool): If True, does not include samples that did not use a prompt.282            exclude_conditioned (bool): If True, excludes samples that used conditioning.283            exclude_unconditioned (bool): If True, excludes samples that did not use conditioning.284        Returns:285            Samples (set of Sample): The retrieved samples matching the provided filters.286        """287        if max_epoch >= 0:288            samples_epoch = max(sample.epoch for sample in self.samples if sample.epoch <= max_epoch)289        else:290            samples_epoch = self.latest_epoch if epoch < 0 else epoch291        samples = {292            sample293            for sample in self.samples294            if (295                (sample.epoch == samples_epoch) and296                (not exclude_prompted or sample.prompt is None) and297                (not exclude_unprompted or sample.prompt is not None) and298                (not exclude_conditioned or not sample.conditioning) and299                (not exclude_unconditioned or sample.conditioning)300            )301        }302        return samples303 304 305def slugify(value: tp.Any, allow_unicode: bool = False):306    """Process string for safer file naming.307 308    Taken from https://github.com/django/django/blob/master/django/utils/text.py309 310    Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated311    dashes to single dashes. Remove characters that aren't alphanumerics,312    underscores, or hyphens. Convert to lowercase. Also strip leading and313    trailing whitespace, dashes, and underscores.314    """315    value = str(value)316    if allow_unicode:317        value = unicodedata.normalize("NFKC", value)318    else:319        value = (320            unicodedata.normalize("NFKD", value)321            .encode("ascii", "ignore")322            .decode("ascii")323        )324    value = re.sub(r"[^\w\s-]", "", value.lower())325    return re.sub(r"[-\s]+", "-", value).strip("-_")326 327 328def _match_stable_samples(samples_per_xp: tp.List[tp.Set[Sample]]) -> tp.Dict[str, tp.List[Sample]]:329    # Create a dictionary of stable id -> sample per XP330    stable_samples_per_xp = [{331        sample.id: sample for sample in samples332        if sample.prompt is not None or sample.conditioning333    } for samples in samples_per_xp]334    # Set of all stable ids335    stable_ids = {id for samples in stable_samples_per_xp for id in samples.keys()}336    # Dictionary of stable id -> list of samples. If an XP does not have it, assign None337    stable_samples = {id: [xp.get(id) for xp in stable_samples_per_xp] for id in stable_ids}338    # Filter out ids that contain None values (we only want matched samples after all)339    # cast is necessary to avoid mypy linter errors.340    return {id: tp.cast(tp.List[Sample], samples) for id, samples in stable_samples.items() if None not in samples}341 342 343def _match_unstable_samples(samples_per_xp: tp.List[tp.Set[Sample]]) -> tp.Dict[str, tp.List[Sample]]:344    # For unstable ids, we use a sorted list since we'll match them in order345    unstable_samples_per_xp = [[346        sample for sample in sorted(samples, key=lambda x: x.id)347        if sample.prompt is None and not sample.conditioning348    ] for samples in samples_per_xp]349    # Trim samples per xp so all samples can have a match350    min_len = min([len(samples) for samples in unstable_samples_per_xp])351    unstable_samples_per_xp = [samples[:min_len] for samples in unstable_samples_per_xp]352    # Dictionary of index -> list of matched samples353    return {354        f'noinput_{i}': [samples[i] for samples in unstable_samples_per_xp] for i in range(min_len)355    }356 357 358def get_samples_for_xps(xps: tp.List[dora.XP], **kwargs) -> tp.Dict[str, tp.List[Sample]]:359    """Gets a dictionary of matched samples across the given XPs.360    Each dictionary entry maps a sample id to a list of samples for that id. The number of samples per id361    will always match the number of XPs provided and will correspond to each XP in the same order given.362    In other words, only samples that can be match across all provided XPs will be returned363    in order to satisfy this rule.364 365    There are two types of ids that can be returned: stable and unstable.366    * Stable IDs are deterministic ids that were computed by the SampleManager given a sample's inputs367      (prompts/conditioning). This is why we can match them across XPs.368    * Unstable IDs are of the form "noinput_{idx}" and are generated on-the-fly, in order to map samples369      that used non-deterministic, random ids. This is the case for samples that did not use prompts or370      conditioning for their generation. This function will sort these samples by their id and match them371      by their index.372 373    Args:374        xps: a list of XPs to match samples from.375        start_epoch (int): If provided, only return samples corresponding to this epoch or newer.376        end_epoch (int): If provided, only return samples corresponding to this epoch or older.377        exclude_prompted (bool): If True, does not include samples that used a prompt.378        exclude_unprompted (bool): If True, does not include samples that did not use a prompt.379        exclude_conditioned (bool): If True, excludes samples that used conditioning.380        exclude_unconditioned (bool): If True, excludes samples that did not use conditioning.381    """382    managers = [SampleManager(xp) for xp in xps]383    samples_per_xp = [manager.get_samples(**kwargs) for manager in managers]384    stable_samples = _match_stable_samples(samples_per_xp)385    unstable_samples = _match_unstable_samples(samples_per_xp)386    return dict(stable_samples, **unstable_samples)387