@gentbajko/slopify 3.22.1 → 3.24.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/edge/http/studio.js +8 -6
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/patch-notes/3.23.0.md +11 -0
- package/dist/patch-notes/3.24.0.md +12 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/schema.js +2 -0
- package/dist/slices/loudness/level-pieces.js +4 -2
- package/dist/slices/loudness/sentence-level-model.js +16 -0
- package/dist/slices/loudness/sentence-level.js +331 -0
- package/dist/slices/play-drafts/convert.js +4 -0
- package/dist/slices/play-drafts/schema.js +2 -0
- package/dist/slices/project-templates/from-project.js +1 -0
- package/dist/slices/rebuild/preview-details.js +1 -0
- package/dist/slices/rebuild/recipe-loudness.js +28 -0
- package/dist/slices/rebuild/runtime-local.js +24 -3
- package/dist/tutorials/Editing-a-Project.md +2 -1
- package/dist/web/assets/{index-X6JyTe2r.js → index-D4ikiEys.js} +131 -131
- package/dist/web/assets/{pdf-B00spAHg.js → pdf-DSYcdduN.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
package/dist/edge/http/studio.js
CHANGED
|
@@ -69,6 +69,8 @@ const extVideoBody = z.object({
|
|
|
69
69
|
projectId: id,
|
|
70
70
|
short: shortField,
|
|
71
71
|
videoId: z.string().regex(videoIdPattern),
|
|
72
|
+
// `/ext/video/done`: the captions went in during the upload dialog's Video elements step.
|
|
73
|
+
captionsAdded: z.boolean().optional(),
|
|
72
74
|
});
|
|
73
75
|
const goneBody = z.object({
|
|
74
76
|
videos: z.array(extVideoBody).max(1000),
|
|
@@ -787,8 +789,8 @@ export function studioRoutes(deps) {
|
|
|
787
789
|
allowOrigin(c, false);
|
|
788
790
|
if (!extAllowed(c))
|
|
789
791
|
return refused(c);
|
|
790
|
-
const { projectId, short, videoId } = c.req.valid("json");
|
|
791
|
-
confirmVideoUpload(deps, projectId, short ?? null, videoId);
|
|
792
|
+
const { projectId, short, videoId, captionsAdded } = c.req.valid("json");
|
|
793
|
+
confirmVideoUpload(deps, projectId, short ?? null, videoId, captionsAdded === true);
|
|
792
794
|
return c.json({ confirmed: true });
|
|
793
795
|
})
|
|
794
796
|
.get("/ext/files/:projectId/:asset", zValidator("param", fileParam, onInvalid), (c) => {
|
|
@@ -819,9 +821,9 @@ export function studioRoutes(deps) {
|
|
|
819
821
|
}));
|
|
820
822
|
}
|
|
821
823
|
// Studio said the upload was scheduled or published: it is on YouTube, and the Details touches
|
|
822
|
-
// (a short's related video, the long video's end screen and captions
|
|
823
|
-
// the pinned comment for the video to be public.
|
|
824
|
-
function confirmVideoUpload(deps, projectId, short, videoId) {
|
|
824
|
+
// (a short's related video, the long video's end screen and captions, unless they went in
|
|
825
|
+
// during the upload) wait for the extension, the pinned comment for the video to be public.
|
|
826
|
+
function confirmVideoUpload(deps, projectId, short, videoId, captionsAdded = false) {
|
|
825
827
|
if (!confirmUpload(deps.db, projectId, short, videoId))
|
|
826
828
|
recordVideo(deps.db, projectId, short, videoId, deps.clock.now().toISOString());
|
|
827
829
|
const confirmedPack = uploadPack(deps, projectId);
|
|
@@ -831,7 +833,7 @@ function confirmVideoUpload(deps, projectId, short, videoId) {
|
|
|
831
833
|
if (confirmedItem !== undefined &&
|
|
832
834
|
(confirmedItem.relatedVideoId !== undefined ||
|
|
833
835
|
confirmedItem.endScreenVideoId !== undefined ||
|
|
834
|
-
confirmedItem.captions !== undefined))
|
|
836
|
+
(confirmedItem.captions !== undefined && !captionsAdded)))
|
|
835
837
|
setTaskState(deps.db, "finish", projectId, short, "waiting", null);
|
|
836
838
|
if (confirmedItem?.kind === "video" &&
|
|
837
839
|
confirmedItem.pinnedComment !== undefined &&
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Slopify 3.23.0
|
|
2
|
+
|
|
3
|
+
Released 14 October 2026.
|
|
4
|
+
|
|
5
|
+
- **Level each sentence.** A new switch beside Soften breaths and Soften S sounds (Play → Video and style, Edit project → Pauses and volume) holds every sentence of the narration to the sentences in the minute around it, so no line jumps out or drops away and the joins between voice requests stop jumping. A loud sentence comes down by up to 6 dB, a quiet one up by up to 3 dB, and the volume only changes in the pauses, so no word is cut into and the captions keep their timing. It runs before Soften breaths and Soften S sounds. Needs Level the volume and one voice. Off by default.
|
|
6
|
+
- On a 2½-hour narration, the spread of sentence loudness against the surrounding minute fell from 1.0 dB to 0.6 dB, and joins between requests jumping more than 3 dB went from 15% to none.
|
|
7
|
+
- **Tip:** set the Video volume to −2 dB (−16 LUFS) with it. The final limiter then never squashes a peak by more than about 3 dB; at the recommended −14 LUFS it flattens the voice's loudest syllables by 3 to 5 dB about 22 times a minute.
|
|
8
|
+
|
|
9
|
+
## Upgrading from 3.22.1
|
|
10
|
+
|
|
11
|
+
- Nothing changes until you turn the switch on. Turning it on joins the narration again from the pieces it already has and renders the video and shorts again; no speech is made again.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Slopify 3.24.0
|
|
2
|
+
|
|
3
|
+
Released 15 October 2026.
|
|
4
|
+
|
|
5
|
+
- **Captions go in during the upload.** The Studio extension now uploads a long video's captions in Studio's upload dialog, on the Video elements step, instead of on the video's Details page after it is scheduled or published. Press Next to Video elements as usual: Add subtitles → Upload file → With timing → the project's captions file → Done happen by themselves, and a toast says "Captions uploaded." The extension still never presses Next, Save or Publish in the upload dialog.
|
|
6
|
+
- Studio keeps Add subtitles greyed out until the video has processed far enough to play and has a video language. Until then a toast says Studio's own reason once, and the captions go in as soon as Studio allows it. Set the video language in Details (Show more → Language) if your channel has no default language.
|
|
7
|
+
- If you move on before that, or Studio's dialog isn't what the extension expects, a toast says so and the captions are uploaded on the Details page after the upload, as before.
|
|
8
|
+
- A video whose captions went in during the upload gets no Details-page visit for captions; one still opens when it needs an end screen or a related video, and skips the captions.
|
|
9
|
+
|
|
10
|
+
## Upgrading from 3.23.0
|
|
11
|
+
|
|
12
|
+
- The extension goes to 1.5.0. Loaded unpacked from Slopify's folder, it reloads itself once it has been idle for 45 minutes; a store install updates through the store.
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "3.24.0",
|
|
4
|
+
"title": "Slopify 3.24.0",
|
|
5
|
+
"version": "3.24.0",
|
|
6
|
+
"date": "2026-10-15"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "3.23.0",
|
|
10
|
+
"title": "Slopify 3.23.0",
|
|
11
|
+
"version": "3.23.0",
|
|
12
|
+
"date": "2026-10-14"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"id": "3.22.1",
|
|
4
16
|
"title": "Slopify 3.22.1",
|
|
@@ -189,6 +189,8 @@ export const runDraftSchema = z.object({
|
|
|
189
189
|
// Soften breaths and Soften S sounds (`narration/breaths-model.ts`); absent is off.
|
|
190
190
|
removeBreaths: z.boolean().optional(),
|
|
191
191
|
deEss: z.boolean().optional(),
|
|
192
|
+
// Level each sentence (`loudness/sentence-level.ts`); absent is off.
|
|
193
|
+
levelSentences: z.boolean().optional(),
|
|
192
194
|
// Absent is English (`kernel/ports/languages.ts`).
|
|
193
195
|
language: languageSchema.optional(),
|
|
194
196
|
});
|
|
@@ -11,7 +11,9 @@ import { lowerLoudPhrases, phrasePasses } from "./phrase-level.js";
|
|
|
11
11
|
// the report says what the levelling did, not what it meant to do.
|
|
12
12
|
const format = { sampleRate: 44100, channels: 1 };
|
|
13
13
|
const goal = { lufs: pieceLufs, truePeak: pieceTruePeak };
|
|
14
|
-
export async function levelPieces(run, files, directory
|
|
14
|
+
export async function levelPieces(run, files, directory,
|
|
15
|
+
// Level each sentence takes the place of the phrase step (`sentence-level.ts`).
|
|
16
|
+
options = { phrases: true }) {
|
|
15
17
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
16
18
|
const levelled = [];
|
|
17
19
|
const before = [];
|
|
@@ -25,7 +27,7 @@ export async function levelPieces(run, files, directory) {
|
|
|
25
27
|
// are quieter too, and an opening still stands above them. Quiet passages are never raised.
|
|
26
28
|
let source = file;
|
|
27
29
|
const made = [];
|
|
28
|
-
for (let pass = 1; pass <= phrasePasses; pass++) {
|
|
30
|
+
for (let pass = 1; options.phrases && pass <= phrasePasses; pass++) {
|
|
29
31
|
const phrased = `${output}.phrases-${String(pass)}.wav`;
|
|
30
32
|
if (!(await lowerLoudPhrases(run, source, phrased)))
|
|
31
33
|
break;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Level each sentence's numbers (`sentence-level.ts`), browser-safe so recipes can carry them.
|
|
2
|
+
export const sentenceLevel = {
|
|
3
|
+
// The sentences a sentence is held to: those whose middle is this close to its own.
|
|
4
|
+
contextSeconds: 30,
|
|
5
|
+
// ceiling: the most a quiet sentence is raised, and a loud one lowered.
|
|
6
|
+
raiseDb: 3,
|
|
7
|
+
lowerDb: 6,
|
|
8
|
+
// ceiling: a raised sentence's loudest sample stays under this, in dBFS.
|
|
9
|
+
peakCeilingDb: -1,
|
|
10
|
+
// Speech is cut into parts at each pause at least this long, a part shorter than the
|
|
11
|
+
// minimum joined to its neighbour.
|
|
12
|
+
cutSeconds: 0.2,
|
|
13
|
+
partSecondsMin: 1.5,
|
|
14
|
+
// A second pass holds each sentence to its neighbours as the first left them.
|
|
15
|
+
passes: 2,
|
|
16
|
+
};
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, resolve as resolvePath } from "node:path";
|
|
4
|
+
import { measurePiece } from "../narration/pauses.js";
|
|
5
|
+
import { runFfmpeg } from "../video/ffmpeg.js";
|
|
6
|
+
import { sentenceLevel } from "./sentence-level-model.js";
|
|
7
|
+
export { sentenceLevel } from "./sentence-level-model.js";
|
|
8
|
+
// Level each sentence (`levelSentences` on a run's config). The voice provider reads a narration
|
|
9
|
+
// a request at a time, and inside a request the voice drifts: the first sentences come out
|
|
10
|
+
// louder, the last ones quieter, and now and then one line stands well above or below the lines
|
|
11
|
+
// around it. Levelling a request by its average can't see that. With the setting on, once each
|
|
12
|
+
// request is brought to the common level (`level-pieces.ts`, without the phrase step), every
|
|
13
|
+
// sentence is measured (its K-weighted loudness, gated as EBU R128 gates a programme) and moved
|
|
14
|
+
// toward the median of the sentences in the minute around it, across the joins between
|
|
15
|
+
// requests too. A loud sentence comes down by up to `lowerDb`, a quiet one up by up to
|
|
16
|
+
// `raiseDb`, and never so far that its loudest sample would pass `peakCeilingDb` (what comes
|
|
17
|
+
// after is written as 16-bit PCM). The gain changes only in the quiet between two sentences, so
|
|
18
|
+
// no word is cut into and nothing moves in time. It runs before Soften breaths and Soften S
|
|
19
|
+
// sounds, which then work on the levelled voice.
|
|
20
|
+
// Fewer neighbours than this within the context, and the context is doubled.
|
|
21
|
+
const neighboursMin = 6;
|
|
22
|
+
// A change smaller than this isn't sent to the volume filter.
|
|
23
|
+
const changeDb = 0.05;
|
|
24
|
+
// The meter's rate: K-weighting's filters are specified at 48 kHz.
|
|
25
|
+
const meterRate = 48000;
|
|
26
|
+
// One reading per 10 ms.
|
|
27
|
+
const frameSamples = 480;
|
|
28
|
+
const framesPerSecond = meterRate / frameSamples;
|
|
29
|
+
// ITU-R BS.1770 K-weighting at 48 kHz: a high shelf, then the RLB high-pass.
|
|
30
|
+
const shelf = {
|
|
31
|
+
b: [1.53512485958697, -2.69169618940638, 1.19839281085285],
|
|
32
|
+
a: [-1.69065929318241, 0.73248077421585],
|
|
33
|
+
};
|
|
34
|
+
const highPass = {
|
|
35
|
+
b: [1.0, -2.0, 1.0],
|
|
36
|
+
a: [-1.99004745483398, 0.99007225036621],
|
|
37
|
+
};
|
|
38
|
+
// K-weighted mean power and sample peak per 10 ms, fed samples (48 kHz mono) as they come.
|
|
39
|
+
export class Meter {
|
|
40
|
+
power = [];
|
|
41
|
+
peaks = [];
|
|
42
|
+
s1 = [0, 0, 0, 0];
|
|
43
|
+
s2 = [0, 0, 0, 0];
|
|
44
|
+
sum = 0;
|
|
45
|
+
peak = 0;
|
|
46
|
+
count = 0;
|
|
47
|
+
feed(samples) {
|
|
48
|
+
const [b0, b1, b2] = shelf.b;
|
|
49
|
+
const [a1, a2] = shelf.a;
|
|
50
|
+
const [c0, c1, c2] = highPass.b;
|
|
51
|
+
const [d1, d2] = highPass.a;
|
|
52
|
+
let [x1, x2, y1, y2] = this.s1;
|
|
53
|
+
let [u1, u2, v1, v2] = this.s2;
|
|
54
|
+
for (const x of samples) {
|
|
55
|
+
const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
|
|
56
|
+
x2 = x1;
|
|
57
|
+
x1 = x;
|
|
58
|
+
y2 = y1;
|
|
59
|
+
y1 = y;
|
|
60
|
+
const v = c0 * y + c1 * u1 + c2 * u2 - d1 * v1 - d2 * v2;
|
|
61
|
+
u2 = u1;
|
|
62
|
+
u1 = y;
|
|
63
|
+
v2 = v1;
|
|
64
|
+
v1 = v;
|
|
65
|
+
this.sum += v * v;
|
|
66
|
+
const level = Math.abs(x);
|
|
67
|
+
if (level > this.peak)
|
|
68
|
+
this.peak = level;
|
|
69
|
+
this.count += 1;
|
|
70
|
+
if (this.count === frameSamples) {
|
|
71
|
+
this.power.push(this.sum / frameSamples);
|
|
72
|
+
this.peaks.push(this.peak);
|
|
73
|
+
this.sum = 0;
|
|
74
|
+
this.peak = 0;
|
|
75
|
+
this.count = 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
this.s1 = [x1, x2, y1, y2];
|
|
79
|
+
this.s2 = [u1, u2, v1, v2];
|
|
80
|
+
}
|
|
81
|
+
readings() {
|
|
82
|
+
return { power: this.power, peaks: this.peaks };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const loudnessOf = (power) => -0.691 + 10 * Math.log10(Math.max(power, 1e-12));
|
|
86
|
+
// The gated loudness of readings `from` to `to` (seconds), as EBU R128 gates a programme: 400 ms
|
|
87
|
+
// blocks every 100 ms, those under -70 LUFS and those 10 LU under the rest left out. Undefined
|
|
88
|
+
// when nothing in it is loud enough to count.
|
|
89
|
+
export function gatedLoudness(power, from, to) {
|
|
90
|
+
const a = Math.max(0, Math.round(from * framesPerSecond));
|
|
91
|
+
const b = Math.min(power.length, Math.round(to * framesPerSecond));
|
|
92
|
+
if (b - a < 10)
|
|
93
|
+
return undefined;
|
|
94
|
+
const block = 40;
|
|
95
|
+
const blocks = [];
|
|
96
|
+
if (b - a < block) {
|
|
97
|
+
let sum = 0;
|
|
98
|
+
for (let at = a; at < b; at++)
|
|
99
|
+
sum += power[at] ?? 0;
|
|
100
|
+
blocks.push(sum / (b - a));
|
|
101
|
+
}
|
|
102
|
+
else
|
|
103
|
+
for (let start = a; start + block <= b; start += 10) {
|
|
104
|
+
let sum = 0;
|
|
105
|
+
for (let at = start; at < start + block; at++)
|
|
106
|
+
sum += power[at] ?? 0;
|
|
107
|
+
blocks.push(sum / block);
|
|
108
|
+
}
|
|
109
|
+
const heard = blocks.filter((one) => loudnessOf(one) > -70);
|
|
110
|
+
if (heard.length === 0)
|
|
111
|
+
return undefined;
|
|
112
|
+
const mean = (values) => values.reduce((sum, one) => sum + one, 0) / values.length;
|
|
113
|
+
const relative = loudnessOf(mean(heard)) - 10;
|
|
114
|
+
const kept = heard.filter((one) => loudnessOf(one) > relative);
|
|
115
|
+
return kept.length === 0 ? undefined : loudnessOf(mean(kept));
|
|
116
|
+
}
|
|
117
|
+
const median = (values) => {
|
|
118
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
119
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
120
|
+
};
|
|
121
|
+
// Each sentence's gain in dB: toward the median of its neighbours, within the caps.
|
|
122
|
+
export function sentenceGains(readings) {
|
|
123
|
+
const gains = readings.map(() => 0);
|
|
124
|
+
const known = readings.flatMap((one, at) => (one.loudness === undefined ? [] : [at]));
|
|
125
|
+
for (let pass = 0; pass < sentenceLevel.passes; pass++) {
|
|
126
|
+
const now = readings.map((one, at) => one.loudness === undefined ? undefined : one.loudness + (gains[at] ?? 0));
|
|
127
|
+
for (const at of known) {
|
|
128
|
+
const reading = readings[at];
|
|
129
|
+
const near = (seconds) => known.filter((other) => other !== at &&
|
|
130
|
+
Math.abs(readings[other].at - reading.at) <= seconds);
|
|
131
|
+
let around = near(sentenceLevel.contextSeconds);
|
|
132
|
+
if (around.length < neighboursMin)
|
|
133
|
+
around = near(2 * sentenceLevel.contextSeconds);
|
|
134
|
+
if (around.length === 0)
|
|
135
|
+
continue;
|
|
136
|
+
const target = median(around.map((other) => now[other]));
|
|
137
|
+
const wanted = (gains[at] ?? 0) + target - now[at];
|
|
138
|
+
const headroom = Math.max(0, sentenceLevel.peakCeilingDb - reading.peakDb);
|
|
139
|
+
gains[at] = Math.max(-sentenceLevel.lowerDb, Math.min(sentenceLevel.raiseDb, headroom, wanted));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return gains.map((gain) => Math.round(gain * 100) / 100);
|
|
143
|
+
}
|
|
144
|
+
// What a piece is levelled in: its speech cut at each pause of at least `cutSeconds`, a part
|
|
145
|
+
// shorter than `partSecondsMin` joined to the one before (or after, for the first). These are
|
|
146
|
+
// sentences, and the halves of a long one; the gain only ever changes in such a pause.
|
|
147
|
+
export function speechSpans(durationSeconds, silences) {
|
|
148
|
+
const edge = 0.02;
|
|
149
|
+
const lead = silences.find((one) => one.start <= edge);
|
|
150
|
+
const tail = silences.find((one) => one !== lead && one.end >= durationSeconds - edge);
|
|
151
|
+
const start = lead?.end ?? 0;
|
|
152
|
+
const end = tail?.start ?? durationSeconds;
|
|
153
|
+
if (end <= start)
|
|
154
|
+
return [];
|
|
155
|
+
const spans = [];
|
|
156
|
+
let from = start;
|
|
157
|
+
for (const silence of silences) {
|
|
158
|
+
if (silence === lead || silence === tail)
|
|
159
|
+
continue;
|
|
160
|
+
if (silence.end - silence.start < sentenceLevel.cutSeconds)
|
|
161
|
+
continue;
|
|
162
|
+
if (silence.start <= from || silence.end >= end)
|
|
163
|
+
continue;
|
|
164
|
+
spans.push({ start: from, end: silence.start });
|
|
165
|
+
from = silence.end;
|
|
166
|
+
}
|
|
167
|
+
spans.push({ start: from, end });
|
|
168
|
+
const merged = [];
|
|
169
|
+
for (const span of spans) {
|
|
170
|
+
const last = merged.at(-1);
|
|
171
|
+
if (last !== undefined &&
|
|
172
|
+
(span.end - span.start < sentenceLevel.partSecondsMin ||
|
|
173
|
+
last.end - last.start < sentenceLevel.partSecondsMin))
|
|
174
|
+
last.end = span.end;
|
|
175
|
+
else
|
|
176
|
+
merged.push({ ...span });
|
|
177
|
+
}
|
|
178
|
+
return merged;
|
|
179
|
+
}
|
|
180
|
+
// A piece's gain changes: the first sentence's from the start, each next one's from the middle of
|
|
181
|
+
// the quiet before it. Spans and times are the piece's own.
|
|
182
|
+
export function gainChanges(spans, gains) {
|
|
183
|
+
const changes = [];
|
|
184
|
+
let sent = Number.NaN;
|
|
185
|
+
for (const [index, span] of spans.entries()) {
|
|
186
|
+
const gain = gains[index] ?? 0;
|
|
187
|
+
if (Math.abs(gain - sent) < changeDb)
|
|
188
|
+
continue;
|
|
189
|
+
const before = spans[index - 1];
|
|
190
|
+
changes.push({
|
|
191
|
+
at: before === undefined ? 0 : Math.round(((before.end + span.start) / 2) * 1000) / 1000,
|
|
192
|
+
gainDb: gain,
|
|
193
|
+
});
|
|
194
|
+
sent = gain;
|
|
195
|
+
}
|
|
196
|
+
return changes;
|
|
197
|
+
}
|
|
198
|
+
// The piece's readings: decoded to 48 kHz mono and metered as it streams.
|
|
199
|
+
export async function meterFile(run, file) {
|
|
200
|
+
const meter = new Meter();
|
|
201
|
+
await new Promise((resolve, reject) => {
|
|
202
|
+
const executable = !isAbsolute(run.bin) && /[\\/]/.test(run.bin) ? resolvePath(run.bin) : run.bin;
|
|
203
|
+
const child = spawn(executable, [
|
|
204
|
+
"-hide_banner",
|
|
205
|
+
"-nostdin",
|
|
206
|
+
"-loglevel",
|
|
207
|
+
"error",
|
|
208
|
+
"-i",
|
|
209
|
+
file,
|
|
210
|
+
"-ac",
|
|
211
|
+
"1",
|
|
212
|
+
"-ar",
|
|
213
|
+
String(meterRate),
|
|
214
|
+
"-f",
|
|
215
|
+
"f32le",
|
|
216
|
+
"pipe:1",
|
|
217
|
+
], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
218
|
+
let carry = Buffer.alloc(0);
|
|
219
|
+
let errors = "";
|
|
220
|
+
child.stdout.on("data", (chunk) => {
|
|
221
|
+
const bytes = carry.length === 0 ? chunk : Buffer.concat([carry, chunk]);
|
|
222
|
+
const whole = bytes.length - (bytes.length % 4);
|
|
223
|
+
const aligned = new Float32Array(whole / 4);
|
|
224
|
+
new Uint8Array(aligned.buffer).set(bytes.subarray(0, whole));
|
|
225
|
+
meter.feed(aligned);
|
|
226
|
+
carry = Buffer.from(bytes.subarray(whole));
|
|
227
|
+
});
|
|
228
|
+
child.stderr.setEncoding("utf8");
|
|
229
|
+
child.stderr.on("data", (text) => {
|
|
230
|
+
errors = (errors + text).slice(-2000);
|
|
231
|
+
});
|
|
232
|
+
const stop = () => child.kill("SIGKILL");
|
|
233
|
+
run.signal.addEventListener("abort", stop, { once: true });
|
|
234
|
+
child.on("error", (error) => {
|
|
235
|
+
run.signal.removeEventListener("abort", stop);
|
|
236
|
+
reject(new Error(`Slopify could not start ffmpeg to measure the narration (${error.message}). Reinstall Slopify, then Try again.`));
|
|
237
|
+
});
|
|
238
|
+
child.on("close", (code) => {
|
|
239
|
+
run.signal.removeEventListener("abort", stop);
|
|
240
|
+
if (run.signal.aborted)
|
|
241
|
+
reject(new Error("the levelling was canceled"));
|
|
242
|
+
else if (code === 0)
|
|
243
|
+
resolve();
|
|
244
|
+
else
|
|
245
|
+
reject(new Error(`Slopify couldn't measure a narration piece's sentences (ffmpeg said: ${errors.trim().split("\n").at(-1) ?? "nothing"}). Try again; if it happens again, turn off Level each sentence in Edit project → Pauses and volume, then use Download diagnostics in Settings and report it.`));
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
return meter.readings();
|
|
249
|
+
}
|
|
250
|
+
// The pieces with each sentence levelled, as WAVs in `directory`, in the order given; a piece
|
|
251
|
+
// none of whose sentences moves is passed on as it is. Returns the files and how many sentences
|
|
252
|
+
// were raised and lowered by more than 1 dB.
|
|
253
|
+
export async function levelSentences(run, files, directory) {
|
|
254
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
255
|
+
const measured = [];
|
|
256
|
+
let offset = 0;
|
|
257
|
+
for (const file of files) {
|
|
258
|
+
run.signal.throwIfAborted();
|
|
259
|
+
const { durationSeconds, silences } = await measurePiece(run, file);
|
|
260
|
+
const spans = speechSpans(durationSeconds, silences);
|
|
261
|
+
const { power, peaks } = await meterFile(run, file);
|
|
262
|
+
const readings = spans.map((span) => {
|
|
263
|
+
let loudest = 0;
|
|
264
|
+
const a = Math.max(0, Math.floor(span.start * framesPerSecond));
|
|
265
|
+
const b = Math.min(peaks.length, Math.ceil(span.end * framesPerSecond));
|
|
266
|
+
for (let at = a; at < b; at++)
|
|
267
|
+
loudest = Math.max(loudest, peaks[at] ?? 0);
|
|
268
|
+
return {
|
|
269
|
+
at: offset + (span.start + span.end) / 2,
|
|
270
|
+
loudness: gatedLoudness(power, span.start, span.end),
|
|
271
|
+
peakDb: 20 * Math.log10(Math.max(loudest, 1e-9)),
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
measured.push({ spans, readings });
|
|
275
|
+
offset += durationSeconds;
|
|
276
|
+
}
|
|
277
|
+
const gains = sentenceGains(measured.flatMap((one) => one.readings));
|
|
278
|
+
const written = [];
|
|
279
|
+
let next = 0;
|
|
280
|
+
for (const [index, file] of files.entries()) {
|
|
281
|
+
run.signal.throwIfAborted();
|
|
282
|
+
const { spans } = measured[index];
|
|
283
|
+
const own = gains.slice(next, next + spans.length);
|
|
284
|
+
next += spans.length;
|
|
285
|
+
const changes = gainChanges(spans, own);
|
|
286
|
+
if (changes.every((one) => Math.abs(one.gainDb) < changeDb)) {
|
|
287
|
+
written.push(file);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const output = join(directory, `sentences-${String(index + 1).padStart(4, "0")}.wav`);
|
|
291
|
+
const commands = `${output}.gains.txt`;
|
|
292
|
+
writeFileSync(commands, changes
|
|
293
|
+
.map((one) => `${one.at.toFixed(3)} volume@sentences volume ${(10 ** (one.gainDb / 20)).toFixed(5)};`)
|
|
294
|
+
.join("\n"), { mode: 0o600 });
|
|
295
|
+
try {
|
|
296
|
+
await runFfmpeg({
|
|
297
|
+
bin: run.bin,
|
|
298
|
+
args: [
|
|
299
|
+
"-hide_banner",
|
|
300
|
+
"-nostdin",
|
|
301
|
+
"-loglevel",
|
|
302
|
+
"error",
|
|
303
|
+
"-nostats",
|
|
304
|
+
"-y",
|
|
305
|
+
"-i",
|
|
306
|
+
file,
|
|
307
|
+
"-af",
|
|
308
|
+
`asendcmd=f=${commands.replaceAll("\\", "/").replaceAll(":", "\\\\:")},volume@sentences=volume=${(10 ** ((changes[0]?.at === 0 ? changes[0].gainDb : 0) / 20)).toFixed(5)}:precision=float:eval=frame`,
|
|
309
|
+
"-c:a",
|
|
310
|
+
"pcm_f32le",
|
|
311
|
+
"-f",
|
|
312
|
+
"wav",
|
|
313
|
+
output,
|
|
314
|
+
],
|
|
315
|
+
signal: run.signal,
|
|
316
|
+
log: run.log,
|
|
317
|
+
onProgress: () => { },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
rmSync(commands, { force: true });
|
|
322
|
+
}
|
|
323
|
+
written.push(output);
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
files: written,
|
|
327
|
+
raised: gains.filter((gain) => gain > 1).length,
|
|
328
|
+
lowered: gains.filter((gain) => gain < -1).length,
|
|
329
|
+
sentences: gains.length,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -296,6 +296,10 @@ export function toAdmissionDraft(input) {
|
|
|
296
296
|
// Soften breaths and Soften S sounds: only while on, with a narration made here to clean.
|
|
297
297
|
...(sources.audio === "generate" ? breathsOfForm(form) : {}),
|
|
298
298
|
...(sources.audio === "generate" ? deEssOfForm(form) : {}),
|
|
299
|
+
// Level each sentence: only while on, with a narration made here.
|
|
300
|
+
...(sources.audio === "generate" && form.levelSentences === true
|
|
301
|
+
? { levelSentences: true }
|
|
302
|
+
: {}),
|
|
299
303
|
edgeSilenceSeconds: measure("edgeSilenceSeconds", sources.audio !== "off", defaultEdgeSilenceSeconds, edgeSilenceSecondsProblem),
|
|
300
304
|
};
|
|
301
305
|
const parsed = runDraftSchema.safeParse(draft);
|
|
@@ -90,6 +90,8 @@ export const playDraftFormSchema = z
|
|
|
90
90
|
// templates saved before them: off.
|
|
91
91
|
removeBreaths: z.boolean().optional(),
|
|
92
92
|
deEss: z.boolean().optional(),
|
|
93
|
+
// Level each sentence (`loudness/sentence-level.ts`). Absent on drafts saved before it: off.
|
|
94
|
+
levelSentences: z.boolean().optional(),
|
|
93
95
|
// Absent on drafts and templates saved before the YouTube description: off, built-in prompt.
|
|
94
96
|
youtubeDescription: z.boolean().optional(),
|
|
95
97
|
showFigures: z.boolean().optional(),
|
|
@@ -238,6 +238,7 @@ export function documentFromProject(deps, revision) {
|
|
|
238
238
|
: { paragraphPause: String(config.paragraphPauseSeconds) }),
|
|
239
239
|
...(config.removeBreaths === true ? { removeBreaths: true } : {}),
|
|
240
240
|
...(config.deEss === true ? { deEss: true } : {}),
|
|
241
|
+
...(config.levelSentences === true ? { levelSentences: true } : {}),
|
|
241
242
|
imagePrompts,
|
|
242
243
|
...(config.imageScale === undefined ? {} : { imageScale: imageScaleForm(config.imageScale) }),
|
|
243
244
|
...(config.imageScenes === true ? { imageScenes: true } : {}),
|
|
@@ -161,6 +161,7 @@ function inputChanges(parent, view) {
|
|
|
161
161
|
add("Pause between paragraphs (seconds)", before.paragraphPauseSeconds ?? 0, after.paragraphPauseSeconds ?? 0);
|
|
162
162
|
add("Soften breaths", before.removeBreaths === true ? "On" : "Off", after.removeBreaths === true ? "On" : "Off");
|
|
163
163
|
add("Soften S sounds", before.deEss === true ? "On" : "Off", after.deEss === true ? "On" : "Off");
|
|
164
|
+
add("Level each sentence", before.levelSentences === true ? "On" : "Off", after.levelSentences === true ? "On" : "Off");
|
|
164
165
|
add("Level the volume", before.loudness === undefined ? "Off" : loudnessSummary(before.loudness), after.loudness === undefined ? "Off" : loudnessSummary(after.loudness));
|
|
165
166
|
add("YouTube description", before.youtubeDescription === true ? "On" : "Off", after.youtubeDescription === true ? "On" : "Off");
|
|
166
167
|
add("Description prompt", before.descriptionPrompt, after.descriptionPrompt);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { pieceLufs, pieceTruePeak, truePeakOf, usesLoudness, } from "../loudness/model.js";
|
|
2
|
+
import { sentenceLevel } from "../loudness/sentence-level-model.js";
|
|
3
|
+
import { usesVoices } from "../voices/model.js";
|
|
2
4
|
import { recipe, resourceIdentity, } from "./recipe-model.js";
|
|
3
5
|
// Level the volume (`loudness/model.ts`): beside each narration join, a step that joins the
|
|
4
6
|
// same pieces again, each brought to one loudness first. The plain join stays what it was, and
|
|
@@ -10,6 +12,17 @@ export const levelOperation = "level-narration-v1";
|
|
|
10
12
|
export function levelKey(segment) {
|
|
11
13
|
return `level:${segment}`;
|
|
12
14
|
}
|
|
15
|
+
export const sentencesValue = "sentences-v1";
|
|
16
|
+
// Level each sentence (`loudness/sentence-level.ts`): asked for, with Level the volume on and one
|
|
17
|
+
// voice (a multi-voice run is levelled line by line instead).
|
|
18
|
+
export function sentencesLevelled(config) {
|
|
19
|
+
return config.levelSentences === true && usesLoudness(config) && !usesVoices(config);
|
|
20
|
+
}
|
|
21
|
+
// Whether a levelled join's values ask for it.
|
|
22
|
+
export function sentencesAsked(values) {
|
|
23
|
+
return (Array.isArray(values) &&
|
|
24
|
+
values.slice(3).some((value) => Array.isArray(value) && value[0] === sentencesValue));
|
|
25
|
+
}
|
|
13
26
|
export function levelRecipe(context, join, segment) {
|
|
14
27
|
if (!usesLoudness(context.config) || join.input.kind !== "local")
|
|
15
28
|
return undefined;
|
|
@@ -22,6 +35,21 @@ export function levelRecipe(context, join, segment) {
|
|
|
22
35
|
join.input.operation,
|
|
23
36
|
join.input.values,
|
|
24
37
|
["piece-level-v1", pieceLufs, pieceTruePeak],
|
|
38
|
+
// Level each sentence: the body's sentences instead of its loud phrases. Nothing while
|
|
39
|
+
// it is off, so every levelled join made before it keeps its fingerprint.
|
|
40
|
+
...(sentencesLevelled(context.config) && segment === "body"
|
|
41
|
+
? [
|
|
42
|
+
[
|
|
43
|
+
sentencesValue,
|
|
44
|
+
sentenceLevel.contextSeconds,
|
|
45
|
+
sentenceLevel.raiseDb,
|
|
46
|
+
sentenceLevel.lowerDb,
|
|
47
|
+
sentenceLevel.peakCeilingDb,
|
|
48
|
+
sentenceLevel.cutSeconds,
|
|
49
|
+
sentenceLevel.partSecondsMin,
|
|
50
|
+
],
|
|
51
|
+
]
|
|
52
|
+
: []),
|
|
25
53
|
],
|
|
26
54
|
}, join.dependsOn, { unresolved: join.unresolved });
|
|
27
55
|
}
|
|
@@ -5,6 +5,7 @@ import { referenceKey, thumbnailVariant } from "../admission/model.js";
|
|
|
5
5
|
import { plainText } from "../article/plain.js";
|
|
6
6
|
import { splitEndMatter } from "../article/split.js";
|
|
7
7
|
import { levelPieces } from "../loudness/level-pieces.js";
|
|
8
|
+
import { levelSentences } from "../loudness/sentence-level.js";
|
|
8
9
|
import { cleanPieces } from "../narration/clean-pieces.js";
|
|
9
10
|
import { joinNarration } from "../narration/concat.js";
|
|
10
11
|
import { applyPauses, planPieces } from "../narration/pauses.js";
|
|
@@ -13,7 +14,7 @@ import { outputPath } from "../storage/layout.js";
|
|
|
13
14
|
import { probeDurationMs, runFfmpeg } from "../video/ffmpeg.js";
|
|
14
15
|
import { turnJoinArgs } from "../voices/join.js";
|
|
15
16
|
import { breathsAsked, deEssAsked } from "./recipe-breaths.js";
|
|
16
|
-
import { levelOperation } from "./recipe-loudness.js";
|
|
17
|
+
import { levelOperation, sentencesAsked } from "./recipe-loudness.js";
|
|
17
18
|
import { executeFigureCard } from "./runtime-figure-card.js";
|
|
18
19
|
import { publishNarrationText } from "./runtime-narration-text.js";
|
|
19
20
|
import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
|
|
@@ -129,13 +130,33 @@ async function concatenate(deps, context, piece) {
|
|
|
129
130
|
const [operation, values] = recipe.input.kind !== "local"
|
|
130
131
|
? ["", undefined]
|
|
131
132
|
: level
|
|
132
|
-
? z
|
|
133
|
+
? z
|
|
134
|
+
.tuple([z.string(), z.unknown(), z.unknown()])
|
|
135
|
+
.rest(z.unknown())
|
|
136
|
+
.parse(recipe.input.values)
|
|
133
137
|
: [recipe.input.operation, recipe.input.values];
|
|
134
138
|
const pending = allocateAsset(deps, context.work.projectId, "narration.mp3");
|
|
135
139
|
const piecesDirectory = join(dirname(pending.absolutePath), "levelled");
|
|
136
140
|
try {
|
|
137
141
|
const run = { bin: deps.ffmpeg, log: deps.log, signal: context.signal };
|
|
138
|
-
|
|
142
|
+
// Level each sentence: in place of the phrase step, then sentence by sentence, before the
|
|
143
|
+
// breaths and S sounds are softened (`loudness/sentence-level.ts`).
|
|
144
|
+
const sentences = level && sentencesAsked(recipe.input.kind === "local" ? recipe.input.values : undefined);
|
|
145
|
+
const pieceLevelled = level
|
|
146
|
+
? await levelPieces(run, files, piecesDirectory, { phrases: !sentences })
|
|
147
|
+
: undefined;
|
|
148
|
+
const levelledSentences = pieceLevelled !== undefined && sentences
|
|
149
|
+
? await levelSentences(run, pieceLevelled.files, join(piecesDirectory, "sentences"))
|
|
150
|
+
: undefined;
|
|
151
|
+
if (levelledSentences !== undefined)
|
|
152
|
+
deps.log.write("info", "narration.sentences", {
|
|
153
|
+
projectId: context.work.projectId,
|
|
154
|
+
stage: "audio",
|
|
155
|
+
detail: `${String(levelledSentences.sentences)} sentences: ${String(levelledSentences.raised)} raised and ${String(levelledSentences.lowered)} lowered by more than 1 dB`,
|
|
156
|
+
});
|
|
157
|
+
const levelled = pieceLevelled === undefined
|
|
158
|
+
? undefined
|
|
159
|
+
: { ...pieceLevelled, files: levelledSentences?.files ?? pieceLevelled.files };
|
|
139
160
|
// Soften breaths, then Soften S sounds: made in what is joined, before the pauses; nothing
|
|
140
161
|
// moves, so the pauses are still planned from the pieces as spoken.
|
|
141
162
|
const cleaned = await cleanPieces(run, levelled?.files ?? files, piecesDirectory, {
|
|
@@ -136,6 +136,7 @@ See [Narration Aliases and Glossary](Narration-Aliases-and-Glossary).
|
|
|
136
136
|
| **Pause between paragraphs (seconds)** | The least quiet at the end of a paragraph, when you want more than between sentences. At 0 paragraphs get the sentence pause. Try 0.8 to 1.2 for a slower read. A speaker's turn gap is set under Speakers instead. | 0; 0 to 2 |
|
|
137
137
|
| **Soften breaths** | Turns the audible in-breaths a voice takes between sentences and after commas down by 12 dB, with soft fades (Inworld's TTS-2 takes a loud one almost every time). A breath with any word sound in it is left alone; words, pauses and captions stay where they were. | Off |
|
|
138
138
|
| **Soften S sounds** | Takes the sharp edge off s and sh sounds with a very light de-esser; the voice keeps its brightness. Works with or without Soften breaths. | Off |
|
|
139
|
+
| **Level each sentence** | Holds every sentence to the sentences in the minute around it, so no line jumps out or drops away: a loud one comes down by up to 6 dB, a quiet one up by up to 3 dB, and the volume only changes in the pauses. Runs before Soften breaths and Soften S sounds. Needs Level the volume; one voice only. Pairs well with a Video volume of −2 dB, which gives the final limiter more room. | Off |
|
|
139
140
|
| **Level the volume** | Brings every narration piece to one loudness before they are joined, then sets the video, shorts and audio files to a finished loudness. | On for new runs (Settings → General → **Level the volume for new runs**) |
|
|
140
141
|
| **Video volume** | How loud the video and shorts are, in dB from the recommended level (0 dB is -14 LUFS, what YouTube and Spotify play at). -6 dB is about 50%, +4 dB about 158%. Peaks stay under -1.5 dBTP. | 0 dB; -10 to +4 dB in 0.5 dB steps |
|
|
141
142
|
| **Audio files volume** | How loud the WAV, MP3 and M4B are, in dB from -18 LUFS (what audiobook shops such as ACX ask for). Peaks stay under -3 dBTP. | 0 dB; -10 to +4 dB |
|
|
@@ -144,7 +145,7 @@ You can type a volume in dB or as a percentage of the recommended level.
|
|
|
144
145
|
|
|
145
146
|
What changing them remakes:
|
|
146
147
|
|
|
147
|
-
- **Pauses, Soften breaths
|
|
148
|
+
- **Pauses, Soften breaths, Soften S sounds or Level each sentence**: the narration is joined again from the pieces it already has and timed again, which remakes what is timed from it: captions, the video and, when on, the YouTube description and the shorts' pick (those two are text calls). No speech is made again.
|
|
148
149
|
- **Level the volume on or off**: the levelled narration is joined again and the exports rendered again. No speech is made again, and word timing, description, shorts' pick and reviews are not redone.
|
|
149
150
|
- **A volume only**: the exports are mastered again, nothing else.
|
|
150
151
|
|