@gentbajko/slopify 3.22.0 → 3.23.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.
@@ -7,7 +7,10 @@ import { cardinal, numberForms } from "../../kernel/ports/number-words.js";
7
7
  // `formsOf` is the language's normaliser (`multilingual.ts` passes its own); the default is
8
8
  // English, and `empty` is the sentence for a text with nothing to say.
9
9
  export function speechWords(text, observed = "", aliases = [], formsOf = wordForms, empty = "Local subtitles currently require an English transcript with spoken words.") {
10
- const matches = aliases.length === 0 ? [] : aliasMatches(text, aliases);
10
+ // An alias spoken as IPA ("Gygax's" said /ˈɡaɪɡæksɪz/) is a pronunciation, not other
11
+ // words: like a glossary entry, the recording is matched against the written word.
12
+ const worded = aliases.filter((alias) => !spokenAsIpa(alias.spoken));
13
+ const matches = worded.length === 0 ? [] : aliasMatches(text, worded);
11
14
  const words = [];
12
15
  for (const token of text.matchAll(/\S+/g)) {
13
16
  const textWord = token[0];
@@ -57,6 +60,9 @@ export function speechWords(text, observed = "", aliases = [], formsOf = wordFor
57
60
  // the written text again without the aliases, and "&" alone says nothing.
58
61
  return words.map(({ text: shown, spoken, forms, aliased }) => forms.length > 1 || aliased ? { text: shown, spoken, forms } : { text: shown, spoken });
59
62
  }
63
+ function spokenAsIpa(spoken) {
64
+ return /^\/[^/]+\/(?:\s+\/[^/]+\/)*$/u.test(spoken.trim());
65
+ }
60
66
  // The form of a word the recording seems to say, else its default.
61
67
  export function respoken(word, observed, formsOf = wordForms) {
62
68
  if (word.forms === undefined)
@@ -0,0 +1,9 @@
1
+ # Slopify 3.22.1
2
+
3
+ Released 13 October 2026.
4
+
5
+ - **Subtitles work with a pronunciation alias.** A narration alias spoken as IPA (for example "Gygax's" said /ˈɡaɪɡæksɪz/) stopped the subtitles with "Local subtitles currently support English speech and Latin-script names only." Subtitles now time that word from its written form, the same way they do for a Pronunciation Glossary entry.
6
+
7
+ ## Upgrading from 3.22.0
8
+
9
+ - A run stopped by that message goes on with Try again; nothing is voiced again.
@@ -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.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.23.0",
4
+ "title": "Slopify 3.23.0",
5
+ "version": "3.23.0",
6
+ "date": "2026-10-14"
7
+ },
8
+ {
9
+ "id": "3.22.1",
10
+ "title": "Slopify 3.22.1",
11
+ "version": "3.22.1",
12
+ "date": "2026-10-13"
13
+ },
2
14
  {
3
15
  "id": "3.22.0",
4
16
  "title": "Slopify 3.22.0",
@@ -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.tuple([z.string(), z.unknown(), z.unknown()]).parse(recipe.input.values)
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
- const levelled = level ? await levelPieces(run, files, piecesDirectory) : undefined;
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 or Soften S sounds**: 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
+ - **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