Skip to main content
The edit loop is the whole argument for DeckTalk. You change one sentence, run the build again, and the run does the work that sentence made necessary and nothing else. Every other section keeps the take it was voiced from and the recording it was captured in, so the second build of a film costs a fraction of the first and the sections you did not touch are byte for byte the ones you already watched. Two caches carry that. A take is named by the digest of what was sent to the voice, and a recording is keyed on everything that decides what its pixels look like. Neither is a flag. This page is what those two keys hold, how to read them before a run, and the three ways to ask for work you were not offered.
Three section columns of one project. In script.md, the word "numbers" in section 2 becomes "values", which edits one sentence, and sections 1 and 3 are unchanged. A plain build then runs three lanes. In the narrate lane, section 2 is voiced and sections 1 and 3 are cached. In the record lane, section 2 is recorded and sections 1 and 3 keep their recordings. In the assemble lane, one bar spans every section, which is cut and joined into one mp4.Three section columns of one project. In script.md, the word "numbers" in section 2 becomes "values", which edits one sentence, and sections 1 and 3 are unchanged. A plain build then runs three lanes. In the narrate lane, section 2 is voiced and sections 1 and 3 are cached. In the record lane, section 2 is recorded and sections 1 and 3 keep their recordings. In the assemble lane, one bar spans every section, which is cut and joined into one mp4.

An edit to one sentence of section 2: a plain build voices and records section 2, keeps the take and the recording of sections 1 and 3, and joins every section into one mp4.

Name the sections with --section

--section is the one selector, and it means the same thing on build, narrate, cue, record, score, assemble, verify, words, storyboard and check. It takes one number, a comma list or a range, and it repeats, so these four lines name the same kind of thing. clip takes --section N too, and there it names exactly one section.
A value that is not a number or a run of them is refused as a usage error, which exits 2. A number that no section carries is refused on every command as an INPUT error, which exits 3, before the command does any work or prices anything. The hint names the sections there are. A selection is never widened to the whole project: naming a section that is not there never means every section. A section that is there but is not the kind a command acts on is refused by that command: narrate and words name the spoken sections, and record names the page sections. build hands each stage the selected sections it can act on and skips a stage that has none, so build --section N on a clip section skips narrate and record, and assemble cuts that clip again when it changed. What the flag does depends on the stage, and the difference is the point of the flag.
  • On record it is an order. A named section is recorded whether or not its inputs moved, which is how you take a bad recording again.
  • On every other stage it is a narrowing. A named section still keeps its take, its generated sound or its section video when nothing about it changed, because naming a section says which sections a run is about and never that its work has to be redone.
  • On assemble it never shortens the film. A film is always whole, so every section is in the answer, and every section video is kept only while its key matches, named or not. --section decides which sections the run judges, so a missing file in a section you left out is not reported again.
A section you leave out of record whose recording no longer matches the project is reported by name, with the reason, so a run never leaves an out-of-date picture in the film silently. A section you leave out that has no recording at all is not this run’s finding, because the run did not touch it, and decktalk status shows it in its Recorded column.

What makes a take stale

A take is one turn’s speech, the stretch of a section one voice reads, and it is named by the digest of exactly six things:
  1. the speech provider’s name
  2. the voice id
  3. the model
  4. the output format the provider declares, which for ElevenLabs is [elevenlabs] output_format
  5. the take identity the provider declares, which is [voices.NAME] speed and the provider’s own voice fields under the names its service reads, such as [elevenlabs] stability
  6. the turn’s text and pauses, written as one canonical text: each paragraph, a [pause N] as a <break time="Ns" /> tag after it and a beat as a dash, which is exactly the text ElevenLabs is sent
