"""Cache immutable codec arrays for deterministic reference-conditioned batches.""" from __future__ import annotations import json from collections.abc import Iterator, Mapping, Sequence from pathlib import Path from typing import Protocol import numpy as np import soundfile as sf import torch from pydantic import Field from torch import Tensor from torch.utils.data import Dataset from .artifacts import Pair, Split, atomic_binary, digest_file, provenance, read_recordings, run_lock, verify_conservation, write_json from .audio import decode_audio, embedded_bytes, raw_rows, source_files from .codec import Codec from .configuration import Recipe, Specification from .layout import CODEBOOKS, SPECIALS, SequenceLayout, prompt_layout, target_layout from .partition import priority from .waveforms import resample_waveform from .progress import Tracker class TextTokenizer(Protocol): def encode(self, text: str, *, add_special_tokens: bool=False) -> list[int]: ... def get_added_vocab(self) -> dict[str, int]: ... class TokenEntry(Specification): record_id: str file: str offset_frames: int = Field(ge=0) frames: int = Field(ge=1) text_ids: tuple[int, ...] = Field(min_length=1) audio_sha256: str duration_s: float = Field(gt=0) padding_samples: int = Field(ge=0) evaluation_waveform_sha256: str | None = None def cache_codes(recipe: Recipe, codec: Codec, tokenizer: TextTokenizer) -> dict[str, object]: with run_lock(recipe.output): records = {record.record_id: record for record in read_recordings(recipe.output / 'recordings.jsonl')} by_location = {(record.shard, record.row): record for record in records.values()} with (recipe.output / 'pairs.jsonl').open() as pairs_stream: pairs = tuple((Pair.model_validate_json(line) for line in pairs_stream)) protected: set[str] = set() for split in ('validation', 'test'): candidates = sorted((pair for pair in pairs if pair.split == split), key=lambda pair: priority(recipe.seed, pair.target)) for pair in candidates[:recipe.evaluation_examples]: protected.update((pair.target, pair.reference)) shards = source_files(recipe) tracker = Tracker('codec shards', len(shards), interval_s=5) files: list[dict[str, object]] = [] total = 0 with atomic_binary(recipe.output / 'tokens.jsonl') as index: for shard in shards: token_path = recipe.output / 'tokens' / f'{shard.stem}.codes' entry_path = token_path.with_suffix('.jsonl') stats_path = token_path.with_suffix('.stats.json') identity = {'config_hash': recipe.digest, 'source_sha256': digest_file(shard), 'recordings_sha256': digest_file(recipe.output / 'recordings.jsonl')} if stats_path.is_file(): report = json.loads(stats_path.read_bytes()) if any((report.get(key) != value for key, value in identity.items())) or report.get('token_sha256') != digest_file(token_path) or report.get('index_sha256') != digest_file(entry_path): message = f'codec cache identity/checksum mismatch at {stats_path}' raise ValueError(message) else: offset = count = 0 with atomic_binary(token_path) as binary, atomic_binary(entry_path) as entries: for row, value in raw_rows(shard): recording = by_location.get((shard.name, row)) if recording is None: continue raw, text, _ = embedded_bytes(value, recipe.max_embedded_bytes) waveform, rate_hz = decode_audio(raw) converted = resample_waveform(waveform, rate_hz) waveform_digest: str | None = None if recording.record_id in protected: waveform_path = recipe.output / 'evaluation' / f'{recording.record_id}.wav' with atomic_binary(waveform_path) as waveform_stream: sf.write(waveform_stream, converted, 24000, format='WAV', subtype='FLOAT') waveform_digest = digest_file(waveform_path) codes = codec.encode(converted) values = np.asarray(codes.numpy(), dtype=' None: self.recipe = recipe self.specials = dict(specials) if set(SPECIALS) - self.specials.keys(): message = 'tokenizer is missing native TTS specials' raise ValueError(message) report = json.loads((recipe.output / 'tokens.stats.json').read_bytes()) if report.get('config_hash') != recipe.digest or report.get('index_sha256') != digest_file(recipe.output / 'tokens.jsonl'): message = 'codec index identity/checksum mismatch' raise ValueError(message) for item in report['files']: path = recipe.output / 'tokens' / item['file'] if Path(item['file']).name != item['file'] or digest_file(path) != item['sha256']: message = f'codec binary identity/checksum mismatch: {path}' raise ValueError(message) with (recipe.output / 'tokens.jsonl').open() as stream: self.entries = {entry.record_id: entry for line in stream if (entry := TokenEntry.model_validate_json(line))} with (recipe.output / 'pairs.jsonl').open() as stream: candidates = tuple((pair for line in stream if (pair := Pair.model_validate_json(line)).split == split)) accepted: list[Pair] = [] excluded: list[dict[str, object]] = [] for pair in candidates: target, reference = (self.entries[pair.target], self.entries[pair.reference]) positions = mixed_positions(target, reference) if positions > recipe.max_mixed_positions: excluded.append({'target': pair.target, 'reference': pair.reference, 'positions': positions, 'reason': 'mixed context exceeds maximum'}) else: accepted.append(pair) self.pairs = tuple(accepted) verify_conservation(len(candidates), len(self.pairs), len(excluded)) write_json(recipe.output / f'{split}-context-rejects.json', excluded) write_json(recipe.output / f'{split}-context.stats.json', {**provenance(recipe.digest), 'input_pairs': len(candidates), 'accepted_pairs': len(self.pairs), 'rejected_pairs': len(excluded)}) if not self.pairs: message = f'empty codec dataset split={split}' raise ValueError(message) self.identity = digest_file(recipe.output / 'tokens.stats.json') + digest_file(recipe.output / 'pairs.jsonl') def __len__(self) -> int: return len(self.pairs) def __getitem__(self, index: int) -> SequenceLayout: return self.layout(index) def positions(self, index: int) -> int: pair = self.pairs[index] return mixed_positions(self.entries[pair.target], self.entries[pair.reference]) def codes(self, entry: TokenEntry) -> Tensor: path = self.recipe.output / 'tokens' / entry.file if Path(entry.file).name != entry.file: message = f'invalid codec file name={entry.file!r} for {entry.record_id}' raise ValueError(message) byte_count = entry.frames * CODEBOOKS * 2 with path.open('rb') as stream: stream.seek(entry.offset_frames * CODEBOOKS * 2) raw = stream.read(byte_count) if len(raw) != byte_count: message = f'short codec read {path}:{entry.offset_frames}: {len(raw)}/{byte_count} bytes' raise ValueError(message) values = np.frombuffer(raw, dtype=' SequenceLayout: pair = self.pairs[index] target, reference = (self.entries[pair.target], self.entries[pair.reference]) prompt = prompt_layout(self.specials, target.text_ids, self.codes(reference), reference.text_ids) return target_layout(prompt, self.codes(target), self.recipe.max_mixed_positions) def windows(self) -> Iterator[tuple[int, ...]]: order = sorted(range(len(self.pairs)), key=lambda index: priority(self.recipe.seed, self.pairs[index].target)) for start in range(0, len(order), self.recipe.update_examples): window = order[start:start + self.recipe.update_examples] yield tuple(sorted(window, key=self.positions)) def collate_layouts(layouts: Sequence[SequenceLayout], device: torch.device) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]: if not layouts: message = 'cannot collate an empty codec microbatch' raise ValueError(message) longest = max((layout.positions for layout in layouts)) text = torch.zeros((len(layouts), longest), dtype=torch.long) codes = torch.zeros((len(layouts), longest, CODEBOOKS), dtype=torch.long) audio = torch.zeros_like(text, dtype=torch.bool) valid = torch.zeros_like(text, dtype=torch.bool) labels = torch.full_like(codes, -100) for row, layout in enumerate(layouts): size = layout.positions text[row, :size], codes[row, :size] = (layout.text_ids, layout.audio_codes) audio[row, :size], labels[row, :size] = (layout.audio_mask, layout.labels) valid[row, :size] = True values: tuple[Tensor, ...] = (text, codes, audio, valid, labels) if device.type == 'cuda': values = tuple((value.pin_memory() for value in values)) moved = tuple((value.to(device, non_blocking=device.type == 'cuda') for value in values)) return (moved[0], moved[1], moved[2], moved[3], moved[4]) def collate_cpu(layouts: Sequence[SequenceLayout]) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]: return collate_layouts(layouts, torch.device('cpu')) def mixed_positions(target: TokenEntry, reference: TokenEntry) -> int: return 5 + len(target.text_ids) + len(reference.text_ids) + target.frames + reference.frames + 2 * (CODEBOOKS - 1)