@gentbajko/slopify 3.19.0 → 3.21.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.
Files changed (39) hide show
  1. package/SUBTITLES.md +10 -2
  2. package/dist/adapters/alignment/cache.js +36 -9
  3. package/dist/adapters/alignment/index.js +16 -4
  4. package/dist/adapters/alignment/prefetch.js +11 -6
  5. package/dist/edge/http/settings.js +6 -1
  6. package/dist/edge/mcp/tools-setup.js +2 -2
  7. package/dist/extension/slopify-studio-chrome.zip +0 -0
  8. package/dist/extension/slopify-studio-firefox.zip +0 -0
  9. package/dist/kernel/ports/subtitles.js +6 -0
  10. package/dist/main.js +6 -2
  11. package/dist/patch-notes/3.20.0.md +10 -0
  12. package/dist/patch-notes/3.21.0.md +11 -0
  13. package/dist/patch-notes/index.json +12 -0
  14. package/dist/slices/admission/schema.js +2 -1
  15. package/dist/slices/narration/breaths-detect.js +51 -21
  16. package/dist/slices/narration/breaths-model.js +21 -8
  17. package/dist/slices/narration/breaths.js +15 -13
  18. package/dist/slices/narration/clean-pieces.js +69 -0
  19. package/dist/slices/play-drafts/convert.js +3 -2
  20. package/dist/slices/play-drafts/schema.js +3 -2
  21. package/dist/slices/project-templates/from-project.js +1 -0
  22. package/dist/slices/rebuild/preview-details.js +2 -1
  23. package/dist/slices/rebuild/recipe-breaths.js +21 -11
  24. package/dist/slices/rebuild/runtime-local.js +13 -12
  25. package/dist/slices/settings/model.js +2 -0
  26. package/dist/slices/settings/playback.js +11 -0
  27. package/dist/tutorials/Editing-a-Project.md +3 -2
  28. package/dist/tutorials/FAQ.md +1 -1
  29. package/dist/tutorials/Install.md +3 -3
  30. package/dist/tutorials/Other-Languages.md +1 -1
  31. package/dist/tutorials/Play-Video-and-Style.md +1 -1
  32. package/dist/tutorials/Settings-Reference.md +9 -1
  33. package/dist/tutorials/Troubleshooting.md +1 -1
  34. package/dist/tutorials/Video-Editing.md +1 -1
  35. package/dist/web/assets/index-wwRT6LH3.js +326 -0
  36. package/dist/web/assets/{pdf-Dxrlh3NL.js → pdf-DH2f4NNx.js} +1 -1
  37. package/dist/web/index.html +1 -1
  38. package/package.json +1 -1
  39. package/dist/web/assets/index-OxnO0Bfh.js +0 -326
@@ -2,19 +2,20 @@ import { mkdirSync, rmSync } from "node:fs";
2
2
  import { open, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
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
5
+ import { breathRate, scanBreaths, softenChunk } from "./breaths-detect.js";
6
+ // Soften breaths, made in the join (`breaths-model.ts` says why there). Each narration piece is
7
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
8
+ // breaths (`breaths-detect.ts`) and once more to write it again as a WAV with them turned down. Both
9
9
  // reads go in chunks, so a long piece never sits in memory whole, and the detector yields to
10
10
  // the event loop as it goes, so the server stays responsive while it works. A piece without a
11
11
  // breath is written again all the same: the join takes every piece in the one format.
12
12
  // ceiling: one second of audio per read.
13
13
  const chunkSamples = breathRate;
14
- export async function removeBreaths(run, files, directory) {
14
+ export async function softenBreaths(run, files, directory) {
15
15
  mkdirSync(directory, { recursive: true, mode: 0o700 });
16
16
  const written = [];
17
- let removed = 0;
17
+ let softened = 0;
18
+ let skipped = 0;
18
19
  for (const [index, file] of files.entries()) {
19
20
  run.signal.throwIfAborted();
20
21
  const name = `breaths-${String(index + 1).padStart(4, "0")}`;
@@ -28,22 +29,23 @@ export async function removeBreaths(run, files, directory) {
28
29
  log: run.log,
29
30
  onProgress: () => { },
30
31
  });
31
- const stretches = await findBreaths(pcmChunks(raw));
32
+ const scan = await scanBreaths(pcmChunks(raw));
32
33
  run.signal.throwIfAborted();
33
- await writeMuted(raw, output, stretches);
34
- removed += stretches.length;
34
+ await writeSoftened(raw, output, scan.stretches);
35
+ softened += scan.stretches.length;
36
+ skipped += scan.skipped.length;
35
37
  }
36
38
  catch (error) {
37
39
  if (run.signal.aborted)
38
40
  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 });
41
+ throw new Error(`Soften 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 Soften breaths in Edit project → Pauses and volume and use Save changes, then use Download diagnostics in Settings and report it.`, { cause: error });
40
42
  }