The six are joined with newlines and the text comes last, so every one of the first five is a single line: a provider, a voice id, a model or a format that holds a newline is refused before anything is sent, and no two sets of six can name the same take. Nothing else is in it, and each absence is a decision you can rely on.
  • The section number is not in it, so inserting a section or renumbering one voices nothing.
  • The heading is not in it, so retitling a section voices nothing. The title is the chapter marker, which assemble writes fresh on every run.
  • The neighbours’ text is not in it, so an edit to section 2 never voices section 3.
  • The lead and the tail are not in it. [narration] lead_seconds, [narration] tail_seconds and a section’s own lead_seconds and tail_seconds are placed when the takes are joined, so changing one moves the section on the clock and buys nothing.
  • Two sections with the same words come to one digest, so the second is kept rather than bought twice.
A take file is named by its digest, under the suffix of the format it holds, which is .mp3 for every ElevenLabs format. narrate sends nothing for a section whose take and words file are on disk and agree. It looks in two places, in this order:
  1. The takes directory, takes/ inside the project unless narration.takes_dir names another. It holds every take the film plays and the words its voice sent with each. Commit it, and a fresh clone builds the real film with no key. Every take this project buys is written there, and a take found in the take store is copied there, so the directory always holds the whole film.
  2. The take store, narration.store_dir, a folder outside every project where this machine keeps a second copy of every take it buys. It is on by default, in the per-user data folder, and decktalk config set narration.store_dir DIR --scope machine moves it. It is written once per take, so a re-buy with --replace-voiced changes this project’s takes directory and never the take another project plays. It holds the spoken words of every script voiced on this machine, so keep it out of a synced or shared folder. Two projects that need the same take buy it once: a run that finds a good copy in the store once its turn comes copies it in rather than buying it again, and one that finds another run still buying waits as long as one whole take request can run, every attempt narration.timeout_seconds long with the waits between narration.retries. A run told to replace that take with --replace-voiced buys it anyway. A take bought while the takes directory cannot be written is kept in the take store first, and the run stops and says so, so the next run copies it in for nothing.
A copy whose words do not read, or whose take does not hold the bytes they recorded, is skipped and moved aside as <name>.unreadable, or as <name>.<n>.unreadable with the first number free for both files when that name is taken, when a good copy replaces it. DeckTalk never deletes a take. A takes_dir that is empty, outside the project, an absolute path, the project directory itself, inside the build directory or a directory that holds it is refused when the project loads, and so is a store_dir that is relative, inside the project or inside the tool cache. A placeholder take, the take index and the joined narration are always under build/narrate/, because a build makes them again for nothing. A turn that is voiced again costs the characters of its whole text and not only the changed sentence, and a section one voice reads is one turn, so decktalk check is the call that prices an edit before you run it.

What makes a recording stale

A recording is one page section stepped through Chromium frame by frame, and its log beside the mp4 keeps what it was made from as made_from: a list of named parts, each with the digest of what it held. Together they are everything that decides what those pixels look like:
  • each turn’s words and the name of the voice that reads it, as text:N.T and voice:N.T
  • each turn’s words with their start times, which the page is handed, as words:N.T
  • the markup of the one scene the section plays, as scene
  • the rest of the page file, which every scene of that page shares, as page: and the page
  • the content of every project file the page loaded when it was last recorded, which its log lists under assets, each as file: and its path
  • the scene and the section’s own params, every cue resolved to its second, and how long the section runs, which is its frames in the narration plus its hold_seconds
  • the frame width, the frame height and the output frame rate, [record] color_scheme, the page policy, and motion.reduce and motion.scale, so a reduced render never reuses a full-motion recording
  • the DeckTalk version, so an upgrade records again what an older engine recorded
