--- name: dji-sync description: "Pulls DJI Mic takes off the transmitter, waveform-matches a reel take to its iPhone clip and swaps in the lav audio losslessly, or cuts and transcribes a solo take into the vault, then archives and wipes the card. Use when a reel was recorded with a DJI lav, when phone audio needs replacing with the lav take, when a rambled voice memo needs transcribing, or when the DJI card needs clearing." tags: [drives, video] --- # DJI Sync This skill starts when the recording stops and owns the card until it is empty. It does not edit, trim, colour-grade or re-encode video. It swaps one audio track and gets out. Hands off to `shorts-production` (encoding, Drive, ClickUp coding and scheduling), `descript-projects` (getting footage in), `video-hooks` and `generate-social` (hooks, scripts, captions), and the vault's own `CLAUDE.md` ingest contract (filed voicenote to wiki pages). ## Two paths off the same card A take is either **half of a reel** or **a thought recorded alone**. They share the card detection and the archive-and-wipe contract and nothing else. | | Reel take | Solo take | | --- | --- | --- | | Script | `dji_sync.py` | `dji_note.py` | | Needs | an iPhone clip to pair with | nothing but the take | | Output | `NAME-dji.MOV`, video bit-identical | speech-only `.opus` + a vault voicenote | | Ranking rule | the video is the timeline | the transcript is the deliverable | **Ask which one it is. Do not infer it silently.** The speaker says it in plain language ("that's a voice memo", "pair this with the reel") and that always wins. A wrong guess either burns 20 minutes of thinking or destroys the audio half of a reel. When he has not said: no clip in `~/Downloads` near the take's timestamp means solo; a long take that VAD reports as mostly silence means solo, a walk with pauses; a take within seconds of a clip's length means reel. Run `dji_note.py --dry-run` when unsure. It decides and reports without writing anything. ## The rule that ranks above the rest **The video is the timeline.** The clip's duration, frame timing and metadata are the truth. The DJI take is a raw ingredient: it gets trimmed, shifted and padded to fit the video, never the other way round. Output duration always equals input duration. If that is not true, the run failed. Normally the mic is started first and stopped last, so the take fully contains the clip and both ends get trimmed off. That is the expected case, not a guarantee. See "When the take runs short". ## Never match on timestamps The transmitter's clock and the phone's clock do not agree, and the drift is not constant. On the first real pair the DJI filename stamped `190732` while the clip stamped `19:07:49`, implying 17 s, and the true measured offset was 16.004 s. A second take on the same card was stamped 90 s away from where its audio actually sat. Filename stamps are worth using to *order* takes and nothing else. The waveform decides. ## How the match is decided The pipeline aligns once globally with GCC-PHAT, then re-measures the offset independently in every 2-second window: a genuine match holds the same offset everywhere, a false one wanders. Alignment is sub-sample and drift is corrected when it matters. The measured evidence behind the method (why envelope correlation and peak height fail, the drift numbers) is in [references/matching-method.md](references/matching-method.md). Accept requires **all** of: ≥ 80 % of windows within 1.0 ms of the median, each with peak-to-sidelobe ≥ 5; a ≥ 15 percentage-point margin over the runner-up take; at least 2 usable windows. Miss any and it refuses and writes nothing. A wrong take burned silently is far more expensive than a refusal: you find it in the edit, an hour later. Do not loosen these to force a match. Pass the take explicitly instead: `dji_sync.py clip.MOV --take /path/to/TX00_MIC005.wav`. ## When the take runs short The very first real pair covered **20.29 s of a 35.84 s clip**: the transmitter stopped before the phone did. Assume it will happen again. Uncovered stretches are filled from the phone's own audio, RMS-matched to the lav so the level does not jump, with a 40 ms crossfade at the seam. The run prints a loud `WARNING` with the head and tail gap. `--silence-gaps` leaves them silent instead, which makes the gap obvious in an edit at the cost of throwing away usable audio. **Always read the coverage line.** A file at 57 % coverage is a usable rescue, not a clean take. ## What "no loss" means here - **Video is never re-encoded.** `-c:v copy`, and the burn is rejected unless the output's video stream MD5 equals the source's. Bit-identical or it does not ship. - **Audio is lossless.** ALAC `s32p` in the `.MOV`. Measured round-trip residual −143.3 dBFS, max 1.19e-7, which is float32's own epsilon rather than any codec loss. - The only intentional changes to the lav samples are the sub-sample shift and the seam crossfade. `--codec pcm` for older NLEs, `--codec aac` for a small upload copy. Both a step down for no gain in an edit. ## Metadata and what survives `-map_metadata 0 -movflags use_metadata_tags`, then `exiftool` restores the Apple keys ffmpeg drops, then the file's mtime and creation date are reset to the original's. Verified preserved: rotation (90°, both as the tag and the display matrix), `CreationDate` with its timezone, GPS, `Make`/`Model`/`Software`, and the four `mebx` Core Media Metadata tracks with their handler names. macOS parses the result natively. **Known cosmetic wart:** ffprobe reports the data tracks' `codec_tag_string` as `stts` rather than `mebx` after the remux. The handlers and payloads survive and macOS reads the file. If a tool ever chokes on those tracks, drop them rather than shipping mangled ones. ## The solo path: silence goes before whisper sees it Silence stripping is a correctness requirement, not a size optimisation: fed a mostly-silent take, whisper loops on one sentence and the real content never appears. **Silero VAD decides** what is speech; a fixed dB gate needs per-recording tuning and is only a cross-check. The measured evidence for both claims is in [references/matching-method.md](references/matching-method.md). Everything else is defaults with a reason: 0.25 s of padding either side so words are not clipped, joins de-clicked with a 10 ms fade, and silences shorter than 400 ms left in. ### The loop guard, which is the real safety net The transcript is checked before anything is written or deleted. If one normalised sentence accounts for 6 or more lines and 40% of the transcript, it is whisper looping, not the speaker repeating himself. The run refuses: no note, no wipe, card untouched, and it says to try a lower `--vad-threshold`. Verified against both transcripts above: the trap trips at 38 of 42 lines, the real one passes at 1 of 11. ### Where the note lands `📥 Raw/Voicenotes/YYYY-MM-DD