//#region src/loudness.d.ts /** * gs measure-loudness (0.12, DESIGN §5.3): loudness-normalized PUBLISH profiles * via a deterministic, peak-clamped scalar GAIN — NOT a render-pipeline change. * * The key insight: YouTube/Shorts re-normalize loudness platform-side, so the * publish target is *≤ target-LUFS AND ≤ -1 dBTP*, not exact. So we never need a * two-pass limiter on the render hot path. Instead: * * 1. `gs measure-loudness` runs ffmpeg's `loudnorm` measurement pass (a * print-only ebur128 + true-peak gate) at MEASURE-time over the final * mixed PCM, reading `inputI` (integrated LUFS), `inputTp` (true peak * dBTP), `inputLra`. This is the ONE non-deterministic stage and it is * QUARANTINED to commit-time — §5.3 already concedes mix-to-PCM bytes are * per-path only. * 2. It commits a `.loudness.json` carrying the measured numbers, the * chosen profile, the resulting `gain` (dB), and a `mixHash` binding the * measurement to the mix CONTENT (the narration/music/sfx manifests). * 3. `gs render` reads that file and applies `gain` as a PURE scalar * `volume=dB` multiply on the FINAL mix node — a single scalar in the * existing filter graph, NOT a new ffmpeg pass. That stage is bit-exact and * golden-hashable (a multiply at the same float→Int16 boundary the rest of * the audio path shares). * * The gain is peak-clamped: `gain = min(gainForTargetLUFS, (-1 dBTP) - inputTp)`. * The clamp uses the MEASURED true-peak, so the published output is guaranteed * ≤ -1 dBTP with no render-time oversampling. (For 0.12 the brickwall true-peak * LIMITER is DEFERRED — un-normalized profiles still get the gain plus an * advisory warning when a peaky source would have needed brickwalling.) */ declare class LoudnessError extends Error { constructor(detail: string); } /** A publish target: integrated-loudness goal + the true-peak ceiling. */ interface PublishProfile { readonly id: string; /** integrated-loudness target, LUFS */ readonly targetLufs: number; /** true-peak ceiling, dBTP (the peak clamp is computed against this) */ readonly truePeakDb: number; /** * Whether the platform re-normalizes loudness on its side. For * platform-normalized targets (YouTube/Shorts) a peaky source that can't reach * `targetLufs` without clipping is fine — the platform finishes the job. For * un-normalized targets (podcast/broadcast) it earns an advisory warning, * since 0.12 ships no brickwall limiter to recover the headroom. */ readonly platformNormalized: boolean; } declare const PUBLISH_PROFILES: Readonly>; declare const DEFAULT_PROFILE_ID = "youtube"; /** Resolve a profile id (case-insensitive) or throw with the valid set. */ declare function resolveProfile(id: string): PublishProfile; declare const LOUDNESS_SCHEMA_VERSION: 1; /** * The brickwall true-peak limiter a `gs master` pass commits (0.39). Absent on a * plain `gs measure-loudness` measurement (that stays a pure peak-clamped scalar * gain, byte-identical). Present, render/remux applies `volume=dB` THEN an * `alimiter` holding the true peak at `ceilingDb` — the non-linear stage that lets * a peaky member reach the target instead of landing LUs low. Applied in the mix * filter graph (deterministic ffmpeg), NOT a render-time per-frame scalar. */ interface CommittedLimiter { readonly mode: 'truepeak'; /** the true-peak ceiling the limiter holds, dBTP. */ readonly ceilingDb: number; } /** The committed `.loudness.json`. */ interface LoudnessMeasurement { loudnessVersion: typeof LOUDNESS_SCHEMA_VERSION; /** the resolved publish profile id (youtube/shorts/podcast/broadcast/ebu) */ profileId: string; /** measured integrated loudness, LUFS (ebur128) */ inputI: number; /** measured true peak, dBTP */ inputTp: number; /** measured loudness range, LU */ inputLra: number; /** * the deterministic gain to apply at render, in dB. * `gain = min(targetLufs - inputI, truePeakDb - inputTp)` — the peak clamp * guarantees the published output is ≤ truePeakDb using the MEASURED peak. */ gain: number; /** * binds this measurement to the mix CONTENT version (a hash of the mix input * manifests — narration/music/sfx timing + any wired timeline audio — NOT * mtime). Render recomputes it and HARD-THROWS on mismatch so a re-narrate * invalidates the measurement loudly instead of silently mis-normalizing. */ mixHash: string; /** * the true-peak limiter to apply after the gain (0.39 `gs master`). Absent on a * plain `gs measure-loudness` measurement (gain-only, byte-identical). When * present, `gain` may exceed the raw peak clamp (the limiter shaves the * overshoot to `limiter.ceilingDb`). */ limiter?: CommittedLimiter; } /** * The peak-clamped publish gain (dB). `gainForTargetLUFS = target - inputI` * raises/lowers to the integrated target; the clamp `truePeak - inputTp` caps it * so the result never exceeds the true-peak ceiling. We take the min: a source * that can't reach the loudness target without clipping is left below it (the * platform re-normalizes the rest for `youtube`/`shorts`). */ declare function computeGainDb(profile: PublishProfile, inputI: number, inputTp: number): number; /** * True when the peak clamp BOUND the gain — i.e. the source is too peaky to reach * the loudness target without exceeding the true-peak ceiling. For an * un-normalized profile this is where a brickwall limiter would normally recover * the headroom (deferred in 0.12 → advisory warning). */ declare function peakClampBinds(profile: PublishProfile, inputI: number, inputTp: number): boolean; /** * Hash the CONTENT of the mix's inputs so a re-narrate / re-sfx / music change OR * an in-place edit of an audio file invalidates a committed measurement. We hash * the files' BYTES (not mtime): the narration/music/sfx timing manifests AND every * path in `extraInputs` — the resolved mix AUDIO files (timeline clips + the music * stem + narration cache audio), supplied by `collectMixAudioInputs` at both the * measure and render call sites so the two agree. Hashing only the timing manifests * would miss an edited `.wav`/stem (same manifest, changed bytes) and apply a stale * publish gain silently — so `extraInputs` MUST be passed on the gate path. Missing * files are recorded by name with a sentinel so adding one later also changes the * hash. The scene module path itself is excluded. */ declare function computeMixHash(modulePath: string, extraInputs?: readonly string[]): string; /** * The committed loudness measurement path for a scene module. * * Base (no locale): `.loudness.json` — UNCHANGED. With a locale set (0.15 * FIX 2): `..loudness.json`. A localized render mixes the per-locale * narration (e.g. the zh wavs) → a DIFFERENT mixHash than the base, so it needs its * OWN committed measurement; one base file can't gate a localized render (it would * always read as a stale mixHash, with no supported way to commit a per-locale one). */ declare function loudnessPathFor(modulePath: string, locale?: string): string; /** Read + validate a committed measurement, or null when none is committed. */ declare function readLoudness(modulePath: string, locale?: string): LoudnessMeasurement | null; /** * Parse the JSON block ffmpeg's `loudnorm=print_format=json` prints to stderr. * It carries `input_i` / `input_tp` / `input_lra` (the measured loudness), * alongside the would-be normalization params (which we ignore — we apply our * own peak-clamped scalar gain instead). */ declare function parseLoudnormJson(stderr: string): { inputI: number; inputTp: number; inputLra: number; }; /** * Run ffmpeg's loudnorm measurement pass over a built mix WAV/PCM file and return * the measured loudness. This is the quarantined non-deterministic stage — it * runs ONLY at measure-time (commit), never during render. */ declare function measureFile(audioPath: string): { inputI: number; inputTp: number; inputLra: number; }; interface MeasureLoudnessOptions { modulePath: string; /** publish profile id; default 'youtube' */ profile?: string; /** narration auto-mix toggle (mirrors render); default auto */ narration?: 'auto' | 'off'; music?: 'auto' | 'off'; sfx?: 'auto' | 'off'; /** * locale code (0.15 FIX 2). When set, measure the per-locale mix (the localized * narration sibling) and commit `..loudness.json` instead of the * base file. Mirrors `gs render --locale`, so the measured content matches what * a localized render mixes. */ locale?: string; } interface MeasureLoudnessResult { measurement: LoudnessMeasurement; loudnessPath: string; /** profile the measurement was taken against */ profile: PublishProfile; /** true when the peak clamp bound the gain (advisory: would need a brickwall limiter on an un-normalized profile) */ clampBound: boolean; /** an advisory warning string when an un-normalized profile can't reach its target without clipping; else null */ warning: string | null; } /** * Build the final mix to a WAV, run the ffmpeg loudnorm measurement over it, and * commit a `.loudness.json` with the peak-clamped publish gain + a mixHash * bound to the mix-input manifests. This is the measure step (commit-time); it is * the only place the non-deterministic ebur128/mix-to-PCM stages run. */ declare function measureLoudnessCommand(opts: MeasureLoudnessOptions): Promise; //#endregion //#region src/master.d.ts declare class MasterError extends Error { constructor(message: string); } /** How much gain-reduction the limiter may apply to buy headroom (dB). */ declare const DEFAULT_MAX_GR_DB = 6; /** The limiter spec (from `glissade.master.json`). */ interface MasterLimiter { /** true-peak brickwall (the only mode today). */ readonly mode: 'truepeak'; /** the true-peak ceiling, dBTP (defaults to the profile's `truePeakDb`). */ readonly ceilingDb?: number; /** limiter lookahead, ms (advisory — maps to the ffmpeg attack window). */ readonly lookaheadMs?: number; /** max gain-reduction the limiter may apply (dB); beyond this a member can't * reach the target cleanly and the shared target drops. Default 6. */ readonly maxGrDb?: number; } /** `glissade.master.json`. */ interface MasterConfig { /** publish profile id (youtube/shorts/podcast/broadcast/ebu). */ readonly profile?: string; /** member scene globs (like `gs build`'s `scenes`). */ readonly members: readonly string[]; /** the limiter (or `false` to keep the legacy peak-clamp behaviour). */ readonly limiter?: MasterLimiter | false; /** `shared-target` (all members hit one LUFS) or `per-asset` (each hits its own max). */ readonly consistency?: 'shared-target' | 'per-asset'; } /** One member's measured input loudness (from the ffmpeg measure pass). */ interface MemberMeasure { readonly id: string; /** integrated loudness, LUFS. */ readonly inputI: number; /** true peak, dBTP. */ readonly inputTp: number; } /** The plan for one member: its target + the gain to apply + predicted limiting. */ interface MemberPlan extends MemberMeasure { /** the output LUFS this member is driven to. */ readonly target: number; /** the gain to apply before the limiter, dB. */ readonly gain: number; /** predicted limiter gain-reduction, dB (0 when the raw peak clears the ceiling). */ readonly grDb: number; /** predicted output true peak, dBTP (== ceiling when the limiter engages). */ readonly predOutTp: number; /** true when this member reaches the shared target within the GR budget. */ readonly reachable: boolean; } interface MasterPlan { /** the LUFS target the set is normalized to (shared-target) or the profile cap. */ readonly sharedTarget: number; readonly profileTarget: number; readonly ceilingDb: number; /** true when a limiter is in play (false = legacy peak-clamp). */ readonly limiter: boolean; readonly maxGrDb: number; readonly members: readonly MemberPlan[]; } /** * Plan a master pass. Pure: measured members → shared target + per-member gains. * * - With a limiter, a member reaches `target` by applying `gain = target - inputI` * and the limiter shaves any overshoot (`grDb`) down to the ceiling. * - Without a limiter (`limiter: null`), the gain is peak-CLAMPED (the legacy * `min(target-inputI, ceiling-inputTp)`), so a peaky member lands below target. * - `shared-target` normalizes every member to one LUFS (the loudest all reach); * `per-asset` drives each to its own max. */ declare function planMaster(measures: readonly MemberMeasure[], profile: PublishProfile, opts: { limiter: MasterLimiter | null; consistency: 'shared-target' | 'per-asset'; }): MasterPlan; /** * The `-af` chain that applies a master's gain + (optional) TRUE-peak limiter to a * WAV — the SAME {@link loudnessFilterNodes} the render `filter_complex` applies, * so the `gs master` verify pass measures exactly what a render will produce. */ declare function masterAfChain(gainDb: number, limiter: CommittedLimiter | null): Promise; interface MasterMemberResult { readonly id: string; readonly loudnessPath: string; readonly measurement: LoudnessMeasurement; /** measured input LUFS / dBTP. */ readonly inputI: number; readonly inputTp: number; /** VERIFIED output LUFS / dBTP (re-measured after gain+limiter). */ readonly outI: number; readonly outTp: number; readonly gain: number; readonly grDb: number; /** true when the verified output true-peak exceeded the ceiling (shouldn't happen). */ readonly overCeiling: boolean; } interface MasterResult { readonly sharedTarget: number; readonly ceilingDb: number; readonly limiter: boolean; readonly members: readonly MasterMemberResult[]; readonly report: string; } interface MasterCommandOptions { configPath: string; onLog?: (line: string) => void; } /** * `gs master ` — measure every member, plan the shared * target + limiter, apply+VERIFY per member, and commit `.loudness.json` * ×N. Reuses `buildMixWav`/`measureFile` (measure) and `resolveScenes` (globs); * writes the existing sidecar shape + the `limiter` block so render composes it as * a mix-only remux under the mixHash preflight. */ declare function masterCommand(opts: MasterCommandOptions): Promise; /** The shared-target loudness options a master pass needs, minus the member list. */ interface MasterRunOptions { profile?: string; limiter?: MasterLimiter | false | null; consistency?: 'shared-target' | 'per-asset'; } /** * The master core, callable with ALREADY-RESOLVED member paths + options — so both * `gs master ` and the 0.43 project runtime's shared-master * phase drive it (the runtime passes the project's scenes as members directly, no * temp config file). Measures every member's mix, plans one shared target, and * commits `.loudness.json` ×N with the limiter block (render applies it as a * mix-only remux under the mixHash preflight). */ /** Validate + normalize a parsed `glissade.master.json` (fail-loud). */ declare function normalizeMasterConfig(raw: unknown): Required> & { profile: string; limiter: MasterLimiter | null; }; //#endregion export { peakClampBinds as A, PublishProfile as C, measureFile as D, loudnessPathFor as E, resolveProfile as M, measureLoudnessCommand as O, PUBLISH_PROFILES as S, computeMixHash as T, LOUDNESS_SCHEMA_VERSION as _, MasterMemberResult as a, MeasureLoudnessOptions as b, MasterRunOptions as c, masterAfChain as d, masterCommand as f, DEFAULT_PROFILE_ID as g, CommittedLimiter as h, MasterLimiter as i, readLoudness as j, parseLoudnormJson as k, MemberMeasure as l, planMaster as m, MasterConfig as n, MasterPlan as o, normalizeMasterConfig as p, MasterError as r, MasterResult as s, DEFAULT_MAX_GR_DB as t, MemberPlan as u, LoudnessError as v, computeGainDb as w, MeasureLoudnessResult as x, LoudnessMeasurement as y };