A section none of whose parts moved is kept. A section whose parts moved is recorded again, and status and record --section say which parts moved, in one line per section:
A turn is named by its section and its number within the section, so 2.1 is the first turn of section 2. The line names what you edited and not what followed from it: new words are timed again, so their times, the section’s cue times and its length move with them, and those are named only when nothing they follow from moved. status reads each turn’s words and voice from the script, because a build narrates before it records, so a sentence you edited is named before narrate has made its new take. record reads them from the take it plays, so a record run before narrate keeps the recording, which would come out the same. The page joins in two pieces rather than as one file, and that is what makes the loop cheap on a real deck. A page holds every scene of a film, so a digest of the whole file would call all nine sections of a nine-scene page stale for one slide’s edit. Editing a slide moves the scene piece and therefore the sections that play that scene. Editing the head, a style, a script or anything else outside a scene moves the shared piece and therefore every section of that page. A word that appears on the slides of two scenes, such as a button both screens show, is an edit to both scenes. The scene is cut out of the page source as you wrote it and never out of a browser, so status and record can both answer whether a recording still stands without opening Chromium. A page that builds its scenes in script instead has no such element to cut out, so every section of it is keyed on the whole file. The asset list is what keeps the key honest in the other direction. A page that swaps one picture for another changes no line of HTML, so the markup alone would say that nothing had moved. One function answers this question, so what record skips and what status calls stale can never disagree, and neither of them compares file times. A section whose words do not read, provider words or estimated words, cannot be judged, because its words are in the page URL. record --section names it in one line, with the file, when the run passes it over, and records the sections it was asked for. Only a run that records that section is refused over them.

What each edit does again

Read it before you run it

decktalk status reads the project and writes only its run’s events file. It is the one call that says what is on disk and whether that is still true. The one thing it judges is a file the project names and has not got, which is a FILE_MISSING error and the only way it exits 1.
--json gives an agent the same reading as one flat object, and the sections[] rows are what a loop dispatches on.
Two fields carry the two caches.
  • take_state is what the disk holds for this section, with take_reason saying why. It goes stale when the text, the voice, the model or the voice settings change, and voiced again once a voiced run buys the take, which is how you find what the next voiced run would buy.
  • recording_stale is true when the recording on disk no longer matches the project. The row carries the fact and the stream carries the sentence, so a reader that dispatches on the boolean is still told which of the several things moved.
next is the whole command to run next, read off the pipeline rather than guessed. It is decktalk build whenever anything on disk is stale, and decktalk verify when the film on disk has not been measured. It is null once the film on disk is the one the last build measured, because then there is nothing to do. It is also null when the takes directory holds none of the takes the project played, which status says in one warning line, because a build would buy those takes again. runs lists any run whose events file is still open, which is how a caller finds a build it started in the background.

A build that finds nothing changed

assemble and verify are pure functions of files already on disk, so when nothing either of them reads has moved since the last build ran them, a build keeps both rather than doing them again. It reports each with the outcome kept and reports its findings again, so the result reads as the run that made the film did.
On the starter that second build took under a second. What a build compares is a digest of every file the two stages read and the options they ran with, kept in build/kept.json, never a file time. Inside assemble each section video has a key of its own in build/sections/NN.json, so an edit to one section encodes that section again and keeps the others.

Ask for work you were not offered

Three flags redo work. --force costs nothing, and the two replace flags buy again what you already own. On narrate, --turn and --take buy one turn again, which is the next section. --force builds again from nothing and never spends. It keeps every voiced take and every sound the score bought, and makes again everything that costs nothing. narrate remakes the placeholders, record records every section it planned, and assemble and verify run and measure again even when nothing they read has moved. With --spend or --no-spend, a forced run buys only what is missing, as an unforced run does.
--replace-voiced spends money on words you already paid for. With --spend it voices each section it names again and plays the new take. Without it, a run that does not spend plays every voiced take on disk and puts a placeholder only where a take is missing, reporting each such section as TAKE_MISSING, so a film you have already voiced rehearses its new section for nothing. --replace-voiced --no-spend plays placeholders in place of the voiced takes for that one run, and the voiced takes stay on disk for the next. A voice that bills nothing, such as dtsp, voices each section it names again with or without --spend.
--replace-score is the same for the score. With --spend it buys every bought sound again, each part of the music included, and it never buys a take. --replace-voiced never buys a sound. Without --spend, --replace-score keeps every bought sound on disk, so it never acts on its own.
On a terminal, each replace flag is confirmed once before anything is replaced. Without a terminal the flag is the authorisation, because a run that was told to replace a take or a sound was told so on purpose. The safe default, in every other case, is to keep every take and every sound. --max-cost N refuses before the first call when the most a run can cost is over N dollars, and --spend approves the price without asking. Neither of them is a way to redo work: they only decide what a run is allowed to buy.

