diff --git a/fedac/native/pieces/cap.mjs b/fedac/native/pieces/cap.mjs --- a/fedac/native/pieces/cap.mjs +++ b/fedac/native/pieces/cap.mjs @@ -88,10 +88,23 @@ ink(130, 130, 130).write("F flips the picture (monitor mode)", { x: 6, y: screen.height - 22, }); - ink(130, 130, 130).write("play your song first — clips carry its audio", { - x: 6, - y: screen.height - 12, - }); + // Song sync: a deck loaded in dj keeps playing here, its audio lands in + // every clip, and each MP4 gets songStart/songEnd timecode metadata. + const d = sound?.deck?.decks?.[0]; + if (d?.loaded) { + const fmt = (s) => + `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`; + const title = (d.title || "?").replace(/\.[^.]+$/, ""); + ink(255, 200, 120).write( + `${title} ${fmt(d.position || 0)}/${fmt(d.duration || 0)} — clips sync to this track`, + { x: 6, y: screen.height - 12 }, + ); + } else { + ink(130, 130, 130).write("play your song in dj first — clips carry its audio", { + x: 6, + y: screen.height - 12, + }); + } const cams = system?.cameraCount?.() || 0; if (cams > 1) { ink(130, 130, 130).write(`TAB switches cameras (${cams} found)`, { diff --git a/fedac/native/src/ac-native.c b/fedac/native/src/ac-native.c --- a/fedac/native/src/ac-native.c +++ b/fedac/native/src/ac-native.c @@ -3493,6 +3493,21 @@ static ACRecorder *g_tape_recorder = NULL; static ACAudio *g_tape_audio = NULL; static ACTts *g_tape_tts = NULL; +#ifdef HAVE_AVCODEC +// First loaded DJ deck (decks persist across piece switches, so a song +// started in `dj` keeps playing inside `cap`). Its live position feeds the +// song timecode metadata baked into each clip for editor resync. +static ACDeckDecoder *tape_active_deck(void) { + if (!g_tape_audio) return NULL; + for (int d = 0; d < AUDIO_MAX_DECKS; d++) { + ACDeck *dk = &g_tape_audio->decks[d]; + if (dk->active && dk->decoder && dk->decoder->loaded) + return dk->decoder; + } + return NULL; +} +#endif + int ac_tape_start(int quiet) { if (!g_tape_recorder) return -1; if (recorder_is_recording(g_tape_recorder)) return 0; @@ -3508,6 +3523,17 @@ snprintf(rec_path, sizeof(rec_path), "/mnt/tapes/%04d.%02d.%02d.%02d.%02d.%02d.%03d.mp4", tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday, tm->tm_hour, tm->tm_min, tm->tm_sec, ms); +#ifdef HAVE_AVCODEC + // Song timecode: if a deck is loaded, stamp the clip with the track + + // where in it recording began (songEnd follows at the cut). + { + ACDeckDecoder *deck = tape_active_deck(); + if (deck) + recorder_set_song(g_tape_recorder, deck->title, deck->artist, + deck->path, deck->position, deck->duration, + deck->speed); + } +#endif if (recorder_start(g_tape_recorder, rec_path) != 0) return -1; // Wire up audio tap if (g_tape_audio) { @@ -3539,6 +3565,13 @@ } // Snapshot the path before stop (recorder_stop may clear it) char saved_tape_path[256] = {0}; strncpy(saved_tape_path, g_tape_current_path, sizeof(saved_tape_path) - 1); +#ifdef HAVE_AVCODEC + // Song position at the cut — completes the songStart/songEnd pair. + { + ACDeckDecoder *deck = tape_active_deck(); + if (deck) recorder_set_song_end(g_tape_recorder, deck->position); + } +#endif recorder_stop(g_tape_recorder); g_tape_recording = 0; g_tape_current_path[0] = '\0'; diff --git a/fedac/native/src/recorder.c b/fedac/native/src/recorder.c --- a/fedac/native/src/recorder.c +++ b/fedac/native/src/recorder.c @@ -73,6 +73,17 @@ // Audio resampler output buffer int16_t *audio_resample_buf; int audio_resample_buf_size; + + // Song timecode metadata for cap music-video clips. Set before start, + // end position updated at the cut, cleared when the file finalizes. + int song_set; + char song_title[256]; + char song_artist[256]; + char song_path[512]; + double song_start; + double song_end; + double song_duration; + double song_speed; }; // ── Forward declarations ── @@ -83,8 +94,44 @@ static void encode_video_frame(ACRecorder *rec, const uint32_t *pixels, int stride); static void encode_audio_chunk(ACRecorder *rec); static void flush_encoders(ACRecorder *rec); +// Minimal JSON string escape: ", \ and control chars. Truncates to dstsz. +static void json_escape(char *dst, size_t dstsz, const char *src) { + size_t o = 0; + for (const char *s = src; *s && o + 2 < dstsz; s++) { + unsigned char c = (unsigned char)*s; + if (c == '"' || c == '\\') { + dst[o++] = '\\'; + dst[o++] = c; + } else if (c < 0x20) { + dst[o++] = ' '; + } else { + dst[o++] = c; + } + } + dst[o] = 0; +} + // ── Public API ── +void recorder_set_song(ACRecorder *rec, const char *title, const char *artist, + const char *path, double position, double duration, + double speed) { + if (!rec || rec->recording) return; + snprintf(rec->song_title, sizeof(rec->song_title), "%s", title ? title : ""); + snprintf(rec->song_artist, sizeof(rec->song_artist), "%s", artist ? artist : ""); + snprintf(rec->song_path, sizeof(rec->song_path), "%s", path ? path : ""); + rec->song_start = position; + rec->song_end = position; // until the cut updates it + rec->song_duration = duration; + rec->song_speed = speed; + rec->song_set = 1; +} + +void recorder_set_song_end(ACRecorder *rec, double position) { + if (!rec || !rec->song_set) return; + rec->song_end = position; +} + ACRecorder *recorder_create(int width, int height, int fps, unsigned int audio_rate) { ACRecorder *rec = calloc(1, sizeof(ACRecorder)); if (!rec) return NULL; @@ -155,6 +202,21 @@ avformat_free_context(rec->fmt_ctx); rec->fmt_ctx = NULL; return -1; } + } + + // Song identity + start timecode into the moov metadata (must land + // before write_header; the precise start/end pair also goes into the + // trailing JSON box at stop, since the end isn't known yet). + if (rec->song_set) { + if (rec->song_title[0]) + av_dict_set(&rec->fmt_ctx->metadata, "title", rec->song_title, 0); + if (rec->song_artist[0]) + av_dict_set(&rec->fmt_ctx->metadata, "artist", rec->song_artist, 0); + char cmt[512]; + snprintf(cmt, sizeof(cmt), + "ac cap clip | song \"%s\" from %.3fs | see trailing skip-box JSON for songStart/songEnd", + rec->song_title, rec->song_start); + av_dict_set(&rec->fmt_ctx->metadata, "comment", cmt, 0); } // Set fragmented MP4 options (crash-safe) @@ -253,9 +315,31 @@ // Finalize MP4 if (rec->fmt_ctx) { av_write_trailer(rec->fmt_ctx); + // Append the song timecode as a top-level `skip` box after the + // trailer: spec-compliant players ignore it, and tools can read + // songStart/songEnd (seconds into the track at record/cut) to + // resync clips onto the song's timeline in an editor. + if (rec->song_set && rec->fmt_ctx->pb) { + char et[520], ea[520], ep[1040], json[2200]; + json_escape(et, sizeof(et), rec->song_title); + json_escape(ea, sizeof(ea), rec->song_artist); + json_escape(ep, sizeof(ep), rec->song_path); + int n = snprintf(json, sizeof(json), + "{\"ac\":\"cap\",\"song\":{\"title\":\"%s\",\"artist\":\"%s\",\"path\":\"%s\"," + "\"duration\":%.3f},\"songStart\":%.3f,\"songEnd\":%.3f,\"speed\":%.3f}", + et, ea, ep, rec->song_duration, + rec->song_start, rec->song_end, rec->song_speed); + if (n > 0 && n < (int)sizeof(json)) { + avio_wb32(rec->fmt_ctx->pb, 8 + (uint32_t)n); + avio_write(rec->fmt_ctx->pb, (const unsigned char *)"skip", 4); + avio_write(rec->fmt_ctx->pb, (const unsigned char *)json, n); + ac_log("[recorder] song timecode box: %s\n", json); + } + } if (!(rec->fmt_ctx->oformat->flags & AVFMT_NOFILE)) avio_closep(&rec->fmt_ctx->pb); } + rec->song_set = 0; // Free encoder resources if (rec->sws) { sws_freeContext(rec->sws); rec->sws = NULL; } diff --git a/fedac/native/src/recorder.h b/fedac/native/src/recorder.h --- a/fedac/native/src/recorder.h +++ b/fedac/native/src/recorder.h @@ -27,6 +27,19 @@ // Submit audio samples (interleaved int16 stereo PCM at source rate). // Non-blocking: copies into ring buffer for the encoder thread. void recorder_submit_audio(ACRecorder *rec, const int16_t *pcm, int frames); +// Attach song timecode to the upcoming recording (call before +// recorder_start; cleared automatically at stop). The song identity + +// start offset land in the moov metadata (title/artist/comment), and a +// JSON `skip` box is appended after the trailer carrying songStart / +// songEnd / speed — editors ignore it, tools can read it to resync +// clips onto the track's timeline for music videos. +void recorder_set_song(ACRecorder *rec, const char *title, const char *artist, + const char *path, double position, double duration, + double speed); + +// Final song position at the moment of the cut (call before recorder_stop). +void recorder_set_song_end(ACRecorder *rec, double position); + // Stop recording: flush encoder, finalize MP4, join thread. // Blocks until the file is fully written. void recorder_stop(ACRecorder *rec); @@ -48,6 +61,8 @@ static inline ACRecorder *recorder_create(int w, int h, int fps, unsigned int ar) { (void)w; (void)h; (void)fps; (void)ar; return (void*)0; } static inline int recorder_start(ACRecorder *r, const char *p) { (void)r; (void)p; return -1; } static inline void recorder_submit_video(ACRecorder *r, const uint32_t *px, int s) { (void)r; (void)px; (void)s; } static inline void recorder_submit_audio(ACRecorder *r, const int16_t *pcm, int f) { (void)r; (void)pcm; (void)f; } +static inline void recorder_set_song(ACRecorder *r, const char *t, const char *a, const char *p, double pos, double dur, double sp) { (void)r; (void)t; (void)a; (void)p; (void)pos; (void)dur; (void)sp; } +static inline void recorder_set_song_end(ACRecorder *r, double pos) { (void)r; (void)pos; } static inline void recorder_stop(ACRecorder *r) { (void)r; } static inline int recorder_is_recording(ACRecorder *r) { (void)r; return 0; } static inline double recorder_elapsed(ACRecorder *r) { (void)r; return 0.0; }