@glissade/cli 0.39.0-pre.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/audioMix.js CHANGED
@@ -13,6 +13,7 @@ var audioMix_exports = /* @__PURE__ */ __exportAll({
13
13
  applyMixGainDb: () => applyMixGainDb,
14
14
  atempoChain: () => atempoChain,
15
15
  gainExpression: () => gainExpression,
16
+ loudnessFilterNodes: () => loudnessFilterNodes,
16
17
  planAudioMix: () => planAudioMix,
17
18
  resolveAssetPath: () => resolveAssetPath
18
19
  });
@@ -61,27 +62,50 @@ function atempoChain(rate) {
61
62
  if (Math.abs(r - 1) > 1e-9 || chain.length === 0) chain.push(`atempo=${r}`);
62
63
  return chain;
63
64
  }
65
+ /** The mix sample rate the limiter oversamples around (glissade mixes at 48 kHz). */
66
+ const MIX_RATE = 48e3;
67
+ /** 4× oversampling for the true-peak limiter — reconstructs inter-sample peaks. */
68
+ const TP_OVERSAMPLE = MIX_RATE * 4;
64
69
  /**
65
- * Append the publish-loudness stage to a mix's `-filter_complex`: the graph's
66
- * final `[aout]` label is renamed and a `volume=<gain>dB` node (plus, for a
67
- * `gs master` measurement, an `alimiter` holding the true peak at `ceilingDb`)
68
- * feeds the new `[aout]`. The gain is a single multiply on the FINAL mix node —
69
- * NOT a second ffmpeg pass — bit-deterministic + golden-hashable. A gain of
70
- * exactly 0 dB with NO limiter is a no-op (returned unchanged) so an at-target
71
- * source preserves the prior, un-gained bytes. The limiter is the ONLY non-linear
72
- * stage; it's baked from committed params so it stays deterministic.
70
+ * True-peak guard (dB): the sample-peak limit is set this far BELOW the ceiling so
71
+ * the downsample-reconstructed TRUE peak lands under `ceilingDb`. Empirically the
72
+ * oversample→limit→downsample residue is ~0.5 dB on worst-case (clipped-noise)
73
+ * content; 0.8 gives a comfortable margin (worst case measured −1.31 dBTP for a −1
74
+ * ceiling) without over-attenuating real beds. WITHOUT the oversample, `alimiter`
75
+ * is a SAMPLE-peak brickwall that leaves the true peak clipping over the ceiling
76
+ * (the 0.39.0-pre.0 defect mode:'truepeak' didn't hold dBTP).
73
77
  */
