@gentbajko/slopify 3.18.2 → 3.19.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.
@@ -0,0 +1,9 @@
1
+ # Slopify 3.19.0
2
+
3
+ Released 11 October 2026.
4
+
5
+ - **Remove breaths.** A new switch beside the pauses between sentences (Play → Video and style, Edit project → Pauses and volume) mutes the loud in-breath some voices take after almost every comma and full stop, Inworld's TTS-2 among them. Words, pauses and captions stay exactly where they were. It is off by default. See `docs/breaths.md`.
6
+
7
+ ## Upgrading from 3.18.2
8
+
9
+ - Nothing becomes outdated. Turning Remove breaths on for a project joins its narration again from the pieces it already has; no speech is made again.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "3.19.0",
4
+ "title": "Slopify 3.19.0",
5
+ "version": "3.19.0",
6
+ "date": "2026-10-11"
7
+ },
2
8
  {
3
9
  "id": "3.18.2",
4
10
  "title": "Slopify 3.18.2",
@@ -185,6 +185,8 @@ export const runDraftSchema = z.object({
185
185
  // Pauses between sentences and paragraphs (`narration/pauses-model.ts`); checked by admission.
186
186
  sentencePauseSeconds: z.number().optional(),
187
187
  paragraphPauseSeconds: z.number().optional(),
188
+ // Remove breaths (`narration/breaths-model.ts`); absent is off.
189
+ removeBreaths: z.boolean().optional(),
188
190
  // Absent is English (`kernel/ports/languages.ts`).
189
191
  language: languageSchema.optional(),
190
192
  });
@@ -0,0 +1,261 @@
1
+ import { setImmediate as nextTurn } from "node:timers/promises";
2
+ // The breath detector behind Remove breaths (`breaths-model.ts` says why). It reads 16-bit mono
3
+ // PCM at 48 kHz in 20 ms frames, 10 ms apart, and gives each frame three numbers: its level
4
+ // (RMS in dB), how often it crosses zero (noise crosses often, a voiced vowel seldom) and its
5
+ // spectral centroid (where its sound sits: an in-breath low, an "s" or "f" high).
6
+ // 1. A frame is breath-like when it is noisy (crosses zero in at least 6% of its samples),
7
+ // between a whisper and speech in level, and centred under 2.8 kHz.
8
+ // 2. A run of breath-like frames 140 ms or longer is a breath. It grows back over the quiet
9
+ // frames before it until silence, a voiced word's tail or a fricative, and forward over the
10
+ // quiet after it until silence or the next word's voice.
11
+ // 3. Each breath is muted to -60 dB with 5 ms fades inside its own stretch: a fade outside
12
+ // it once landed on the next word's first sound.
13
+ // Levels are relative to the voice's own: the median level of its voiced frames. A quieter or
14
+ // louder voice finds the same breaths. The thresholds are the ones of the prototype Gent
15
+ // approved by ear, on a narration whose median voiced frame is -21.03 dB.
16
+ export const breathRate = 48_000;
17
+ const frameLength = 960; // 20 ms
18
+ const hop = 480; // 10 ms
19
+ const referenceSpeech = -21.03;
20
+ const breathCeiling = -22;
21
+ const breathFloor = -55;
22
+ const voicedFloor = -42;
23
+ const silenceFloor = -58;
24
+ const noisyCrossings = 0.06;
25
+ const breathCentroid = 2800;
26
+ const breathFrames = 14; // 140 ms
27
+ // A voiced frame for the reference: under the noisy crossing rate and within 30 dB of the
28
+ // voice's loud frames (the 95th percentile).
29
+ const voicedSpan = 30;
30
+ const mutedGain = 10 ** (-60 / 20);
31
+ const fadeSamples = 240; // 5 ms
32
+ // ceiling: 200 frames (2 s of audio, about 15 ms of work) between yields, so a long piece never
33
+ // holds the server's event loop long enough for the page to notice.
34
+ const framesPerTurn = 200;
35
+ // Where the breaths are in a piece, read once from its PCM in order.
36
+ export async function findBreaths(chunks) {
37
+ return breathsIn(await featuresOf(chunks));
38
+ }
39
+ // The chunk starting `offset` samples into the piece with the stretches muted; a new array.
40
+ export function muteChunk(chunk, offset, stretches) {
41
+ const out = Int16Array.from(chunk);
42
+ const end = offset + chunk.length;
43
+ for (const stretch of stretches) {
44
+ if (stretch.end <= offset || stretch.start >= end)
45
+ continue;
46
+ const fade = Math.min(fadeSamples, Math.floor((stretch.end - stretch.start) / 2));
47
+ const from = Math.max(stretch.start, offset);
48
+ const to = Math.min(stretch.end, end);
49
+ for (let at = from; at < to; at += 1) {
50
+ const local = at - offset;
51
+ out[local] = Math.round((chunk[local] ?? 0) * gainAt(stretch, fade, at));
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+ function gainAt(stretch, fade, at) {
57
+ const into = at - stretch.start;
58
+ if (into < fade)
59
+ return fade === 1 ? 1 : 1 + ((mutedGain - 1) * into) / (fade - 1);
60
+ const out = at - (stretch.end - fade);
61
+ if (out >= 0)
62
+ return fade === 1 ? 1 : mutedGain + ((1 - mutedGain) * out) / (fade - 1);
63
+ return mutedGain;
64
+ }
65
+ async function featuresOf(chunks) {
66
+ let capacity = 4096;
67
+ let db = new Float32Array(capacity);
68
+ let crossings = new Float32Array(capacity);
69
+ let centroid = new Float32Array(capacity);
70
+ let count = 0;
71
+ const work = fftWorkspace();
72
+ const frame = new Float64Array(frameLength);
73
+ let carry = new Int16Array(0);
74
+ let base = 0;
75
+ for await (const chunk of chunks) {
76
+ const pending = new Int16Array(carry.length + chunk.length);
77
+ pending.set(carry);
78
+ pending.set(chunk, carry.length);
79
+ // A frame is read once a sample after it has arrived, so the last frame of the piece is
80
+ // the last that ends before its final sample, as the prototype read it.
81
+ while (count * hop + frameLength < base + pending.length) {
82
+ if (count === capacity) {
83
+ capacity *= 2;
84
+ db = grown(db, capacity);
85
+ crossings = grown(crossings, capacity);
86
+ centroid = grown(centroid, capacity);
87
+ }
88
+ const start = count * hop - base;
89
+ for (let at = 0; at < frameLength; at += 1)
90
+ frame[at] = (pending[start + at] ?? 0) / 32768;
91
+ db[count] = levelOf(frame);
92
+ crossings[count] = crossingsOf(frame);
93
+ centroid[count] = powerSpectrumCentroid(frame, work);
94
+ count += 1;
95
+ if (count % framesPerTurn === 0)
96
+ await nextTurn();
97
+ }
98
+ const keep = count * hop - base;
99
+ carry = pending.slice(keep);
100
+ base += keep;
101
+ }
102
+ return { count, db, crossings, centroid };
103
+ }
104
+ function grown(values, capacity) {
105
+ const next = new Float32Array(capacity);
106
+ next.set(values);
107
+ return next;
108
+ }
109
+ function breathsIn(features) {
110
+ const speech = speechLevel(features);
111
+ if (speech === undefined)
112
+ return [];
113
+ const level = (prototype) => prototype - referenceSpeech + speech;
114
+ const ceiling = level(breathCeiling);
115
+ const floor = level(breathFloor);
116
+ const voicedAbove = level(voicedFloor);
117
+ const silentBelow = level(silenceFloor);
118
+ const { count, db, crossings, centroid } = features;
119
+ const at = (values, index) => values[index] ?? 0;
120
+ const voiced = (index) => at(db, index) > voicedAbove && at(crossings, index) < noisyCrossings;
121
+ const hit = (index) => at(crossings, index) >= noisyCrossings &&
122
+ at(db, index) > floor &&
123
+ at(db, index) < ceiling &&
124
+ at(centroid, index) < breathCentroid;
125
+ const stretches = [];
126
+ let i = 0;
127
+ while (i < count) {
128
+ if (!hit(i)) {
129
+ i += 1;
130
+ continue;
131
+ }
132
+ let j = i;
133
+ while (j < count && hit(j))
134
+ j += 1;
135
+ if (j - i >= breathFrames) {
136
+ let a = i;
137
+ while (a > 0 &&
138
+ at(db, a - 1) > silentBelow &&
139
+ !voiced(a - 1) &&
140
+ at(centroid, a - 1) < breathCentroid)
141
+ a -= 1;
142
+ let b = j;
143
+ while (b < count && at(db, b) > silentBelow && !voiced(b))
144
+ b += 1;
145
+ stretches.push({ start: a * hop, end: (b - 1) * hop + frameLength });
146
+ }
147
+ i = j;
148
+ }
149
+ return stretches;
150
+ }
151
+ // The voice's own level: the median of its voiced frames, those under the noisy crossing rate
152
+ // within 30 dB of its loud frames. Undefined for a piece without a voiced frame.
153
+ function speechLevel(features) {
154
+ if (features.count === 0)
155
+ return undefined;
156
+ const levels = features.db.slice(0, features.count).sort();
157
+ const loud = percentile(levels, 0.95);
158
+ const voiced = [];
159
+ for (let index = 0; index < features.count; index += 1) {
160
+ const db = features.db[index] ?? 0;
161
+ if ((features.crossings[index] ?? 1) < noisyCrossings && db > loud - voicedSpan)
162
+ voiced.push(db);
163
+ }
164
+ if (voiced.length === 0)
165
+ return undefined;
166
+ return percentile(Float32Array.from(voiced).sort(), 0.5);
167
+ }
168
+ // numpy's default: linear between the two nearest ranks of sorted values.
169
+ function percentile(sorted, share) {
170
+ const position = share * (sorted.length - 1);
171
+ const below = Math.floor(position);
172
+ const low = sorted[below] ?? 0;
173
+ const high = sorted[Math.min(sorted.length - 1, below + 1)] ?? low;
174
+ return low + (high - low) * (position - below);
175
+ }
176
+ function levelOf(frame) {
177
+ let sum = 0;
178
+ for (const value of frame)
179
+ sum += value * value;
180
+ return 20 * Math.log10(Math.sqrt(sum / frame.length) + 1e-9);
181
+ }
182
+ // The share of neighbouring samples whose sign differs, a zero counting half.
183
+ function crossingsOf(frame) {
184
+ let sum = 0;
185
+ let previous = Math.sign(frame[0] ?? 0);
186
+ for (let at = 1; at < frame.length; at += 1) {
187
+ const sign = Math.sign(frame[at] ?? 0);
188
+ sum += Math.abs(sign - previous);
189
+ previous = sign;
190
+ }
191
+ return sum / 2 / (frame.length - 1);
192
+ }
193
+ function fftWorkspace() {
194
+ const angle = (index) => (2 * Math.PI * index) / frameLength;
195
+ return {
196
+ re: new Float64Array(frameLength),
197
+ im: new Float64Array(frameLength),
198
+ outRe: new Float64Array(frameLength),
199
+ outIm: new Float64Array(frameLength),
200
+ tempRe: new Float64Array(8),
201
+ tempIm: new Float64Array(8),
202
+ cos: Float64Array.from({ length: frameLength }, (_one, index) => Math.cos(angle(index))),
203
+ sin: Float64Array.from({ length: frameLength }, (_one, index) => Math.sin(angle(index))),
204
+ // numpy's hanning: symmetric, zero at both ends.
205
+ hann: Float64Array.from({ length: frameLength }, (_one, index) => 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / (frameLength - 1))),
206
+ };
207
+ }
208
+ // Where a 20 ms frame's sound sits, in Hz: the power-weighted mean frequency of its
209
+ // Hann-windowed spectrum, 0 for a silent frame.
210
+ export function powerSpectrumCentroid(frame, work = fftWorkspace()) {
211
+ for (let at = 0; at < frameLength; at += 1) {
212
+ work.re[at] = (frame[at] ?? 0) * (work.hann[at] ?? 0);
213
+ work.im[at] = 0;
214
+ }
215
+ transform(work, frameLength, 0, 1, 0);
216
+ let weighted = 0;
217
+ let total = 0;
218
+ for (let bin = 0; bin <= frameLength / 2; bin += 1) {
219
+ const power = (work.outRe[bin] ?? 0) ** 2 + (work.outIm[bin] ?? 0) ** 2;
220
+ weighted += ((bin * breathRate) / frameLength) * power;
221
+ total += power;
222
+ }
223
+ return total > 0 ? weighted / total : 0;
224
+ }
225
+ // A mixed-radix decimation-in-time FFT (960 = 2^6 * 3 * 5) from `work.re/im` into
226
+ // `work.outRe/outIm`: the n-point transform of the samples at `from`, `from + stride`, … is
227
+ // written at `to`.
228
+ function transform(work, n, from, stride, to) {
229
+ if (n === 1) {
230
+ work.outRe[to] = work.re[from] ?? 0;
231
+ work.outIm[to] = work.im[from] ?? 0;
232
+ return;
233
+ }
234
+ const radix = n % 2 === 0 ? 2 : n % 3 === 0 ? 3 : n % 5 === 0 ? 5 : n;
235
+ const m = n / radix;
236
+ for (let r = 0; r < radix; r += 1)
237
+ transform(work, m, from + r * stride, stride * radix, to + r * m);
238
+ const step = frameLength / n;
239
+ for (let k = 0; k < m; k += 1) {
240
+ for (let q = 0; q < radix; q += 1) {
241
+ let sumRe = 0;
242
+ let sumIm = 0;
243
+ for (let r = 0; r < radix; r += 1) {
244
+ const twiddle = ((r * (k + q * m)) % n) * step;
245
+ const c = work.cos[twiddle] ?? 1;
246
+ const s = work.sin[twiddle] ?? 0;
247
+ const yRe = work.outRe[to + r * m + k] ?? 0;
248
+ const yIm = work.outIm[to + r * m + k] ?? 0;
249
+ // y · e^(-iθ)
250
+ sumRe += yRe * c + yIm * s;
251
+ sumIm += yIm * c - yRe * s;
252
+ }
253
+ work.tempRe[q] = sumRe;
254
+ work.tempIm[q] = sumIm;
255
+ }
256
+ for (let q = 0; q < radix; q += 1) {
257
+ work.outRe[to + q * m + k] = work.tempRe[q] ?? 0;
258
+ work.outIm[to + q * m + k] = work.tempIm[q] ?? 0;
259
+ }
260
+ }
261
+ }
@@ -0,0 +1,52 @@
1
+ import { breathRate } from "./breaths-detect.js";
2
+ // Synthetic narration at 48 kHz: a voiced tone stands for a word, band-limited noise for a
3
+ // breath (low band, under 2.8 kHz) or a fricative (high band, an "s" or "f").
4
+ export const ms = (value) => Math.round((value / 1000) * breathRate);
5
+ export function seeded(seed) {
6
+ let state = seed >>> 0;
7
+ return () => {
8
+ state = (state * 1664525 + 1013904223) >>> 0;
9
+ return state / 2 ** 32;
10
+ };
11
+ }
12
+ export function tone(length, rms) {
13
+ const out = new Float64Array(length);
14
+ const amplitude = rms * Math.SQRT2;
15
+ for (let at = 0; at < length; at += 1)
16
+ out[at] = amplitude * Math.sin((2 * Math.PI * 180 * at) / breathRate);
17
+ return out;
18
+ }
19
+ // Noise as many random-phase sines spread evenly over a band, scaled to the RMS asked for.
20
+ export function noise(length, low, high, rms, seed) {
21
+ const random = seeded(seed);
22
+ const count = 48;
23
+ const out = new Float64Array(length);
24
+ for (let one = 0; one < count; one += 1) {
25
+ const frequency = low + ((high - low) * (one + random())) / count;
26
+ const phase = 2 * Math.PI * random();
27
+ for (let at = 0; at < length; at += 1)
28
+ out[at] = (out[at] ?? 0) + Math.sin((2 * Math.PI * frequency * at) / breathRate + phase);
29
+ }
30
+ const power = out.reduce((sum, value) => sum + value * value, 0) / length;
31
+ const scale = rms / Math.sqrt(power);
32
+ return out.map((value) => value * scale);
33
+ }
34
+ export function silence(length) {
35
+ return new Float64Array(length);
36
+ }
37
+ export function pcm(parts, gain = 1) {
38
+ const length = parts.reduce((sum, part) => sum + part.length, 0);
39
+ const out = new Int16Array(length);
40
+ let at = 0;
41
+ for (const part of parts)
42
+ for (const value of part) {
43
+ out[at] = Math.max(-32768, Math.min(32767, Math.round(value * gain * 32767)));
44
+ at += 1;
45
+ }
46
+ return out;
47
+ }
48
+ // The whole clip in chunks of an odd size, so frames straddle the chunk edges.
49
+ export function* chunked(samples, size = 7777) {
50
+ for (let at = 0; at < samples.length; at += size)
51
+ yield samples.subarray(at, at + size);
52
+ }
@@ -0,0 +1,17 @@
1
+ // Remove breaths. Some voices (Inworld's TTS-2 among them) take a loud, audible in-breath after
2
+ // almost every comma and full stop: about a third of a second, only 12 to 15 dB under the
3
+ // words. Switched on, the join finds those breaths in every narration piece and mutes them
4
+ // (`breaths.ts`, `breaths-detect.ts`): nothing is cut out, so every word and its timing stay
5
+ // exactly where they were, and the pauses between sentences (`pauses-model.ts`) are planned
6
+ // from the pieces as spoken just the same. It happens in the join, so the plain narration the
7
+ // word timing reads and the levelled one the video plays lose the same breaths.
8
+ //
9
+ // Browser-safe: Play and Edit project read it. Off is absent from the config, which is what
10
+ // every project saved before it was, and the joins keep their fingerprints then.
11
+ export function breathsOn(config) {
12
+ return config.removeBreaths === true;
13
+ }
14
+ // Play's switch as a run's config holds it: only on is written.
15
+ export function breathsOfForm(form) {
16
+ return form.removeBreaths === true ? { removeBreaths: true } : {};
17
+ }
@@ -0,0 +1,132 @@
1
+ import { mkdirSync, rmSync } from "node:fs";
2
+ import { open, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { runFfmpeg } from "../video/ffmpeg.js";
5
+ import { breathRate, findBreaths, muteChunk } from "./breaths-detect.js";
6
+ // Remove breaths, made in the join (`breaths-model.ts` says why there). Each narration piece is
7
+ // decoded by ffmpeg to 16-bit mono PCM at 48 kHz in a file beside it, read once to find its
8
+ // breaths (`breaths-detect.ts`) and once more to write it again as a WAV with them muted. Both
9
+ // reads go in chunks, so a long piece never sits in memory whole, and the detector yields to
10
+ // the event loop as it goes, so the server stays responsive while it works. A piece without a
11
+ // breath is written again all the same: the join takes every piece in the one format.
12
+ // ceiling: one second of audio per read.
13
+ const chunkSamples = breathRate;
14
+ export async function removeBreaths(run, files, directory) {
15
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
16
+ const written = [];
17
+ let removed = 0;
18
+ for (const [index, file] of files.entries()) {
19
+ run.signal.throwIfAborted();
20
+ const name = `breaths-${String(index + 1).padStart(4, "0")}`;
21
+ const raw = join(directory, `${name}.pcm`);
22
+ const output = join(directory, `${name}.wav`);
23
+ try {
24
+ await runFfmpeg({
25
+ bin: run.bin,
26
+ args: decodeArgs(file, raw),
27
+ signal: run.signal,
28
+ log: run.log,
29
+ onProgress: () => { },
30
+ });
31
+ const stretches = await findBreaths(pcmChunks(raw));
32
+ run.signal.throwIfAborted();
33
+ await writeMuted(raw, output, stretches);
34
+ removed += stretches.length;
35
+ }
36
+ catch (error) {
37
+ if (run.signal.aborted)
38
+ throw error;
39
+ throw new Error(`Remove breaths could not process narration piece ${String(index + 1)} (${error instanceof Error ? error.message : String(error)}), so the narration can't be joined together. Use Try again; if it fails again, turn off Remove breaths in Edit project → Pauses and volume and use Save changes, then use Download diagnostics in Settings and report it.`, { cause: error });
40
+ }
41
+ finally {
42
+ rmSync(raw, { force: true });
43
+ }
44
+ written.push(output);
45
+ }
46
+ return { files: written, removed };
47
+ }
48
+ export function decodeArgs(input, output) {
49
+ return [
50
+ "-hide_banner",
51
+ "-nostdin",
52
+ "-loglevel",
53
+ "error",
54
+ "-nostats",
55
+ "-y",
56
+ "-i",
57
+ input,
58
+ "-map",
59
+ "0:a:0",
60
+ "-ac",
61
+ "1",
62
+ "-ar",
63
+ String(breathRate),
64
+ "-c:a",
65
+ "pcm_s16le",
66
+ "-f",
67
+ "s16le",
68
+ output,
69
+ ];
70
+ }
71
+ async function* pcmChunks(path) {
72
+ const handle = await open(path, "r");
73
+ try {
74
+ for (;;) {
75
+ const chunk = await readChunk(handle);
76
+ if (chunk === undefined)
77
+ return;
78
+ yield chunk;
79
+ }
80
+ }
81
+ finally {
82
+ await handle.close();
83
+ }
84
+ }
85
+ async function readChunk(handle) {
86
+ const buffer = Buffer.alloc(chunkSamples * 2);
87
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
88
+ const samples = Math.floor(bytesRead / 2);
89
+ if (samples === 0)
90
+ return undefined;
91
+ // A copy into its own buffer: Int16Array needs an aligned, even-length view.
92
+ const out = new Int16Array(samples);
93
+ for (let at = 0; at < samples; at += 1)
94
+ out[at] = buffer.readInt16LE(at * 2);
95
+ return out;
96
+ }
97
+ async function writeMuted(raw, output, stretches) {
98
+ const size = (await stat(raw)).size;
99
+ const bytes = size - (size % 2);
100
+ const target = await open(output, "w", 0o600);
101
+ try {
102
+ await target.write(wavHeader(bytes));
103
+ let offset = 0;
104
+ for await (const chunk of pcmChunks(raw)) {
105
+ const muted = muteChunk(chunk, offset, stretches);
106
+ // Typed arrays are little-endian on every platform Slopify ships for, as WAV is.
107
+ await target.write(Buffer.from(muted.buffer, muted.byteOffset, muted.byteLength));
108
+ offset += chunk.length;
109
+ }
110
+ }
111
+ finally {
112
+ await target.close();
113
+ }
114
+ }
115
+ // A canonical 44-byte header: PCM, mono, 16-bit, 48 kHz.
116
+ export function wavHeader(dataBytes) {
117
+ const header = Buffer.alloc(44);
118
+ header.write("RIFF", 0, "ascii");
119
+ header.writeUInt32LE(36 + dataBytes, 4);
120
+ header.write("WAVE", 8, "ascii");
121
+ header.write("fmt ", 12, "ascii");
122
+ header.writeUInt32LE(16, 16);
123
+ header.writeUInt16LE(1, 20);
124
+ header.writeUInt16LE(1, 22);
125
+ header.writeUInt32LE(breathRate, 24);
126
+ header.writeUInt32LE(breathRate * 2, 28);
127
+ header.writeUInt16LE(2, 32);
128
+ header.writeUInt16LE(16, 34);
129
+ header.write("data", 36, "ascii");
130
+ header.writeUInt32LE(dataBytes, 40);
131
+ return header;
132
+ }
@@ -4,6 +4,7 @@ import { runDraftSchema } from "../admission/schema.js";
4
4
  import { draftDocumentThemeOf } from "../document/model.js";
5
5
  import { defaultExpectedWords, everyMinutesMax, everyMinutesMin, expectedWordsMax, imagesPerHourMax, imagesPerHourMin, perHourFromMinutes, wordCount, } from "../images/scale.js";
6
6
  import { defaultLoudness, loudnessOfForm, } from "../loudness/model.js";
7
+ import { breathsOfForm } from "../narration/breaths-model.js";
7
8
  import { pausesOfForm } from "../narration/pauses-model.js";
8
9
  import { reviewSettingsFromForm } from "../reviews/model.js";
9
10
  import { stageMakesItems } from "../reviews/rules.js";
@@ -292,6 +293,8 @@ export function toAdmissionDraft(input) {
292
293
  ...pickLoudness(sources.audio, loudnessOfForm(form.loudness, input.loudness ?? defaultLoudness)),
293
294
  // Pauses between sentences: the default for a new run, with a narration to pace.
294
295
  ...(sources.audio === "generate" ? pausesOfForm(form) : {}),
296
+ // Remove breaths: only while on, with a narration made here to clean.
297
+ ...(sources.audio === "generate" ? breathsOfForm(form) : {}),
295
298
  edgeSilenceSeconds: measure("edgeSilenceSeconds", sources.audio !== "off", defaultEdgeSilenceSeconds, edgeSilenceSecondsProblem),
296
299
  };
297
300
  const parsed = runDraftSchema.safeParse(draft);
@@ -86,6 +86,9 @@ export const playDraftFormSchema = z
86
86
  // paragraph minimum (`narration/pauses-model.ts`).
87
87
  sentencePause: text.optional(),
88
88
  paragraphPause: text.optional(),
89
+ // Remove breaths (`narration/breaths-model.ts`). Absent on drafts and templates saved
90
+ // before it: off.
91
+ removeBreaths: z.boolean().optional(),
89
92
  // Absent on drafts and templates saved before the YouTube description: off, built-in prompt.
90
93
  youtubeDescription: z.boolean().optional(),
91
94
  showFigures: z.boolean().optional(),
@@ -236,6 +236,7 @@ export function documentFromProject(deps, revision) {
236
236
  ...(config.paragraphPauseSeconds === undefined
237
237
  ? {}
238
238
  : { paragraphPause: String(config.paragraphPauseSeconds) }),
239
+ ...(config.removeBreaths === true ? { removeBreaths: true } : {}),
239
240
  imagePrompts,
240
241
  ...(config.imageScale === undefined ? {} : { imageScale: imageScaleForm(config.imageScale) }),
241
242
  ...(config.imageScenes === true ? { imageScenes: true } : {}),
@@ -159,6 +159,7 @@ function inputChanges(parent, view) {
159
159
  add("Show tables and figures on screen", before.showFigures === true ? "On" : "Off", after.showFigures === true ? "On" : "Off");
160
160
  add("Pause between sentences (seconds)", before.sentencePauseSeconds ?? 0, after.sentencePauseSeconds ?? 0);
161
161
  add("Pause between paragraphs (seconds)", before.paragraphPauseSeconds ?? 0, after.paragraphPauseSeconds ?? 0);
162
+ add("Remove breaths", before.removeBreaths === true ? "On" : "Off", after.removeBreaths === true ? "On" : "Off");
162
163
  add("Level the volume", before.loudness === undefined ? "Off" : loudnessSummary(before.loudness), after.loudness === undefined ? "Off" : loudnessSummary(after.loudness));
163
164
  add("YouTube description", before.youtubeDescription === true ? "On" : "Off", after.youtubeDescription === true ? "On" : "Off");
164
165
  add("Description prompt", before.descriptionPrompt, after.descriptionPrompt);
@@ -5,6 +5,7 @@ import { concatArgs } from "../narration/concat.js";
5
5
  import { normalizeNarrationText } from "../narration/plan.js";
6
6
  import { pronunciationChunks } from "../narration/pronunciation-chunks.js";
7
7
  import { aliasFutureValues, narrationParts, pronunciationFutureValues, voiceValues, } from "./recipe-audio-parts.js";
8
+ import { breathValues } from "./recipe-breaths.js";
8
9
  import { levelRecipe } from "./recipe-loudness.js";
9
10
  import { recipe, resourceIdentity, selectedReference, } from "./recipe-model.js";
10
11
  import { narrationFileRecipe } from "./recipe-narration-text.js";
@@ -142,6 +143,7 @@ export function audioRecipes(context, text) {
142
143
  : (config.chunking ?? defaultChunking).mode === "paragraph"
143
144
  ? "paragraph"
144
145
  : "sentence")),
146
+ ...breathValues(config),
145
147
  ],
146
148
  }, parts.map((part) => part.key), { unresolved: text.narrationText !== null && groups.length === 0 });
147
149
  recipes.push(body);
@@ -199,6 +201,7 @@ export function audioRecipes(context, text) {
199
201
  parts.map((part) => resourceIdentity(context, part)),
200
202
  parts.length === 1 ? "copy-single-file" : concatArgs("$parts", "$output"),
201
203
  ...pauseValues(config, parts.map((_part, at) => (at === parts.length - 1 ? "end" : "sentence"))),
204
+ ...breathValues(config),
202
205
  ],
203
206
  }, parts.map((part) => part.key));