Voice one turn again

A take that reads a word with the wrong stress, or stretches a pause, is bought again one turn at a time. --turn N.T names turn T of section N, counted from one. A section one voice reads is one turn, so its whole text is N.1, and a dialogue has one turn per voice direction, the same numbers decktalk --json status lists under each section’s turns.
--take names a turn by the take it plays: its digest, as build/narrate/takes.json and a narrate --json result name it, or as much of its start as no other take shares. Both flags repeat. A turn that shares its take with another turn, because the two say the same words in the same voice, shares the new take too, and it is bought once. The run voices exactly the turns it names, even though a take of their text is on disk, and nothing else. Every other turn, of those sections and of the rest of the film, keeps the take it plays, and a turn of the same section that has no take yet plays a placeholder rather than being bought, so the price --max-cost holds, the bill a terminal is shown and the price an approval refusal carries cover the named turns alone. decktalk check --turn 4.2 prices the same run without running it. --section and --replace-voiced are refused beside them, because the turns already name their sections. The new take plays under the take’s own name, so the take index, status and every later stage read it as they read the old one. The take it replaces is a paid record, so it is never deleted: it is set aside in the takes directory’s rejected/ folder as <digest>.1.mp3 with its words file <digest>.1.words.json, then .2 for the next, and the run says where it went. To play a rejected take again, move its pair back over the take as <digest>.mp3 and <digest>.words.json, after setting that pair aside the same way if you want to keep it, and run decktalk narrate. The take store keeps the take it first held, because another project may play it, so it holds the first take of that turn and not the new one: commit the takes directory once you keep a new take, because a takes directory that lost it is filled from the store with the first. --replace-voiced sets aside every voiced take it replaces the same way.

Write with the build running

decktalk build --watch is the draft loop. It serves the project itself, prints the URL, builds once without buying, and then rebuilds on every save until you stop it.
The origin listens on this machine alone and takes whichever port was free, so the number changes from run to run and the line is the one place it is printed. Four things are worth knowing about it.
  • It never spends, whatever the settings say. A free provider such as dtsp still voices each change once a voice is named, because spend gates money and nothing else. Buying from a voice that bills is a different command, because the safe default is the rule and the named escape is a separate act.
  • It rebuilds the sections the saved file touches. A page belongs to the sections that play it, a clip to the section that plays it, and script.md, cues.json and decktalk.toml each belong to every section at once. A file that belongs to no section, such as a stylesheet, rebuilds all of them, because nothing can say which sections it reached.
  • A section whose words changed under a voice that bills plays a placeholder until you voice it, and decktalk status shows its take as stale. decktalk narrate --section N --spend voices it.
  • A save that breaks a file is printed as one error block and the loop keeps watching, so you fix the file and save again rather than restarting the loop.

Run one stage at a time

Each stage is also a command, and a build is those six in order. After an edit that reaches one stage, run that stage alone and the two after it that read what it wrote. --from STAGE and --to STAGE do the same thing through build, which is the way to say it when the span is more than one stage.
A run that starts past an artifact nothing has written yet is refused by name, with the command that writes it, so a span that cannot work is refused before any of it runs.
  • Read the six stages in order: How it works
  • Price an edit before you run it: CLI
  • Measure the film after a rebuild: Verify
  • Read the flags of every command: CLI