74
- function applyMixGainDb(filterComplex, gainDb, limiter) {
75
- const chain = [];
76
- if (gainDb !== 0) chain.push(`volume=${gainDb}dB`);
78
+ const TP_GUARD_DB = .8;
79
+ /**
80
+ * The publish-loudness filter nodes: a `volume=<gain>dB` multiply, and — for a
81
+ * `gs master` measurement — a REAL true-peak limiter (oversample 4× → `alimiter`
82
+ * at `ceilingDb − guard` → downsample) that actually holds the inter-sample /
83
+ * true peak under `ceilingDb`. Shared by the render `filter_complex` apply and the
84
+ * `gs master` verify pass so the committed limiter and the rendered output are the
85
+ * identical deterministic chain. Empty (a no-op) at 0 dB with no limiter.
86
+ */
87
+ function loudnessFilterNodes(gainDb, limiter) {
88
+ const nodes = [];
89
+ if (gainDb !== 0) nodes.push(`volume=${gainDb}dB`);
77
90
  if (limiter) {
78
- const limit = Math.pow(10, limiter.ceilingDb / 20).toFixed(6);
79
- chain.push(`alimiter=limit=${limit}:level=disabled`);
91
+ const limit = Math.pow(10, (limiter.ceilingDb - TP_GUARD_DB) / 20).toFixed(6);
92
+ nodes.push(`aresample=${TP_OVERSAMPLE}`, `alimiter=limit=${limit}:level=disabled`, `aresample=${MIX_RATE}`);
80
93
  }
81
- if (chain.length === 0) return filterComplex;
94
+ return nodes;
95
+ }
96
+ /**
97
+ * Append the publish-loudness stage to a mix's `-filter_complex`: the graph's
98
+ * final `[aout]` label is renamed and the {@link loudnessFilterNodes} (gain +,
99
+ * for a master, the true-peak limiter) feed the new `[aout]`. Bit-deterministic +
100
+ * golden-hashable. A 0 dB gain with NO limiter is a no-op (returned unchanged) so
101
+ * an at-target source preserves the prior, un-gained bytes.
102
+ */
103
+ function applyMixGainDb(filterComplex, gainDb, limiter) {
104
+ const nodes = loudnessFilterNodes(gainDb, limiter);
105
+ if (nodes.length === 0) return filterComplex;
82
106
  const at = filterComplex.lastIndexOf("[aout]");
83
107
  if (at < 0) throw new AudioMixError("mix filter graph has no [aout] to apply the loudness gain to");
84
- return `${filterComplex.slice(0, at) + "[apreg]" + filterComplex.slice(at + 6)};[apreg]${chain.join(",")}[aout]`;
108
+ return `${filterComplex.slice(0, at) + "[apreg]" + filterComplex.slice(at + 6)};[apreg]${nodes.join(",")}[aout]`;
85
109
  }
86
110
  /** Build the FFmpeg mix plan for clips that intersect [0, duration]. */
87
111
  function planAudioMix(clips, modulePath, duration) {
package/dist/index.d.ts CHANGED
@@ -633,8 +633,12 @@ declare function planMaster(measures: readonly MemberMeasure[], profile: Publish
633
633
  limiter: MasterLimiter | null;
634
634
  consistency: 'shared-target' | 'per-asset';
635
635
  }): MasterPlan;
636
- /** The `-af` chain that applies a master's gain + (optional) limiter to a WAV. */
637
- declare function masterAfChain(gainDb: number, limiter: CommittedLimiter | null): string;
636
+ /**
637
+ * The `-af` chain that applies a master's gain + (optional) TRUE-peak limiter to a
638
+ * WAV — the SAME {@link loudnessFilterNodes} the render `filter_complex` applies,
639
+ * so the `gs master` verify pass measures exactly what a render will produce.
640
+ */
641
+ declare function masterAfChain(gainDb: number, limiter: CommittedLimiter | null): Promise<string>;
638
642
  interface MasterMemberResult {
639
643
  readonly id: string;
640
644
  readonly loudnessPath: string;
@@ -772,15 +776,21 @@ interface AudioMixPlan {
772
776
  filterComplex: string;
773
777
  hasEasedGain: boolean;
774
778
  }
779
+ /**
780
+ * The publish-loudness filter nodes: a `volume=<gain>dB` multiply, and — for a
781
+ * `gs master` measurement — a REAL true-peak limiter (oversample 4× → `alimiter`
782
+ * at `ceilingDb − guard` → downsample) that actually holds the inter-sample /
783
+ * true peak under `ceilingDb`. Shared by the render `filter_complex` apply and the
784
+ * `gs master` verify pass so the committed limiter and the rendered output are the
785
+ * identical deterministic chain. Empty (a no-op) at 0 dB with no limiter.
786
+ */
787
+
775
788
  /**
776
789
  * Append the publish-loudness stage to a mix's `-filter_complex`: the graph's
777
- * final `[aout]` label is renamed and a `volume=<gain>dB` node (plus, for a
778
- * `gs master` measurement, an `alimiter` holding the true peak at `ceilingDb`)
779
- * feeds the new `[aout]`. The gain is a single multiply on the FINAL mix node —
780
- * NOT a second ffmpeg pass bit-deterministic + golden-hashable. A gain of
781
- * exactly 0 dB with NO limiter is a no-op (returned unchanged) so an at-target
782
- * source preserves the prior, un-gained bytes. The limiter is the ONLY non-linear
783
- * stage; it's baked from committed params so it stays deterministic.
790
+ * final `[aout]` label is renamed and the {@link loudnessFilterNodes} (gain +,
791
+ * for a master, the true-peak limiter) feed the new `[aout]`. Bit-deterministic +
792
+ * golden-hashable. A 0 dB gain with NO limiter is a no-op (returned unchanged) so
793
+ * an at-target source preserves the prior, un-gained bytes.
784
794
  */
785
795
  declare function applyMixGainDb(filterComplex: string, gainDb: number, limiter?: {
786
796
  readonly ceilingDb: number;
package/dist/master.js CHANGED
@@ -90,12 +90,15 @@ function planMaster(measures, profile, opts) {
90
90
  function round2(n) {
91
91
  return Math.round(n * 100) / 100;
92
92
  }
93
- /** The `-af` chain that applies a master's gain + (optional) limiter to a WAV. */
94
- function masterAfChain(gainDb, limiter) {
95
- const chain = [];
96
- if (gainDb !== 0) chain.push(`volume=${gainDb}dB`);
97
- if (limiter) chain.push(`alimiter=limit=${Math.pow(10, limiter.ceilingDb / 20).toFixed(6)}:level=disabled`);
98
- return chain.length ? chain.join(",") : "anull";
93
+ /**
94
+ * The `-af` chain that applies a master's gain + (optional) TRUE-peak limiter to a
95
+ * WAV the SAME {@link loudnessFilterNodes} the render `filter_complex` applies,
96
+ * so the `gs master` verify pass measures exactly what a render will produce.
97
+ */
98
+ async function masterAfChain(gainDb, limiter) {
99
+ const { loudnessFilterNodes } = await import("./audioMix.js").then((n) => n.i);
100
+ const nodes = loudnessFilterNodes(gainDb, limiter ?? void 0);
101
+ return nodes.length ? nodes.join(",") : "anull";
99
102
  }
100
103
  /**
101
104
  * `gs master <glissade.master.json>` — measure every member, plan the shared
@@ -154,7 +157,7 @@ async function masterCommand(opts) {
154
157
  const meas = measured[i];
155
158
  const p = plan.members[i];
156
159
  const outWav = join(tmp, `${i}.out.wav`);
157
- const af = masterAfChain(p.gain, committedLimiter);
160
+ const af = await masterAfChain(p.gain, committedLimiter);
158
161
  const r = spawnSync("ffmpeg", [
159
162
  "-hide_banner",
160
163
  "-nostats",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.39.0-pre.0",
3
+ "version": "0.39.0",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -28,15 +28,15 @@
28
28
  "@napi-rs/canvas": "^0.1.65",
29
29
  "esbuild": "0.28.0",
30
30
  "jiti": "^2.4.2",
31
- "@glissade/backend-skia": "0.39.0-pre.0",
32
- "@glissade/core": "0.39.0-pre.0",
33
- "@glissade/interact": "0.39.0-pre.0",
34
- "@glissade/lottie": "0.39.0-pre.0",
35
- "@glissade/narrate": "0.39.0-pre.0",
36
- "@glissade/player": "0.39.0-pre.0",
37
- "@glissade/scene": "0.39.0-pre.0",
38
- "@glissade/sfx": "0.39.0-pre.0",
39
- "@glissade/svg": "0.39.0-pre.0"
31
+ "@glissade/backend-skia": "0.39.0",
32
+ "@glissade/core": "0.39.0",
33
+ "@glissade/interact": "0.39.0",
34
+ "@glissade/lottie": "0.39.0",
35
+ "@glissade/narrate": "0.39.0",
36
+ "@glissade/player": "0.39.0",
37
+ "@glissade/scene": "0.39.0",
38
+ "@glissade/sfx": "0.39.0",
39
+ "@glissade/svg": "0.39.0"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",