204
207
  recipes.push(audio);
@@ -0,0 +1,16 @@
1
+ import { breathsOn } from "../narration/breaths-model.js";
2
+ // Remove breaths (`narration/breaths-model.ts`) is made in the narration joins, so a join carries
3
+ // it in its values, after the pauses when they are set. Nothing at all while it is off, so
4
+ // every join made before it keeps its fingerprint. The levelled join copies these values, so it
5
+ // removes the same breaths.
6
+ export const breathsValue = "breaths-v1";
7
+ export function breathValues(config) {
8
+ return breathsOn(config) ? [[breathsValue]] : [];
9
+ }
10
+ // Whether a join's values ask for it: any value after the join's own two.
11
+ export function breathsAsked(values) {
12
+ return (Array.isArray(values) &&
13
+ values
14
+ .slice(2)
15
+ .some((value) => Array.isArray(value) && value.length === 1 && value[0] === breathsValue));
16
+ }
@@ -9,6 +9,7 @@ import { prepareRequests } from "../narration/steering.js";
9
9
  import { groupTurns } from "../voices/grouping.js";
10
10
  import { speakerPace } from "../voices/model.js";
11
11
  import { parseScript } from "../voices/script.js";
12
+ import { breathValues } from "./recipe-breaths.js";
12
13
  import { describedText, describeFuture, describing } from "./recipe-describe.js";
