@glissade/cli 0.39.0-pre.0 → 0.39.0-pre.1
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 +39 -15
- package/dist/index.d.ts +19 -9
- package/dist/master.js +10 -7
- package/package.json +10 -10
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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
+
* 4× 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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]${
|
|
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
|
-
/**
|
|
637
|
-
|
|
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
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
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
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
3
|
+
"version": "0.39.0-pre.1",
|
|
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.
|
|
32
|
-
"@glissade/core": "0.39.0-pre.
|
|
33
|
-
"@glissade/interact": "0.39.0-pre.
|
|
34
|
-
"@glissade/lottie": "0.39.0-pre.
|
|
35
|
-
"@glissade/narrate": "0.39.0-pre.
|
|
36
|
-
"@glissade/player": "0.39.0-pre.
|
|
37
|
-
"@glissade/scene": "0.39.0-pre.
|
|
38
|
-
"@glissade/sfx": "0.39.0-pre.
|
|
39
|
-
"@glissade/svg": "0.39.0-pre.
|
|
31
|
+
"@glissade/backend-skia": "0.39.0-pre.1",
|
|
32
|
+
"@glissade/core": "0.39.0-pre.1",
|
|
33
|
+
"@glissade/interact": "0.39.0-pre.1",
|
|
34
|
+
"@glissade/lottie": "0.39.0-pre.1",
|
|
35
|
+
"@glissade/narrate": "0.39.0-pre.1",
|
|
36
|
+
"@glissade/player": "0.39.0-pre.1",
|
|
37
|
+
"@glissade/scene": "0.39.0-pre.1",
|
|
38
|
+
"@glissade/sfx": "0.39.0-pre.1",
|
|
39
|
+
"@glissade/svg": "0.39.0-pre.1"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|