from enum import StrEnum from typing import Final, Self, assert_never from pydantic import BaseModel, ConfigDict, computed_field, model_validator from src.asset_upload.models import AssetUploadValidationRequest from src.types import MaybeInt, MaybeStr from src.worker import NonEmptyStr # Floor for dBFS measurements (Sony QC's value): a channel with no measurable signal reports # -200.0 rather than -inf, which would not survive JSON serialization of the metadata. SILENCE_FLOOR_DBFS: Final = -200.0 class AtmosValidationFindingCode(StrEnum): # "All audio must be 24-bit linear pulse code modulation (LPCM) audio at 48kHz." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=All%20audio%20must%20be%2024%2Dbit%20linear%20pulse%20code%20modulation%20(LPCM)%20audio%20at%2048kHz%2E NOT_PCM = "NOT_PCM" NOT_LPCM = "NOT_LPCM" WRONG_BIT_DEPTH = "WRONG_BIT_DEPTH" WRONG_SAMPLE_RATE = "WRONG_SAMPLE_RATE" # "Provide the Dolby Atmos file as a Broadcast Wave Format Audio Definition Model (BWF ADM) file." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=Provide%20the%20Dolby%20Atmos%20file%20as%20a%20Broadcast%20Wave%20Format%20Audio%20Definition%20Model%20(BWF%20ADM)%20file%2E # Apple/Dolby say "BWF ADM" but the files don't have a bext chunk. Strict BWF per EBU Tech 3285 # requires a bext chunk; the industry uses "BWF" loosely to mean "WAV with professional metadata" # (in this case axml + chna + dbmd). Verified against files from `prod-dolby-atmos-packager- # alpha-input` and a test asset produced by the Dolby Atmos Renderer. We validate Wave container + # ADM profile, not bext presence. NOT_WAVE = "NOT_WAVE" NOT_DOLBY_ATMOS = "NOT_DOLBY_ATMOS" ATMOS_FILE_TRUNCATED = "ATMOS_FILE_TRUNCATED" STEREO_FILE_TRUNCATED = "STEREO_FILE_TRUNCATED" # Apple allows atmos-only delivery if you instruct Apple to downmix via the # audio.transform_to.2_0 attribute, but in our discussions Apple recommended always sending a # stereo file alongside the atmos to ensure the stereo experience is as intended. A stereo # reference is therefore required by our API (see validator.validate), so the following apply: # "You must conform and sync the Atmos files with the stereo reference files for the same project." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=You%20must%20conform%20and%20sync%20the%20Atmos%20files%20with%20the%20stereo%20reference%20files%20for%20the%20same%20project%2E # "The difference between the most recently delivered (new or update) stereo source and immersive source durations must be less than or equal to 50 milliseconds." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=The%20difference%20between%20the%20most%20recently%20delivered%20(new%20or%20update)%20stereo%20source%20and%20immersive%20source%20durations%20must%20be%20less%20than%20or%20equal%20to%2050%20milliseconds%2E # We gate the duration difference at 2000 ms rather than Apple's 50 ms (CDAM-3806): container # durations routinely differ by up to a couple of seconds of trailing silence or padding, which a # 50 ms gate would flag as failures even though nothing is wrong. Whether the content actually # lines up is a separate concern from container length. DURATION_MISMATCH = "DURATION_MISMATCH" # Having a stereo file alongside the atmos file helps us to validate: # "A Dolby Atmos track must be created from multitracks or stems created from multitracks." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=A%20Dolby%20Atmos%20track%20must%20be%20created%20from%20multitracks%20or%20stems%20created%20from%20multitracks%2E # "Upmixing from a stereo release is not allowed." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=Upmixing%20from%20a%20stereo%20release%20is%20not%20allowed%2E # "Extracting stems (“de-mixing”) from a stereo release is not allowed." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=Extracting%20stems%20(%E2%80%9Cde%2Dmixing%E2%80%9D)%20from%20a%20stereo%20release%20is%20not%20allowed%2E # "A Dolby Atmos track consisting only of a stereo mix placed in the sound field with added ambience or reverb is not allowed." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=A%20Dolby%20Atmos%20track%20consisting%20only%20of%20a%20stereo%20mix%20placed%20in%20the%20sound%20field%20with%20added%20ambience%20or%20reverb%20is%20not%20allowed%2E # Because we send a stereo file alongside atmos we need to validate: # "You must conform and sync the Atmos files with the stereo reference files for the same project." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=You%20must%20conform%20and%20sync%20the%20Atmos%20files%20with%20the%20stereo%20reference%20files%20for%20the%20same%20project%2E # Populated by alignment_check (cross-correlation of the stereo render against the stereo # reference); the thresholds and method are documented there, next to the enforcing constants. SYNC_MISMATCH = "SYNC_MISMATCH" CONTENT_MISMATCH = "CONTENT_MISMATCH" # TODO: CDAM-3806 — upmix / multitrack detection; cross-correlation can't detect it # Some things listed as "should" but not "must" # "The integrated loudness value should not exceed -18 LKFS measured as per ITU-R BS. 1770-4." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=the%20same%20project.-,The%20integrated%20loudness%20value%20should%20not%20exceed%20%2D18%20LKFS%20measured%20as%20per%20ITU%2DR%20BS.%201770%2D4.,-True%2Dpeak%20level LOUDNESS_TOO_HIGH = "LOUDNESS_TOO_HIGH" # "True-peak level should not exceed -1 dB TP measured as per ITU-R BS. 1770-4." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=BS.%201770%2D4.-,True%2Dpeak%20level%20should%20not%20exceed%20%2D1%20dB%20TP%20measured%20as%20per%20ITU%2DR%20BS.%201770%2D4.,-Full%2Dfrequency%20content TRUE_PEAK_TOO_HIGH = "TRUE_PEAK_TOO_HIGH" # "Full-frequency content should not be present in the Low-Frequency Effects (LFE) channel of the BWF ADM." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=Full%2Dfrequency%20content%20should%20not%20be%20present%20in%20the%20Low%2DFrequency%20Effects%20(LFE)%20channel%20of%20the%20BWF%20ADM%2E # Sony QC enforces this one rule with two nested band gates (ventura_1_1.py): the peak level # at/above 400 Hz must not exceed -60 dBFS, and — 40 dB stricter — the peak at/above 2000 Hz must # not exceed -100 dBFS. The >= 2000 Hz bins are a subset of the >= 400 Hz bins, so a moderate # high-frequency peak can breach the 2 kHz gate while leaving the 400 Hz one intact; each gate is # therefore its own finding pointing at its own measured level. Populated by lfe_check (streamed # FFT over each ADM-identified LFE channel); the thresholds and method are documented there, next # to the enforcing constants. LFE_LEVEL_ABOVE_400HZ_TOO_HIGH = "LFE_LEVEL_ABOVE_400HZ_TOO_HIGH" LFE_LEVEL_ABOVE_2000HZ_TOO_HIGH = "LFE_LEVEL_ABOVE_2000HZ_TOO_HIGH" # A stored object channel whose audio is blank delivers nothing to the mix — from Sony QC's # blank-object scanner, supporting Apple's multitrack requirements above (a master padded with # empty objects is a delivery defect, not a creative choice). Populated by silent_object_check. SILENT_OBJECT = "SILENT_OBJECT" # Sony's companion sub-rule: silent bed height channels while NO object is active means the # master carries zero height content anywhere — effectively a flat mix in an Atmos container. # (Silent bed heights WITH active objects are a mixing choice: the height lives in the objects.) SILENT_HEIGHT = "SILENT_HEIGHT" # Album level validation that can be performed using metadata output from individual file checks: # "All tracks within a project must be at the same frame rate." # https://help.apple.com/itc/videoaudioassetguide/en.lproj/static.html#itcf946aaace:~:text=All%20tracks%20within%20a%20project%20must%20be%20at%20the%20same%20frame%20rate%2E # TODO: CDAM-3806 @property def metadata_key(self) -> str: match self: case AtmosValidationFindingCode.NOT_PCM: return "codec" case AtmosValidationFindingCode.NOT_LPCM: return "codec_id" case AtmosValidationFindingCode.WRONG_BIT_DEPTH: return "bits_per_sample" case AtmosValidationFindingCode.WRONG_SAMPLE_RATE: return "sample_rate_hz" case AtmosValidationFindingCode.NOT_WAVE: return "container" case AtmosValidationFindingCode.NOT_DOLBY_ATMOS: return "adm_profile" case AtmosValidationFindingCode.ATMOS_FILE_TRUNCATED: return "is_truncated" case AtmosValidationFindingCode.STEREO_FILE_TRUNCATED: return "stereo_reference_is_truncated" case AtmosValidationFindingCode.DURATION_MISMATCH: return "stereo_reference_duration_diff_ms" case AtmosValidationFindingCode.SYNC_MISMATCH: return "stereo_reference_alignment_offset_ms" case AtmosValidationFindingCode.CONTENT_MISMATCH: return "stereo_reference_content_match_r" case AtmosValidationFindingCode.LOUDNESS_TOO_HIGH: return "integrated_loudness_lkfs" case AtmosValidationFindingCode.TRUE_PEAK_TOO_HIGH: return "true_peak_dbtp" case AtmosValidationFindingCode.LFE_LEVEL_ABOVE_400HZ_TOO_HIGH: return "lfe_above_400hz_dbfs" case AtmosValidationFindingCode.LFE_LEVEL_ABOVE_2000HZ_TOO_HIGH: return "lfe_above_2000hz_dbfs" case AtmosValidationFindingCode.SILENT_OBJECT: return "silent_object_track_indices" case AtmosValidationFindingCode.SILENT_HEIGHT: return "silent_height_track_indices" case _: assert_never(self) class AtmosValidationRequest(AssetUploadValidationRequest): model_config = ConfigDict(frozen=True, extra="forbid") atmos_bucket: NonEmptyStr atmos_key: NonEmptyStr stereo_reference_bucket: NonEmptyStr | None = None stereo_reference_key: NonEmptyStr | None = None lookup_stereo_reference: bool = False report_validation_result: bool = False @model_validator(mode="after") def _check_stereo_reference_contract(self) -> Self: if self.lookup_stereo_reference and ( self.stereo_reference_bucket is not None or self.stereo_reference_key is not None ): raise ValueError( "stereo_reference_bucket and stereo_reference_key must be omitted when lookup_stereo_reference is True" ) if not self.lookup_stereo_reference and ( self.stereo_reference_bucket is None or self.stereo_reference_key is None ): raise ValueError( "stereo_reference_bucket and stereo_reference_key are required when lookup_stereo_reference is False" ) return self class MediainfoOutput(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") container: MaybeStr codec: MaybeStr codec_id: MaybeStr channels: MaybeInt sample_rate_hz: MaybeInt bits_per_sample: MaybeInt is_truncated: bool adm_profile: MaybeStr duration_ms: MaybeInt class AtmosValidationMetadata(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") container: MaybeStr codec: MaybeStr codec_id: MaybeStr channels: MaybeInt sample_rate_hz: MaybeInt bits_per_sample: MaybeInt is_truncated: bool adm_profile: MaybeStr duration_ms: MaybeInt stereo_reference_is_truncated: bool | None = None stereo_reference_duration_ms: MaybeInt = None integrated_loudness_lkfs: float | None = None true_peak_dbtp: float | None = None stereo_reference_alignment_offset_ms: float | None = None stereo_reference_content_match_r: float | None = None lfe_above_400hz_dbfs: float | None = None lfe_above_2000hz_dbfs: float | None = None # How many object channels the ADM classification found — persisted so a healthy all-active # master (count N, no silent indices) is distinguishable from a master where classification # found no objects at all and the silent-object check was vacuous. Production Atmos masters # virtually always carry objects, so a stored 0 is a queryable anomaly. object_track_count: int | None = None # 0-based source-channel indices (chna trackIndex - 1) of object channels that are silent. silent_object_track_indices: tuple[int, ...] | None = None height_track_count: int | None = None silent_height_track_indices: tuple[int, ...] | None = None @computed_field # type: ignore[prop-decorator] @property def stereo_reference_duration_diff_ms(self) -> int | None: if self.duration_ms is None or self.stereo_reference_duration_ms is None: return None return abs(self.duration_ms - self.stereo_reference_duration_ms) class AtmosValidationResult(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") is_valid: bool metadata: AtmosValidationMetadata | None errors: dict[str, str] warnings: dict[str, str] class AtmosValidationBuilder: def __init__(self) -> None: self.metadata: AtmosValidationMetadata | None = None self.errors: dict[str, str] = {} self.warnings: dict[str, str] = {} def error(self, code: AtmosValidationFindingCode, message: str) -> None: self.errors[code.metadata_key] = message def warning(self, code: AtmosValidationFindingCode, message: str) -> None: self.warnings[code.metadata_key] = message def update_metadata(self, **fields: object) -> None: # metadata is None when the atmos mediainfo failed; there is nothing to update onto. if self.metadata is not None: self.metadata = self.metadata.model_copy(update=fields) def to_result(self) -> AtmosValidationResult: return AtmosValidationResult( is_valid=not self.errors, metadata=self.metadata, errors=self.errors, warnings=self.warnings, )