41
43
  finally {
42
44
  rmSync(raw, { force: true });
43
45
  }
44
46
  written.push(output);
45
47
  }
46
- return { files: written, removed };
48
+ return { files: written, softened, skipped };
47
49
  }
48
50
  export function decodeArgs(input, output) {
49
51
  return [
@@ -94,7 +96,7 @@ async function readChunk(handle) {
94
96
  out[at] = buffer.readInt16LE(at * 2);
95
97
  return out;
96
98
  }
97
- async function writeMuted(raw, output, stretches) {
99
+ async function writeSoftened(raw, output, stretches) {
98
100
  const size = (await stat(raw)).size;
99
101
  const bytes = size - (size % 2);
100
102
  const target = await open(output, "w", 0o600);
@@ -102,9 +104,9 @@ async function writeMuted(raw, output, stretches) {
102
104
  await target.write(wavHeader(bytes));
103
105
  let offset = 0;
104
106
  for await (const chunk of pcmChunks(raw)) {
105
- const muted = muteChunk(chunk, offset, stretches);
107
+ const soft = softenChunk(chunk, offset, stretches);
106
108
  // 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));
109
+ await target.write(Buffer.from(soft.buffer, soft.byteOffset, soft.byteLength));
108
110
  offset += chunk.length;
109
111
  }
110
112
  }
@@ -0,0 +1,69 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { runFfmpeg } from "../video/ffmpeg.js";
4
+ import { softenBreaths } from "./breaths.js";
5
+ import { deEsser } from "./breaths-model.js";
6
+ export async function cleanPieces(run, files, directory, cleaning) {
7
+ if (!cleaning.breaths && !cleaning.deEss)
8
+ return undefined;
9
+ const breaths = cleaning.breaths
10
+ ? await softenBreaths(run, files, join(directory, "breaths"))
11
+ : undefined;
12
+ const soft = breaths?.files ?? files;
13
+ const cleaned = cleaning.deEss ? await deEssPieces(run, soft, join(directory, "deess")) : soft;
14
+ return {
15
+ files: cleaned,
16
+ ...(breaths === undefined
17
+ ? {}
18
+ : { breaths: { softened: breaths.softened, skipped: breaths.skipped } }),
19
+ };
20
+ }
21
+ export async function deEssPieces(run, files, directory) {
22
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
23
+ const written = [];
24
+ for (const [index, file] of files.entries()) {
25
+ run.signal.throwIfAborted();
26
+ const output = join(directory, `deess-${String(index + 1).padStart(4, "0")}.wav`);
27
+ try {
28
+ await runFfmpeg({
29
+ bin: run.bin,
30
+ args: deEssArgs(file, output),
31
+ signal: run.signal,
32
+ log: run.log,
33
+ onProgress: () => { },
34
+ });
35
+ }
36
+ catch (error) {
37
+ if (run.signal.aborted)
38
+ throw error;
39
+ throw new Error(`Soften S sounds 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 Soften S sounds in Edit project → Pauses and volume and use Save changes, then use Download diagnostics in Settings and report it.`, { cause: error });
40
+ }
41
+ written.push(output);
42
+ }
43
+ return written;
44
+ }
45
+ export function deEssFilter() {
46
+ return `deesser=i=${String(deEsser.intensity)}:m=${String(deEsser.max)}:f=${String(deEsser.frequency)}`;
47
+ }
48
+ // The piece's own rate and channels, written as 16-bit WAV like the other cleaned pieces.
49
+ export function deEssArgs(input, output) {
50
+ return [
51
+ "-hide_banner",
52
+ "-nostdin",
53
+ "-loglevel",
54
+ "error",
55
+ "-nostats",
56
+ "-y",
57
+ "-i",
58
+ input,
59
+ "-map",
60
+ "0:a:0",
61
+ "-af",
62
+ deEssFilter(),
63
+ "-c:a",
64
+ "pcm_s16le",
65
+ "-f",
66
+ "wav",
67
+ output,
68
+ ];
69
+ }
@@ -4,7 +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
+ import { breathsOfForm, deEssOfForm } from "../narration/breaths-model.js";
8
8
  import { pausesOfForm } from "../narration/pauses-model.js";
9
9
  import { reviewSettingsFromForm } from "../reviews/model.js";
10
10
  import { stageMakesItems } from "../reviews/rules.js";
@@ -293,8 +293,9 @@ export function toAdmissionDraft(input) {
293
293
  ...pickLoudness(sources.audio, loudnessOfForm(form.loudness, input.loudness ?? defaultLoudness)),
294
294
  // Pauses between sentences: the default for a new run, with a narration to pace.
295
295
  ...(sources.audio === "generate" ? pausesOfForm(form) : {}),
296
- // Remove breaths: only while on, with a narration made here to clean.
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
+ ...(sources.audio === "generate" ? deEssOfForm(form) : {}),
298
299
  edgeSilenceSeconds: measure("edgeSilenceSeconds", sources.audio !== "off", defaultEdgeSilenceSeconds, edgeSilenceSecondsProblem),
299
300
  };
300
301
  const parsed = runDraftSchema.safeParse(draft);
@@ -86,9 +86,10 @@ 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.
89
+ // Soften breaths and Soften S sounds (`narration/breaths-model.ts`). Absent on drafts and
90
+ // templates saved before them: off.
91
91
  removeBreaths: z.boolean().optional(),
92
+ deEss: z.boolean().optional(),
92
93
  // Absent on drafts and templates saved before the YouTube description: off, built-in prompt.
93
94
  youtubeDescription: z.boolean().optional(),
94
95
  showFigures: z.boolean().optional(),
@@ -237,6 +237,7 @@ export function documentFromProject(deps, revision) {
237
237
  ? {}
238
238
  : { paragraphPause: String(config.paragraphPauseSeconds) }),
239
239
  ...(config.removeBreaths === true ? { removeBreaths: true } : {}),
240
+ ...(config.deEss === true ? { deEss: true } : {}),
240
241
  imagePrompts,
241
242
  ...(config.imageScale === undefined ? {} : { imageScale: imageScaleForm(config.imageScale) }),
242
243
  ...(config.imageScenes === true ? { imageScenes: true } : {}),
@@ -159,7 +159,8 @@ 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
+ add("Soften breaths", before.removeBreaths === true ? "On" : "Off", after.removeBreaths === true ? "On" : "Off");
163
+ add("Soften S sounds", before.deEss === true ? "On" : "Off", after.deEss === true ? "On" : "Off");
163
164
  add("Level the volume", before.loudness === undefined ? "Off" : loudnessSummary(before.loudness), after.loudness === undefined ? "Off" : loudnessSummary(after.loudness));
164
165
  add("YouTube description", before.youtubeDescription === true ? "On" : "Off", after.youtubeDescription === true ? "On" : "Off");
165
166
  add("Description prompt", before.descriptionPrompt, after.descriptionPrompt);
@@ -1,16 +1,26 @@
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";
1
+ import { breathsOn, deEsser, deEssOn } from "../narration/breaths-model.js";
2
+ // Soften breaths and Soften S sounds (`narration/breaths-model.ts`) are made in the narration
3
+ // joins, so a join carries them in its values, after the pauses when they are set. Nothing at
4
+ // all while they are off, so every join made before them keeps its fingerprint. The levelled
5
+ // join copies these values, so it softens the same.
6
+ // v2: 3.19.0's v1 muted breaths to silence and could clip word edges; its joins are remade.
7
+ export const breathsValue = "breaths-v2";
8
+ export const deEssValue = "deess-v1";
7
9
  export function breathValues(config) {
8
- return breathsOn(config) ? [[breathsValue]] : [];
10
+ return [
11
+ ...(breathsOn(config) ? [[breathsValue]] : []),
12
+ ...(deEssOn(config) ? [[deEssValue, deEsser.intensity, deEsser.max, deEsser.frequency]] : []),
13
+ ];
9
14
  }
10
15
  // Whether a join's values ask for it: any value after the join's own two.
11
16
  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));
17
+ return laterValues(values).some((value) => value.length === 1 && value[0] === breathsValue);
18
+ }
19
+ export function deEssAsked(values) {
20
+ return laterValues(values).some((value) => value[0] === deEssValue);
21
+ }
22
+ function laterValues(values) {
23
+ return Array.isArray(values)
24
+ ? values.slice(2).filter((value) => Array.isArray(value))
25
+ : [];
16
26
  }
@@ -5,14 +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
+ import { cleanPieces } from "../narration/clean-pieces.js";
9
9
  import { joinNarration } from "../narration/concat.js";
10
10
  import { applyPauses, planPieces } from "../narration/pauses.js";
11
11
  import { allocateAsset, discardPreparedAssets, sealAsset } from "../storage/assets.js";
12
12
  import { outputPath } from "../storage/layout.js";
13
13
  import { probeDurationMs, runFfmpeg } from "../video/ffmpeg.js";
14
14
  import { turnJoinArgs } from "../voices/join.js";
15
- import { breathsAsked } from "./recipe-breaths.js";
15
+ import { breathsAsked, deEssAsked } from "./recipe-breaths.js";
16
16
  import { levelOperation } from "./recipe-loudness.js";
17
17
  import { executeFigureCard } from "./runtime-figure-card.js";
18
18
  import { publishNarrationText } from "./runtime-narration-text.js";
@@ -136,27 +136,28 @@ async function concatenate(deps, context, piece) {
136
136
  try {
137
137
  const run = { bin: deps.ffmpeg, log: deps.log, signal: context.signal };
138
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)
139
+ // Soften breaths, then Soften S sounds: made in what is joined, before the pauses; nothing
140
+ // moves, so the pauses are still planned from the pieces as spoken.
141
+ const cleaned = await cleanPieces(run, levelled?.files ?? files, piecesDirectory, {
142
+ breaths: breathsAsked(values),
143
+ deEss: deEssAsked(values),
144
+ });
145
+ if (cleaned?.breaths !== undefined)
145
146
  deps.log.write("info", "narration.breaths", {
146
147
  projectId: context.work.projectId,
147
148
  stage: "audio",
148
- detail: `${String(breathless.removed)} breaths muted in ${String(files.length)} pieces`,
149
+ detail: `${String(cleaned.breaths.softened)} breaths softened, ${String(cleaned.breaths.skipped)} left alone (a word sound in them) in ${String(files.length)} pieces`,
149
150
  });
150
151
  // Pauses between sentences: planned from the pieces as spoken, made in what is joined.
151
152
  const pauses = pausesIn(values);
152
153
  const paced = pauses === undefined
153
154
  ? undefined
154
- : await applyPauses(run, breathless?.files ?? levelled?.files ?? files, paceScaled(await planPieces(run, files.map((file, at) => ({
155
+ : await applyPauses(run, cleaned?.files ?? levelled?.files ?? files, paceScaled(await planPieces(run, files.map((file, at) => ({
155
156
  file,
156
157
  text: pieceText(plan.recipes.find((one) => one.key === recipe.dependsOn[at])),
157
158
  next: pauses.next[at] ?? "end",
158
159
  })), pauses.settings), operation === "concat-turns-v1" ? values : undefined), join(piecesDirectory, "paced"));
159
- const joined = paced?.files ?? breathless?.files ?? levelled?.files ?? files;
160
+ const joined = paced?.files ?? cleaned?.files ?? levelled?.files ?? files;
160
161
  const durationMs = operation === "concat-turns-v1"
161
162
  ? await joinTurns(run, joined, values, pending.absolutePath)
162
163
  : await joinNarration({ bin: deps.ffmpeg, log: deps.log }, {
@@ -164,7 +165,7 @@ async function concatenate(deps, context, piece) {
164
165
  output: pending.absolutePath,
165
166
  listPath: join(dirname(pending.absolutePath), "parts.txt"),
166
167
  signal: context.signal,
167
- reencode: level || paced !== undefined || breathless !== undefined,
168
+ reencode: level || paced !== undefined || cleaned !== undefined,
168
169
  });
169
170
  rmSync(piecesDirectory, { recursive: true, force: true });
170
171
  context.signal.throwIfAborted();
@@ -1,4 +1,5 @@
1
1
  // The provider catalogue and the settings domain types.
2
+ import { defaultEnglishSubtitleModel, } from "../../kernel/ports/subtitles.js";
2
3
  import { defaultLoudness } from "../loudness/model.js";
3
4
  // The three families are the three ports, so the set is named beside them.
4
5
  export { providerFamilies } from "../../kernel/ports/model.js";
@@ -135,4 +136,5 @@ export const defaultSettings = {
135
136
  silenceGapSeconds: 3,
136
137
  appearance: "system",
137
138
  loudness: defaultLoudness,
139
+ subtitleModel: defaultEnglishSubtitleModel,
138
140
  };
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { englishSubtitleModels } from "../../kernel/ports/subtitles.js";
2
3
  import { silenceGapSecondsMax } from "../admission/rules.js";
3
4
  import { loudnessFields } from "../loudness/model.js";
4
5
  import { appearances, defaultSettings } from "./model.js";
@@ -8,8 +9,10 @@ import { readSetting, writeSetting } from "./repo.js";
8
9
  const silenceGapKey = "silenceGapSeconds";
9
10
  const appearanceKey = "appearance";
10
11
  const loudnessKey = "loudness";
12
+ const subtitleModelKey = "subtitleModel";
11
13
  const silenceGapValue = z.number().int().min(0).max(silenceGapSecondsMax);
12
14
  const appearanceValue = z.enum(appearances);
15
+ const subtitleModelValue = z.enum(englishSubtitleModels);
13
16
  const loudnessValue = z.object({
14
17
  enabled: z.boolean(),
15
18
  videoLufs: z.number(),
@@ -20,6 +23,7 @@ export function readSettings(deps) {
20
23
  silenceGapSeconds: parsed(deps, silenceGapKey, silenceGapValue, defaultSettings.silenceGapSeconds),
21
24
  appearance: parsed(deps, appearanceKey, appearanceValue, defaultSettings.appearance),
22
25
  loudness: parsed(deps, loudnessKey, loudnessValue, defaultSettings.loudness),
26
+ subtitleModel: parsed(deps, subtitleModelKey, subtitleModelValue, defaultSettings.subtitleModel),
23
27
  };
24
28
  }
25
29
  export function saveSettings(deps, settings) {
@@ -39,12 +43,19 @@ export function saveSettings(deps, settings) {
39
43
  else {
40
44
  fields.push(...loudnessFields(settings.loudness));
41
45
  }
46
+ if (!subtitleModelValue.safeParse(settings.subtitleModel).success) {
47
+ fields.push({
48
+ field: "subtitleModel",
49
+ message: "Choose Most accurate, Balanced or Smallest in Settings → Production defaults → Subtitle timing model.",
50
+ });
51
+ }
42
52
  if (fields.length > 0) {
43
53
  return { ok: false, fields };
44
54
  }
45
55
  writeSetting(deps.db, silenceGapKey, JSON.stringify(settings.silenceGapSeconds));
46
56
  writeSetting(deps.db, appearanceKey, JSON.stringify(settings.appearance));
47
57
  writeSetting(deps.db, loudnessKey, JSON.stringify(settings.loudness));
58
+ writeSetting(deps.db, subtitleModelKey, JSON.stringify(settings.subtitleModel));
48
59
  return { ok: true, settings };
49
60
  }
50
61
  // A stored value is JSON, and a hand-edited database is the only way it can be anything
@@ -134,7 +134,8 @@ See [Narration Aliases and Glossary](Narration-Aliases-and-Glossary).
134
134
  | --- | --- | --- |
135
135
  | **Pause between sentences (seconds)** | The least quiet after each sentence. Adds silence where the pause is shorter; never shortens one or cuts a word. About 0.25 s is brisk, 0.35 to 0.45 natural, 0.5 to 0.8 an audiobook's pace, 0.8 and up sleep content. 0 keeps the voice's own. | 0.4; 0 to 2 in steps of 0.05 |
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
- | **Remove breaths** | Mutes the audible in-breaths a voice takes between sentences and after commas (Inworld's TTS-2 takes a loud one almost every time). Only the breath goes quiet: words, pauses and captions stay where they were. | Off |
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
+ | **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 |
138
139
  | **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**) |
139
140
  | **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 |
140
141
  | **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 |
@@ -143,7 +144,7 @@ You can type a volume in dB or as a percentage of the recommended level.
143
144
 
144
145
  What changing them remakes:
145
146
 
146
- - **Pauses or Remove breaths**: 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.
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.
147
148
  - **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.
148
149
  - **A volume only**: the exports are mastered again, nothing else.
149
150
 
@@ -48,7 +48,7 @@ What you write is sent only to the AI providers you pick, because they make the
48
48
 
49
49
  ### Does it work offline?
50
50
 
51
- Partly. The AI steps (article, research, narration, images, thumbnails, descriptions) call providers over the internet, or CLIs that do. Rendering the video, levelling the volume, captions, the style preview and the System voice run on your computer. Captions need a speech model of about 95 MB that is downloaded once, on first use. Patch notes ship with the app and open offline.
51
+ Partly. The AI steps (article, research, narration, images, thumbnails, descriptions) call providers over the internet, or CLIs that do. Rendering the video, levelling the volume, captions, the style preview and the System voice run on your computer. Captions need a speech model of about 378 MB (smaller ones can be chosen in Settings → Production defaults) that is downloaded once, on first use. Patch notes ship with the app and open offline.
52
52
 
53
53
  ## Making and editing videos
54
54
 
@@ -11,7 +11,7 @@ Slopify is a local web app you start from a terminal. It runs on your own comput
11
11
  | Node.js | Version 26 or newer, with npm (it comes with Node). Get it from nodejs.org. |
12
12
  | Operating system | Windows, macOS or Linux for the normal install. The managed Docker install is Linux only (see [Docker](Docker)). |
13
13
  | ffmpeg | Nothing to install: Slopify brings its own copy. See [ffmpeg](#ffmpeg) below. |
14
- | Disk space | Enough for your videos. Every project keeps its video, images, narration and working files. The caption model is a 95 MB download. |
14
+ | Disk space | Enough for your videos. Every project keeps its video, images, narration and working files. The caption model is a 378 MB download (95 MB if you pick Smallest in Settings → Production defaults). |
15
15
  | Keys or CLIs | Text and images can come from Claude Code, Codex or Gemini CLI, or from API keys. Narration can use a voice provider key, or your computer's own speech for free (the System voice; on Linux, install `espeak-ng`). See [Providers and Keys](Providers-and-Keys) and [AI CLIs](AI-CLIs). |
16
16
 
17
17
  ## Choose how to run it
@@ -71,7 +71,7 @@ A flag beats the environment variable, and the variable beats the default.
71
71
  | `--no-open` | `SLOPIFY_NO_OPEN` | opens a browser | Doesn't open a browser tab. For the variable, any value except empty, `0` or `false` counts as on. |
72
72
  | `--autostart` / `--no-autostart` | | asks once | Turns [Start at Login](Start-at-Login) on or off without asking. Useful in scripts. |
73
73
  | | `SLOPIFY_FFMPEG` | the bundled ffmpeg | The full path of another ffmpeg to use. |
74
- | | `SLOPIFY_NO_MODEL_PREFETCH` | downloads at start | Set to `1` to skip fetching the 95 MB caption model when Slopify starts. It is then fetched the first time a video needs captions. |
74
+ | | `SLOPIFY_NO_MODEL_PREFETCH` | downloads at start | Set to `1` to skip fetching the caption model (the one chosen in Settings → Production defaults, 378 MB by default) when Slopify starts. It is then fetched the first time a video needs captions. |
75
75
 
76
76
  Options that only work with `--docker`: `--projects-dir <folder>`, `--host-cli=off` and `--accept-host-cli`. See [Docker](Docker). With `--docker`, `--host` and `--data-dir` are refused, because the Docker version always listens on localhost and keeps its data in a Docker volume.
77
77
 
@@ -112,7 +112,7 @@ The bundled ffmpeg is a separate program under the GPL-3.0-or-later licence. Slo
112
112
 
113
113
  ## The caption model
114
114
 
115
- Captions are timed against the narration by a speech model that runs on your computer, at no cost. The English model (95 MB) is downloaded when Slopify starts, in the background, so a run never waits for it later. It is kept in `<data folder>/models/`. Set `SLOPIFY_NO_MODEL_PREFETCH=1` to skip the download at start. Other languages use a separate model that is downloaded the first time a project in that language needs it.
115
+ Captions are timed against the narration by a speech model that runs on your computer, at no cost. The English model is downloaded when Slopify starts, in the background, so a run never waits for it later: the one chosen in Settings → Production defaults → **Subtitle timing model**, Most accurate (378 MB) unless changed. The Docker image already carries it. It is kept in `<data folder>/models/`. Set `SLOPIFY_NO_MODEL_PREFETCH=1` to skip the download at start. Other languages use a separate model that is downloaded the first time a project in that language needs it.
116
116
 
117
117
  ## Anonymous usage stats
118
118
 
@@ -32,7 +32,7 @@ This applies with the brand kit off too, because language is not styling. Projec
32
32
  | | English | Spanish, German, French, Italian, Portuguese, Dutch, Catalan, Polish, Czech | All other languages |
33
33
  |---|---|---|---|
34
34
  | Article, narration preparation, YouTube description, chapters, tags, shorts titles and hashtags, multi-voice scripts | English | Written in the language | Written in the language |
35
- | Caption timing | Word by word, English model (95 MB) | Word by word, multilingual model (248 MB, downloaded once, on first use) | Sentence by sentence, no model |
35
+ | Caption timing | Word by word, English model (378 MB; 189 MB or 95 MB in Settings → Production defaults) | Word by word, multilingual model (248 MB, downloaded once, on first use) | Sentence by sentence, no model |
36
36
  | Word-by-word Shorts captions | On | On | Off: shorts show whole caption groups |
37
37
  | Cuts that follow the narration | Available | Available | Off: cuts fall every N seconds |
38
38
  | Pronunciation Glossary | Standard-English IPA | Full IPA (ç, ʁ, ø, y, ɲ, x and so on) | Full IPA |
@@ -84,7 +84,7 @@ Below the ambient sound are **Pause between sentences**, **Pause between paragra
84
84
 
85
85
  ## Captions
86
86
 
87
- Captions are timed from your narration on this computer, with no paid API. The first use downloads a speech model of about 95 MB. They need narration: with narration Off, the section says "Turn Audio on to add subtitles."
87
+ Captions are timed from your narration on this computer, with no paid API. The first use downloads a speech model of about 378 MB (Settings → Production defaults → **Subtitle timing model** offers smaller ones). They need narration: with narration Off, the section says "Turn Audio on to add subtitles."
88
88
 
89
89
  | Option | What it does | Default / range |
90
90
  | --- | --- | --- |
@@ -116,7 +116,7 @@ There is nothing to turn on: the address works whenever Slopify runs. See [Agent
116
116
 
117
117
  ## Production defaults
118
118
 
119
- "How new runs pace the narration and level the volume."
119
+ "How new runs pace the narration and level the volume, and which model times English captions."
120
120
 
121
121
  ### Silence between segments
122
122
 
@@ -136,6 +136,14 @@ Videos already started keep their own gap. A value that is not a whole number fr
136
136
 
137
137
  The volume fields show only while the switch is on. A project keeps what it started with; change it there in **Edit project**. Levelling runs on this computer and makes no API calls. Changes save as you make them.
138
138
 
139
+ ### Subtitle timing model
140
+
141
+ | Control | What it does | Default / range |
142
+ |---|---|---|
143
+ | **Subtitle timing model** | The speech model that times English captions on this computer. The same model in three sizes: **Most accurate (378 MB, recommended)**, **Balanced (189 MB)** and **Smallest (95 MB, less accurate)**. Bigger hears the narration more accurately and, on most computers, also runs faster. | Most accurate |
144
+
145
+ The chosen model downloads once, the first time captions are timed (or when Slopify starts, unless `SLOPIFY_NO_MODEL_PREFETCH` is set), and is kept in `<data folder>/models/english-subtitles/`. A model already downloaded stays there when you switch, so switching back costs nothing. Smallest can mishear a line and stop a run with "Subtitles stopped matching the audio" where the narration is fine. Changing the model never marks finished subtitles outdated, and a timing that is already running finishes with the model it started with; the next one uses the new choice. It is a setting of this computer, not of a project. Other languages use their own model and are not affected. Changes save as you make them.
146
+
139
147
  ## Notifications
140
148
 
141
149
  "When a run finishes, fails, or waits for you."
@@ -67,7 +67,7 @@ Slopify ships its own ffmpeg; you do not need to install one. In Docker it is in
67
67
  | "The audio/video export failed (ffmpeg exited with code ...)" | Open **Error details**: it quotes what ffmpeg could not do, for example an input file it could not open. Replace that file (an uploaded image or clip in **Edit project → Images**), or try again. |
68
68
  | "Slopify couldn't measure the loudness of the narration" | Try again. If it happens again, turn off **Level the volume** in **Edit project → Inputs → Pauses and volume**, then download diagnostics and report it. |
69
69
  | "The style preview couldn't render ... Press Render again." | Press **Render again**. If it fails again, download diagnostics and report it. |
70
- | "The free subtitle model could not be downloaded" / "download was interrupted" / "failed download verification" | The 95 MB caption model downloads on first use. Check your connection and try again. `SLOPIFY_NO_MODEL_PREFETCH` only skips the download at start; captions still need it. |
70
+ | "The free subtitle model could not be downloaded" / "download was interrupted" / "failed download verification" | The caption model (378 MB by default, the size chosen in Settings → Production defaults → **Subtitle timing model**) downloads on first use. Check your connection, then **Continue** the run, or pick a smaller model there. `SLOPIFY_NO_MODEL_PREFETCH` only skips the download at start; captions still need it. |
71
71
  | "Captions can't be edited until the current narration has finished and its subtitle timing is ready." | Let the narration and subtitle timing finish, then edit captions. |
72
72
  | "The saved subtitle timing doesn't match the current narration." | Do what the message says: make the subtitle timing again (More → make it again), then edit the captions. |
73
73
  | "Subtitles recovered after missing narration (N)" under the video | Some transcript passages could not be matched to the audio and were left out of the captions. The audio is fine. Review those passages before sharing. |
@@ -90,7 +90,7 @@ With **Level the volume** on, the bed is mixed and ducked first, then the whole
90
90
  | **Subtitle font size** | How big burned-in captions are. Larger is easier to read on phones but covers more of the picture. | 48; 16 to 120 |
91
91
  | **Subtitle position** | Where burned-in captions sit: **Top**, **Upper-middle**, **Center**, **Lower-middle** or **Bottom**. Move them up when pictures have detail near the bottom. | Bottom |
92
92
 
93
- Captions are timed from your narration on your computer, with no paid API. The first use downloads a speech model of about 95 MB. Subtitle files carry no font, size or position, because players style them. Changing the font, size or position remakes only the video.
93
+ Captions are timed from your narration on your computer, with no paid API. The first use downloads a speech model of about 378 MB (Settings → Production defaults → **Subtitle timing model** offers smaller ones). Subtitle files carry no font, size or position, because players style them. Changing the font, size or position remakes only the video.
94
94
 
95
95
  Subtitles need narration: with Audio off the section says "Turn Audio on to add subtitles." With Video off (an audio export), only subtitle files are offered.
96
96