Matoub-4B / audio.py
ainouche-abderahmane's picture
Upload folder using huggingface_hub
3699b0a verified
Raw History Blame Contribute Delete
11 kB
"""Audit embedded source audio through resumable shard transactions."""
from __future__ import annotations
import hashlib
import io
import json
import threading
from collections.abc import Iterator
from datetime import date
from pathlib import Path
from typing import BinaryIO
import numpy as np
import pyarrow.parquet as pq
import soundfile as sf
from pydantic import Field
from .artifacts import Recording, Rejection, atomic_binary, digest_file, provenance, read_recordings, run_lock, verify_conservation, write_json
from .configuration import Recipe, Specification
from .pairs import pair_recordings
from .partition import partition_recordings
from .waveforms import Waveform, measure_signal, resample_waveform, text_key
from .progress import Tracker, stage
DECODE_LOCK = threading.Lock()
class SourceFile(Specification):
path: str
sha256: str
class Acquisition(Specification):
revision: str
files: tuple[SourceFile, ...]
access_date: date
class ShardResult(Specification):
config_hash: str
source_sha256: str
rows: int = Field(ge=0)
accepted: int = Field(ge=0)
rejected: int = Field(ge=0)
recordings_sha256: str
rejects_sha256: str
provenance: dict[str, object]
def read_acquisition(recipe: Recipe) -> Acquisition:
path = recipe.source / 'acquisition.stats.json'
payload: object = json.loads(path.read_bytes())
if not isinstance(payload, dict) or not isinstance(payload.get('files'), list):
message = f'invalid acquisition file list: {path}'
raise TypeError(message)
files: list[SourceFile] = []
for item in payload['files']:
if not isinstance(item, dict) or not isinstance(item.get('path'), str):
message = f'invalid acquisition entry at {path}: {item!r}'
raise TypeError(message)
if item['path'].endswith('.parquet'):
files.append(SourceFile.model_validate({'path': item['path'], 'sha256': item.get('sha256')}))
acquisition = Acquisition.model_validate({'revision': payload.get('revision'), 'files': tuple(files), 'access_date': payload.get('access_date')})
if acquisition.revision != recipe.source_revision or not files:
message = f'source identity mismatch or no Parquet files at {path}: {acquisition.revision}'
raise ValueError(message)
return acquisition
def source_files(recipe: Recipe) -> tuple[Path, ...]:
path = recipe.source / 'acquisition.stats.json'
acquisition = read_acquisition(recipe)
expected: dict[str, str] = {}
for item in acquisition.files:
name = Path(item.path).name
if name in expected:
message = f'duplicate acquisition shard name {name!r} in {path}'
raise ValueError(message)
expected[name] = item.sha256
actual = tuple(sorted((recipe.source / 'data').glob('*.parquet')))
if set(expected) != {item.name for item in actual}:
message = f'source shard set differs from {path}: expected={sorted(expected)} actual={actual}'
raise ValueError(message)
for shard in actual:
observed = digest_file(shard)
if observed != expected[shard.name]:
message = f'checksum mismatch {shard}: observed={observed}, expected={expected[shard.name]}'
raise ValueError(message)
return actual
def raw_rows(shard: Path) -> Iterator[tuple[int, object]]:
with pq.ParquetFile(shard) as parquet:
offset = 0
for batch in parquet.iter_batches(batch_size=128, columns=['audio', 'transcription', 'client_id'], use_threads=False):
for value in batch.to_pylist():
yield (offset, value)
offset += 1
verify_conservation(parquet.metadata.num_rows, offset, 0)
def embedded_bytes(value: object, max_size_bytes: int) -> tuple[bytes, str, str]:
if not isinstance(value, dict):
message = f'expected audio record, got {type(value).__name__}'
raise TypeError(message)
audio, text, speaker = (value.get('audio'), value.get('transcription'), value.get('client_id'))
if not isinstance(audio, dict) or not isinstance(audio.get('bytes'), bytes):
message = 'missing embedded audio.bytes'
raise TypeError(message)
raw = audio['bytes']
if len(raw) > max_size_bytes or not raw:
message = f'embedded audio size_bytes={len(raw)}, limit={max_size_bytes}'
raise ValueError(message)
if not isinstance(text, str) or not text_key(text):
message = f'missing/empty transcription: {text!r}'
raise ValueError(message)
if not isinstance(speaker, str) or not speaker.strip():
message = f'missing/empty client_id: {speaker!r}'
raise ValueError(message)
return (raw, text, speaker)
def decode_audio(raw: bytes) -> tuple[Waveform, int]:
with DECODE_LOCK:
waveform, rate_hz = sf.read(io.BytesIO(raw), dtype='float32', always_2d=True)
if waveform.shape[1] != 1:
message = f'expected mono audio, got channels={waveform.shape[1]}'
raise ValueError(message)
mono = np.asarray(waveform[:, 0], dtype=np.float32)
measure_signal(mono, rate_hz)
return (mono, int(rate_hz))
def recording_for(value: object, shard: Path, row: int, recipe: Recipe, retrieval_date: str) -> Recording:
raw, text, speaker = embedded_bytes(value, recipe.max_embedded_bytes)
waveform, rate_hz = decode_audio(raw)
signal = measure_signal(waveform, rate_hz)
duration_s = signal.samples / rate_hz
if not recipe.min_duration_s <= duration_s <= recipe.max_duration_s:
message = f'duration_s={duration_s} outside [{recipe.min_duration_s},{recipe.max_duration_s}]'
raise ValueError(message)
converted = resample_waveform(waveform, rate_hz, recipe.rate_hz)
measure_signal(converted, recipe.rate_hz)
identity = f'{recipe.source_revision}:{shard.name}:{row}'
return Recording(record_id=hashlib.sha256(identity.encode()).hexdigest(), shard=shard.name, row=row, audio_sha256=hashlib.sha256(raw).hexdigest(), speaker=speaker, text=text, text_key=text_key(text), stored_rate_hz=rate_hz, samples=signal.samples, prepared_samples=converted.size, duration_s=duration_s, peak=signal.peak, rms_dbfs=signal.rms_dbfs, clipping_fraction=signal.clipping_fraction, source_id=recipe.source_id, source_revision=recipe.source_revision, license=recipe.license, retrieval_date=retrieval_date)
def write_line(stream: BinaryIO, value: Specification) -> None:
stream.write((value.model_dump_json() + '\n').encode())
def prepare_shard(shard: Path, recipe: Recipe, date: str) -> ShardResult:
root = recipe.output / 'shards' / shard.stem
stats_path = root / 'audit.stats.json'
accepted_path, rejects_path = (root / 'recordings.jsonl', root / 'rejects.jsonl')
source_digest = digest_file(shard)
if stats_path.is_file():
result = ShardResult.model_validate_json(stats_path.read_bytes())
if result.config_hash != recipe.digest or result.source_sha256 != source_digest or result.recordings_sha256 != digest_file(accepted_path) or (result.rejects_sha256 != digest_file(rejects_path)):
message = f'prepared artifact identity/checksum mismatch: {stats_path}'
raise ValueError(message)
verify_conservation(result.rows, result.accepted, result.rejected)
return result
rows = accepted = rejected = 0
with atomic_binary(accepted_path) as kept, atomic_binary(rejects_path) as rejects:
for row, value in raw_rows(shard):
rows += 1
try:
record = recording_for(value, shard, row, recipe, date)
except (ValueError, TypeError, sf.LibsndfileError) as error:
rejected += 1
rejection = Rejection(record_id=f'{shard.name}:{row}', shard=shard.name, row=row, reason=str(error))
write_line(rejects, rejection)
else:
accepted += 1
write_line(kept, record)
verify_conservation(rows, accepted, rejected)
result = ShardResult(config_hash=recipe.digest, source_sha256=source_digest, rows=rows, accepted=accepted, rejected=rejected, recordings_sha256=digest_file(accepted_path), rejects_sha256=digest_file(rejects_path), provenance=provenance(recipe.digest))
write_json(stats_path, result.model_dump())
return result
def prepare_audio(recipe: Recipe) -> dict[str, object]:
with run_lock(recipe.output), stage('decode source audio'):
shards = source_files(recipe)
tracker = Tracker('decoded shards', len(shards), interval_s=5)
counts: list[ShardResult] = []
retrieval_date = read_acquisition(recipe).access_date.isoformat()
with atomic_binary(recipe.output / 'recordings.jsonl') as combined:
for shard in shards:
counts.append(prepare_shard(shard, recipe, retrieval_date))
path = recipe.output / 'shards' / shard.stem / 'recordings.jsonl'
with path.open('rb') as stream:
while (block := stream.read(1 << 20)):
combined.write(block)
tracker.advance()
report: dict[str, object] = {**provenance(recipe.digest), 'input_rows': sum((item.rows for item in counts)), 'accepted_rows': sum((item.accepted for item in counts)), 'rejected_rows': sum((item.rejected for item in counts)), 'language_reviewed': False, 'recordings_sha256': digest_file(recipe.output / 'recordings.jsonl'), 'shards': [item.model_dump() for item in counts]}
write_json(recipe.output / 'preparation.stats.json', report)
return report
def prepare_pairs(recipe: Recipe) -> dict[str, object]:
with run_lock(recipe.output), stage('partition reference pairs'):
records = tuple(read_recordings(recipe.output / 'recordings.jsonl'))
partition = partition_recordings(records, recipe)
pairing = pair_recordings(records, partition, recipe.seed)
for split in ('train', 'validation', 'test'):
if not any((pair.split == split for pair in pairing.pairs)):
message = f'no eligible pairs in {split}: inspect speaker/script coverage'
raise ValueError(message)
with atomic_binary(recipe.output / 'pairs.jsonl') as stream:
for pair in pairing.pairs:
write_line(stream, pair)
excluded = {**partition.excluded, **pairing.excluded}
write_json(recipe.output / 'pair-rejects.json', excluded)
verify_conservation(len(records), len(pairing.pairs), len(excluded))
report: dict[str, object] = {**provenance(recipe.digest), 'input_rows': len(records), 'paired_rows': len(pairing.pairs), 'excluded_rows': len(excluded), 'split_pairs': {split: sum((pair.split == split for pair in pairing.pairs)) for split in ('train', 'validation', 'test')}, 'recordings_sha256': digest_file(recipe.output / 'recordings.jsonl'), 'pairs_sha256': digest_file(recipe.output / 'pairs.jsonl')}
write_json(recipe.output / 'pairs.stats.json', report)
return report