13
14
  import { recipe, resourceIdentity, } from "./recipe-model.js";
14
15
  import { pauseValues } from "./recipe-pauses.js";
@@ -125,6 +126,7 @@ export function voiceBodyRecipes(context, voices, script, glossary) {
125
126
  layout,
126
127
  // Pauses between sentences inside a turn; the turn gap stays the layout's.
127
128
  ...pauseValues(context.config, layout.map(([, gap], at) => at === layout.length - 1 ? "end" : gap > 0 || turnEnds.has(at) ? "turn" : "sentence")),
129
+ ...breathValues(context.config),
128
130
  ],
129
131
  }, parts.map((part) => part.key), { unresolved: parsed?.ok !== true || pending });
130
132
  return {
@@ -5,12 +5,14 @@ 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 { removeBreaths } from "../narration/breaths.js";
8
9
  import { joinNarration } from "../narration/concat.js";
9
10
  import { applyPauses, planPieces } from "../narration/pauses.js";
10
11
  import { allocateAsset, discardPreparedAssets, sealAsset } from "../storage/assets.js";
11
12
  import { outputPath } from "../storage/layout.js";
12
13
  import { probeDurationMs, runFfmpeg } from "../video/ffmpeg.js";
13
14
  import { turnJoinArgs } from "../voices/join.js";
15
+ import { breathsAsked } from "./recipe-breaths.js";
14
16
  import { levelOperation } from "./recipe-loudness.js";
15
17
  import { executeFigureCard } from "./runtime-figure-card.js";
16
18
  import { publishNarrationText } from "./runtime-narration-text.js";
@@ -134,16 +136,27 @@ async function concatenate(deps, context, piece) {
134
136
  try {
135
137
  const run = { bin: deps.ffmpeg, log: deps.log, signal: context.signal };
136
138
  const levelled = level ? await levelPieces(run, files, piecesDirectory) : undefined;
139
+ // Remove breaths: muted in what is joined, before the pauses; nothing moves, so the pauses
140
+ // are still planned from the pieces as spoken.
141
+ const breathless = breathsAsked(values)
142
+ ? await removeBreaths(run, levelled?.files ?? files, join(piecesDirectory, "breaths"))
143
+ : undefined;
144
+ if (breathless !== undefined)
145
+ deps.log.write("info", "narration.breaths", {
146
+ projectId: context.work.projectId,
147
+ stage: "audio",
148
+ detail: `${String(breathless.removed)} breaths muted in ${String(files.length)} pieces`,
149
+ });
137
150
  // Pauses between sentences: planned from the pieces as spoken, made in what is joined.
138
151
  const pauses = pausesIn(values);
139
152
  const paced = pauses === undefined
140
153
  ? undefined
141
- : await applyPauses(run, levelled?.files ?? files, paceScaled(await planPieces(run, files.map((file, at) => ({
154
+ : await applyPauses(run, breathless?.files ?? levelled?.files ?? files, paceScaled(await planPieces(run, files.map((file, at) => ({
142
155
  file,
143
156
  text: pieceText(plan.recipes.find((one) => one.key === recipe.dependsOn[at])),
144
157
  next: pauses.next[at] ?? "end",
145
158
  })), pauses.settings), operation === "concat-turns-v1" ? values : undefined), join(piecesDirectory, "paced"));
146
- const joined = paced?.files ?? levelled?.files ?? files;
159
+ const joined = paced?.files ?? breathless?.files ?? levelled?.files ?? files;
147
160
  const durationMs = operation === "concat-turns-v1"
148
161
  ? await joinTurns(run, joined, values, pending.absolutePath)
149
162
  : await joinNarration({ bin: deps.ffmpeg, log: deps.log }, {
@@ -151,7 +164,7 @@ async function concatenate(deps, context, piece) {
151
164
  output: pending.absolutePath,
152
165
  listPath: join(dirname(pending.absolutePath), "parts.txt"),
153
166
  signal: context.signal,
154
- reencode: level || paced !== undefined,
167
+ reencode: level || paced !== undefined || breathless !== undefined,
155
168
  });
156
169
  rmSync(piecesDirectory, { recursive: true, force: true });
157
170
  context.signal.throwIfAborted();