Build click-track videos from a YAML song definition and a source lyric video.
click-builder parses a project file, generates beat-accurate click and cue audio, then muxes that audio back into the source video with FFmpeg.
- Parse a YAML project file into a validated AST
- Generate a beat-accurate timeline from tempo, meter, and structure
- Render click and cue stems into a mixed WAV file
- Mux the generated audio back into the source video
- Support self-contained project files with input and output video paths in YAML
You need:
- Node.js 20 or newer
ffmpegandffprobeavailable on yourPATH
If you do not already have Node.js installed, install the current LTS release.
winget install OpenJS.NodeJS.LTSbrew install nodesudo apt update
sudo apt install nodejs npmwinget install Gyan.FFmpegbrew install ffmpegsudo apt update
sudo apt install ffmpegnode --version
npm --version
ffmpeg -version
ffprobe -versionIf all four commands succeed, you are ready to run click-builder.
The easiest way to try it is with npx:
npx @popscode/click-builder ./project.yamlIf your YAML includes input_video_path and output_video_path, that is enough.
If you use click-builder regularly, install it globally:
npm install -g @popscode/click-builderThen run:
click-builder ./project.yamlIf you are using the source checkout directly:
npm install
npm run build
npm install -g .
click-builder ./project.yamlThe recommended format is a self-contained YAML project file with video paths and explicit stems.
name: "My Song"
tempo: 137
time_signature: 4/4
video_downbeat_offset_ms: 400
click_profile: assets/click-profiles/PraiseCharts.config.yml
input_video_path: ./My Song - Lyric Video - Instrumental.mp4
output_video_path: ./My Song - Lyric Video - Click.mp4
count_in_enabled: true
section_markers_enabled: true
downbeat_emphasis_enabled: true
mid_beat_filler_enabled: false
stems:
- id: instrumental_full
source:
type: source-video-audio
routing:
left_percent: 100
right_percent: 100
- id: click
source:
type: generated
generated_stem: click
routing:
left_percent: 100
right_percent: 100
- id: cue
source:
type: generated
generated_stem: cue
routing:
left_percent: 100
right_percent: 100
structure:
- section: "Click"
measures: 2
- section: "Intro"
measures: 2
- section: "Verse 1"
measures: 8
- section: "Chorus"
measures: 8
- section: "Outro"
measures: 4This section documents every supported YAML attribute and how it behaves.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name |
string |
yes | none | Project name for metadata/logging. |
tempo |
number |
yes | none | Base BPM used by sections unless overridden per section. |
time_signature |
string (X/Y) |
yes | none | Base meter used by sections unless overridden per section. |
video_downbeat_offset_ms |
number >= 0 |
yes | none | Downbeat offset in milliseconds. |
click_profile |
string path |
no | assets/click-profiles/PraiseCharts.config.yml |
Click/cue asset mapping profile. |
input_video_path |
string path |
no** | none | Source video path; can be provided via CLI arg instead. |
output_video_path |
string path |
no** | none | Output video path; can be provided via CLI arg instead. |
count_in_enabled |
boolean |
no | true |
Base default for section count-in behavior. |
divisions |
positive integer | no | none | Pulses per measure override; applies to any time signature. |
section_markers_enabled |
boolean |
no | true |
Base default for section cue announcements. |
downbeat_emphasis_enabled |
boolean |
no | true |
Emits click.downbeat on beat/pulse 1 when enabled. |
mid_beat_filler_enabled |
boolean |
no | false |
Inserts click.between halfway between pulses. |
stems |
array |
no | none | Stem routing configuration (see Stem Fields). |
structure |
array<section> |
yes | none | Ordered song/click sections (see Section Fields). |
** Runtime requires paths from either CLI args or YAML fields.
Each stem entry supports:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
id |
string |
yes | none | Identifier for your project organization. |
source.type |
generated | source-video-audio |
yes | none | Stem source selector. |
source.generated_stem |
click | cue |
when source.type=generated |
none | Selects generated stem type. |
routing.left_percent |
0..100 integer |
no | 100 |
Left channel gain percent. |
routing.right_percent |
0..100 integer |
no | 100 |
Right channel gain percent. |
Notes:
- Routing is currently applied to generated stems (
click,cue). source-video-audiois accepted by schema and parser for forward compatibility, but generated mix behavior currently centers on generated stems.
Each structure entry supports:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
section |
string |
yes | none | Section name used for cue lookup normalization. |
measures |
positive integer | yes | none | Number of measures in this section. |
final_measure_beats |
positive integer | no | full measure | Limits click count in the final measure (clamped to meter top). |
time_signature |
string (X/Y) |
no | inherited from top-level time_signature |
Per-section meter override. |
tempo |
number |
no | inherited from top-level tempo |
Per-section BPM override. |
section_designator |
song | click |
no | auto (click if section normalizes to click, else song) |
Affects some cue behaviors. |
count_in_enabled |
boolean |
no | inherited from top-level count_in_enabled (true) |
Enables intro lead-in cue counts behavior. |
divisions |
positive integer | no | inherited from top-level divisions |
Per-section pulse override for any meter. |
section_markers_enabled |
boolean |
no | inherited, except auto-false for section_designator=click |
Emits cue.section:<name>. |
downbeat_emphasis_enabled |
boolean |
no | inherited from top-level (true) |
Downbeat vs upbeat click behavior. |
mid_beat_filler_enabled |
boolean |
no | inherited from top-level (false) |
Inserts midpoint clicks in each pulse interval. |
count_cues_enabled |
boolean |
no | false |
Emits cue.count:2..N during first measure. |
section_cue_override |
string |
no | none | Replaces section name used for cue asset lookup. |
- Top-level
tempoandtime_signaturedefine the base. - Section-level
tempoandtime_signatureoverride only that section. - Boolean behavior flags inherit from top-level defaults unless overridden on the section.
section_designatordefaults to:clickwhen normalized section name isclicksongfor all other section names
Section cue keys are normalized before lookup:
- Lowercased
- Non-alphanumeric characters replaced with
_
Examples:
Verse 1->verse_1Post-Chorus->post_chorusRefrain 2->refrain_2
The renderer then resolves cue.section:<normalized_name> through the click profile mapping.
- Beat duration is calculated from BPM and denominator:
- quarter note duration:
60000 / bpm - beat duration:
quarter_note_duration * (4 / denominator)
- quarter note duration:
- Pulses per measure:
- default: numerator (
XinX/Y) - if
divisionsis set globally or on a section: that integer value is used - examples:
4/4withdivisions: 2-> two clicks at beats 1 and 36/8withdivisions: 2-> two clicks at beats 1 and 412/8withdivisions: 4-> four clicks at beats 1, 4, 7, and 10
- default: numerator (
final_measure_beatsapplies only to the last measure in a section:- clamped between
1and full beats-per-measure - pulse count scales proportionally and is rounded to at least
1
- clamped between
- Section markers (
cue.section:*):- emitted when
section_markers_enabledistrue - timing is one measure before the section downbeat when possible
- first section cue is emitted at timeline start
- emitted when
- Auto intro count cues:
- apply when section is intro-like (
intro,intro_1, etc.),count_in_enabled=true, and section is not designatorclick - for non-first intro sections, count cues are emitted during the preceding measure
- for first intro section, count cues can emit on the first measure pulses
- apply when section is intro-like (
- Explicit count cues (
count_cues_enabled=true):- emits
cue.count:2..Non the first measure of the section
- emits
Input/output video paths are resolved in this precedence order:
- CLI args:
click-builder <config> [input-video] [output-video] - YAML:
input_video_pathandoutput_video_path - error if unresolved
name: "Advanced Song"
tempo: 120
time_signature: 4/4
video_downbeat_offset_ms: 250
count_in_enabled: true
section_markers_enabled: true
divisions: 2
structure:
- section: "Click"
measures: 2
section_designator: click
section_markers_enabled: false
- section: "Intro"
measures: 2
time_signature: 6/8
divisions: 2
- section: "Verse 1"
measures: 8
tempo: 124
- section: "Tag"
measures: 2
final_measure_beats: 2
section_cue_override: "Tag"
count_cues_enabled: trueclick-builder [--allow-reencode] <config.yaml> [input-video] [output-video]Path precedence is:
- CLI arguments
- YAML
input_video_pathandoutput_video_path - error if neither provides the required path
In most cases, the recommended approach is to keep paths in the YAML so the project file is self-contained.
Install FFmpeg and make sure ffmpeg is on your PATH.
Install FFmpeg and make sure ffprobe is on your PATH.
Upgrade to Node.js 20 or newer.
Make sure npm global binaries are on your shell PATH, or run with npx instead.
If you prefer a Node version manager, fnm is a good lightweight option.
winget install Schniz.fnm
fnm install --lts
fnm use lts-latestbrew install fnm
fnm install --lts
fnm use lts-latestThen verify:
node --version
npm --versionInstall dependencies:
npm installBuild:
npm run buildRun the main test suites:
npm run test
npm run test:coverage
npm run test:bddGenerate deterministic real-mux fixtures:
npm run fixtures:video-syncUnit tests live next to the source they exercise as *.test.ts files.
BDD tests live under tests/bdd/ as .feature files with matching step definitions.
docs/- product and planning documentationopenspec/- proposal, design, specs, and archived changessrc/- CLI pipeline source codeassets/- click profiles and runtime assetstests/- BDD coverage and fixtures
- Internal timing values are stored as floating-point millisecond values.
- The generated click stem is the timing foundation for leader-aware muxing.
- Existing YAML files without
stemsstill work, but new project files should prefer the explicit stem-routing format.