ascii-chat 0.8.38
Real-time terminal-based video chat with ASCII art conversion
Loading...
Searching...
No Matches
source.c
Go to the documentation of this file.
1
6#include <ascii-chat/media/source.h>
7#include <ascii-chat/media/ffmpeg_decoder.h>
8#include <ascii-chat/media/yt_dlp.h>
9#include <ascii-chat/video/webcam/webcam.h>
10#include <ascii-chat/options/options.h>
11#include <ascii-chat/audio/audio.h>
12#include <ascii-chat/log/logging.h>
13#include <ascii-chat/asciichat_errno.h>
14#include <ascii-chat/platform/abstraction.h>
15#include <ascii-chat/platform/util.h>
16#include <ascii-chat/common/buffer_sizes.h>
17#include <ascii-chat/util/time.h>
18#include <stdlib.h>
19#include <string.h>
20
21/* ============================================================================
22 * Media Source Structure
23 * ============================================================================ */
24
32 media_source_type_t type;
34 bool is_paused;
35
36 // Webcam context (for WEBCAM and TEST types)
37 webcam_context_t *webcam_ctx;
38 unsigned short int webcam_index;
39
40 // FFmpeg decoders (for FILE and STDIN types)
44
45 // Thread synchronization
46 mutex_t decoder_mutex;
48 mutex_t pause_mutex;
49
50 // Cached paths
51 char *file_path;
53
54 // Audio integration
55 void *audio_ctx;
56};
57
58/* ============================================================================
59 * Stream Type Detection
60 * ============================================================================ */
61
65static bool url_has_ffmpeg_native_extension(const char *url) {
66 if (!url)
67 return false;
68
69 // Extract extension from URL (ignore query params)
70 const char *question = strchr(url, '?');
71 size_t url_len = question ? (size_t)(question - url) : strlen(url);
72
73 // Find last dot
74 const char *dot = NULL;
75 for (const char *p = url + url_len - 1; p >= url; p--) {
76 if (*p == '.') {
77 dot = p;
78 break;
79 }
80 if (*p == '/')
81 break;
82 }
83
84 if (!dot)
85 return false;
86
87 const char *ext = dot + 1;
88
89 // Video containers
90 if (strcasecmp(ext, "mp4") == 0 || strcasecmp(ext, "mkv") == 0 || strcasecmp(ext, "webm") == 0 ||
91 strcasecmp(ext, "mov") == 0 || strcasecmp(ext, "avi") == 0 || strcasecmp(ext, "flv") == 0 ||
92 strcasecmp(ext, "ogv") == 0 || strcasecmp(ext, "ts") == 0 || strcasecmp(ext, "m2ts") == 0 ||
93 strcasecmp(ext, "mts") == 0 || strcasecmp(ext, "3gp") == 0 || strcasecmp(ext, "3g2") == 0 ||
94 strcasecmp(ext, "f4v") == 0 || strcasecmp(ext, "asf") == 0 || strcasecmp(ext, "wmv") == 0) {
95 return true;
96 }
97
98 // Audio containers
99 if (strcasecmp(ext, "ogg") == 0 || strcasecmp(ext, "oga") == 0 || strcasecmp(ext, "wma") == 0 ||
100 strcasecmp(ext, "wav") == 0 || strcasecmp(ext, "flac") == 0 || strcasecmp(ext, "aac") == 0 ||
101 strcasecmp(ext, "m4a") == 0 || strcasecmp(ext, "m4b") == 0 || strcasecmp(ext, "mp3") == 0 ||
102 strcasecmp(ext, "aiff") == 0 || strcasecmp(ext, "au") == 0) {
103 return true;
104 }
105
106 // Streaming
107 if (strcasecmp(ext, "m3u8") == 0 || strcasecmp(ext, "mpd") == 0) {
108 return true;
109 }
110
111 return false;
112}
113
117static bool url_is_direct_stream(const char *url) {
118 if (!url)
119 return false;
120
121 // RTSP and RTMP are streaming protocols, always direct
122 if (strncmp(url, "rtsp://", 7) == 0 || strncmp(url, "rtmp://", 7) == 0) {
123 return true;
124 }
125
126 // Check for FFmpeg-native file extension
127 return url_has_ffmpeg_native_extension(url);
128}
129
133static asciichat_error_t media_source_resolve_url(const char *url, const char *yt_dlp_options, char *output_url,
134 size_t output_size) {
135 if (!url || !output_url || output_size < 256) {
136 SET_ERRNO(ERROR_INVALID_PARAM, "Invalid parameters for URL resolution");
137 return ERROR_INVALID_PARAM;
138 }
139
140 bool is_direct = url_is_direct_stream(url);
141
142 if (is_direct) {
143 log_debug("URL is direct stream, passing to FFmpeg directly: %s", url);
144 SAFE_STRNCPY(output_url, url, output_size - 1);
145 output_url[output_size - 1] = '\0';
146 return ASCIICHAT_OK;
147 }
148
149 log_debug("URL is complex site, attempting yt-dlp extraction: %s", url);
150
151 // Try yt-dlp extraction (for complex sites like YouTube, Twitch, etc.)
152 asciichat_error_t yt_dlp_result = yt_dlp_extract_stream_url(url, yt_dlp_options, output_url, output_size);
153 if (yt_dlp_result == ASCIICHAT_OK) {
154 log_debug("yt-dlp successfully extracted stream URL");
155 return ASCIICHAT_OK;
156 }
157
158 log_error("yt-dlp extraction failed for URL: %s", url);
159
160 // For complex sites, try FFmpeg as last resort
161 log_debug("yt-dlp failed, trying FFmpeg as fallback for: %s", url);
162 SAFE_STRNCPY(output_url, url, output_size - 1);
163 output_url[output_size - 1] = '\0';
164 log_info("FFmpeg will attempt to handle URL (yt-dlp extraction failed)");
165 return ASCIICHAT_OK; // Let FFmpeg try - if it fails, that's ok too
166}
167
168/* ============================================================================
169 * Media Source Lifecycle
170 * ============================================================================ */
171
172media_source_t *media_source_create(media_source_type_t type, const char *path) {
173 media_source_t *source = SAFE_MALLOC(sizeof(media_source_t), media_source_t *);
174 if (!source) {
175 SET_ERRNO(ERROR_MEMORY, "Failed to allocate media source");
176 return NULL;
177 }
178
179 memset(source, 0, sizeof(*source));
180 source->type = type;
181 source->loop_enabled = false;
182 source->is_paused = false;
183
184 // Initialize mutex for protecting shared decoder access (for YouTube URLs)
185 if (mutex_init(&source->decoder_mutex) != 0) {
186 SET_ERRNO(ERROR_MEMORY, "Failed to initialize mutex");
187 SAFE_FREE(source);
188 return NULL;
189 }
190
191 // Initialize mutex for protecting pause state (accessed from keyboard and video threads)
192 if (mutex_init(&source->pause_mutex) != 0) {
193 SET_ERRNO(ERROR_MEMORY, "Failed to initialize pause mutex");
195 SAFE_FREE(source);
196 return NULL;
197 }
198
199 // Initialize mutex for protecting decoder access during seeks (prevents race conditions)
200 if (mutex_init(&source->seek_access_mutex) != 0) {
201 SET_ERRNO(ERROR_MEMORY, "Failed to initialize seek access mutex");
203 mutex_destroy(&source->pause_mutex);
204 SAFE_FREE(source);
205 return NULL;
206 }
207
208 switch (type) {
209 case MEDIA_SOURCE_WEBCAM: {
210 // Parse webcam index from path (if provided)
211 unsigned short int index = 0;
212 if (path) {
213 int parsed = atoi(path);
214 if (parsed >= 0 && parsed <= USHRT_MAX) {
215 index = (unsigned short int)parsed;
216 }
217 }
218
219 source->webcam_index = index;
220 asciichat_error_t webcam_error = webcam_init_context(&source->webcam_ctx, index);
221 if (webcam_error != ASCIICHAT_OK) {
222 // Webcam init failed - log and cleanup
223 log_error("Failed to initialize webcam device %u (error code: %d)", index, webcam_error);
224 SAFE_FREE(source);
225
226 // Explicitly re-set errno to preserve the specific error code for the caller
227 // (log_error or other calls may have cleared the thread-local errno)
228 if (webcam_error == ERROR_WEBCAM_IN_USE) {
229 SET_ERRNO(ERROR_WEBCAM_IN_USE, "Webcam device %u is in use", index);
230 } else {
231 SET_ERRNO(ERROR_WEBCAM, "Failed to initialize webcam device %u", index);
232 }
233 return NULL;
234 }
235
236 log_debug("Media source: Webcam device %u", index);
237 break;
238 }
239
240 case MEDIA_SOURCE_FILE: {
241 if (!path || path[0] == '\0') {
242 SET_ERRNO(ERROR_INVALID_PARAM, "File path is required for FILE source");
243 SAFE_FREE(source);
244 return NULL;
245 }
246
247 // Resolve URL using smart FFmpeg/yt-dlp routing
248 const char *effective_path = path;
249 char resolved_url[BUFFER_SIZE_XLARGE] = {0};
250 const char *yt_dlp_options = GET_OPTION(yt_dlp_options);
251
252 log_debug("Resolving URL: %s", path);
253 asciichat_error_t resolve_err = media_source_resolve_url(path, yt_dlp_options, resolved_url, sizeof(resolved_url));
254 if (resolve_err != ASCIICHAT_OK) {
255 log_debug("Failed to resolve URL (error: %d)", resolve_err);
256 SAFE_FREE(source);
257 return NULL;
258 }
259 effective_path = resolved_url;
260 log_debug("Using resolved URL for playback");
261
262 // Store original URL for potential re-extraction if needed
264 if (!source->original_youtube_url) {
265 log_warn("Failed to cache original URL");
266 }
267
268 // Cache file path for potential reopen on loop
269 source->file_path = platform_strdup(effective_path);
270 if (!source->file_path) {
271 SET_ERRNO(ERROR_MEMORY, "Failed to duplicate file path");
272 SAFE_FREE(source->original_youtube_url);
273 SAFE_FREE(source);
274 return NULL;
275 }
276
277 // Always use separate decoders for video and audio
278 // This allows independent read rates and avoids lock contention
279 source->video_decoder = ffmpeg_decoder_create(effective_path);
280 if (!source->video_decoder) {
281 log_error("Failed to open media file for video: %s", effective_path);
282 SAFE_FREE(source->file_path);
283 SAFE_FREE(source->original_youtube_url);
284 SAFE_FREE(source);
285 return NULL;
286 }
287
288 // Start prefetch thread for video frames (critical for HTTP performance)
289 // This thread continuously reads frames into a buffer so the render loop never blocks
290 asciichat_error_t prefetch_err = ffmpeg_decoder_start_prefetch(source->video_decoder);
291 if (prefetch_err != ASCIICHAT_OK) {
292 log_error("Failed to start video prefetch thread: %s", asciichat_error_string(prefetch_err));
293 // Don't fail on prefetch error - continue with frame skipping as fallback
294 }
295
296 source->audio_decoder = ffmpeg_decoder_create(effective_path);
297 if (!source->audio_decoder) {
298 log_error("Failed to open media file for audio: %s", effective_path);
300 source->video_decoder = NULL;
301 SAFE_FREE(source->file_path);
302 SAFE_FREE(source->original_youtube_url);
303 SAFE_FREE(source);
304 return NULL;
305 }
306 source->is_shared_decoder = false;
307
308 log_debug("Media source: URL resolved to stream (separate video/audio decoders)");
309 break;
310 }
311
312 case MEDIA_SOURCE_STDIN: {
313 // Create separate decoders for video and audio from stdin
315 if (!source->video_decoder) {
316 log_error("Failed to open stdin for video input");
317 SAFE_FREE(source);
318 return NULL;
319 }
320
321 // Start prefetch thread for stdin video frames
322 asciichat_error_t prefetch_err = ffmpeg_decoder_start_prefetch(source->video_decoder);
323 if (prefetch_err != ASCIICHAT_OK) {
324 log_error("Failed to start stdin video prefetch thread: %s", asciichat_error_string(prefetch_err));
325 // Don't fail on prefetch error - continue with frame skipping as fallback
326 }
327
329 if (!source->audio_decoder) {
330 log_error("Failed to open stdin for audio input");
332 source->video_decoder = NULL;
333 SAFE_FREE(source);
334 return NULL;
335 }
336
337 log_debug("Media source: stdin (separate video/audio decoders)");
338 break;
339 }
340
341 case MEDIA_SOURCE_TEST: {
342 // Test pattern doesn't need webcam context - it's handled in webcam_read()
343 // which checks GET_OPTION(test_pattern) and generates a pattern directly
344 source->webcam_index = 0;
345 source->webcam_ctx = NULL; // No context needed for test pattern
346
347 log_debug("Media source: Test pattern");
348 break;
349 }
350
351 default:
352 SET_ERRNO(ERROR_INVALID_PARAM, "Unknown media source type: %d", type);
353 SAFE_FREE(source);
354 return NULL;
355 }
356
357 return source;
358}
359
361 if (!source) {
362 return;
363 }
364
365 // Cleanup based on type
366 if (source->webcam_ctx) {
368 source->webcam_ctx = NULL;
369 }
370
371 // For YouTube (shared decoder), only destroy once
372 // For local files (separate decoders), destroy both
373 if (source->video_decoder) {
375 source->video_decoder = NULL;
376 }
377
378 if (source->audio_decoder) {
379 // Don't double-free if audio and video share the same decoder
380 if (!source->is_shared_decoder) {
382 }
383 source->audio_decoder = NULL;
384 }
385
386 if (source->file_path) {
387 free(source->file_path);
388 source->file_path = NULL;
389 }
390
391 if (source->original_youtube_url) {
392 free(source->original_youtube_url);
393 source->original_youtube_url = NULL;
394 }
395
396 // Destroy mutexes
398 mutex_destroy(&source->pause_mutex);
400
401 SAFE_FREE(source);
402}
403
404/* ============================================================================
405 * Video Operations
406 * ============================================================================ */
407
409 if (!source) {
410 return NULL;
411 }
412
413 // Check pause state (thread-safe)
414 mutex_lock(&source->pause_mutex);
415 bool is_paused = source->is_paused;
416 mutex_unlock(&source->pause_mutex);
417
418 // Return NULL immediately if paused (maintaining position)
419 if (is_paused) {
420 return NULL;
421 }
422
423 switch (source->type) {
424 case MEDIA_SOURCE_WEBCAM:
425 // Read from webcam
426 if (source->webcam_ctx) {
427 return webcam_read_context(source->webcam_ctx);
428 }
429 return NULL;
430
431 case MEDIA_SOURCE_TEST:
432 // Test pattern uses global webcam_read() which checks GET_OPTION(test_pattern)
433 return webcam_read();
434
435 case MEDIA_SOURCE_FILE:
436 case MEDIA_SOURCE_STDIN: {
437 if (!source->video_decoder) {
438 return NULL;
439 }
440
441 // Lock shared decoder if YouTube URL (protect against concurrent audio thread access)
442 if (source->is_shared_decoder) {
443 mutex_lock(&source->decoder_mutex);
444 }
445
446 uint64_t frame_read_start_ns = time_get_ns();
447 image_t *frame = ffmpeg_decoder_read_video_frame(source->video_decoder);
448 uint64_t frame_read_ns = time_elapsed_ns(frame_read_start_ns, time_get_ns());
449
450 // Track frame reading statistics for FPS diagnosis
451 static uint64_t total_attempts = 0;
452 static uint64_t successful_frames = 0;
453 static uint64_t null_frame_count = 0;
454 static uint64_t total_read_time_ns = 0;
455 static uint64_t max_read_time_ns = 0;
456
457 total_attempts++;
458 if (frame) {
459 successful_frames++;
460 total_read_time_ns += frame_read_ns;
461 if (frame_read_ns > max_read_time_ns) {
462 max_read_time_ns = frame_read_ns;
463 }
464
465 // Log statistics every 30 successful frames
466 if (successful_frames % 30 == 0) {
467 double avg_read_ms = (double)total_read_time_ns / (double)successful_frames / NS_PER_MS;
468 double max_read_ms = (double)max_read_time_ns / NS_PER_MS;
469 double null_rate = (double)null_frame_count * 100.0 / (double)total_attempts;
470 log_info_every(3 * US_PER_SEC_INT,
471 "FRAME_STATS[%lu]: avg_read=%.2f ms, max_read=%.2f ms, null_rate=%.1f%% "
472 "(%lu null/%lu attempts)",
473 successful_frames, avg_read_ms, max_read_ms, null_rate, null_frame_count, total_attempts);
474 }
475 } else {
476 null_frame_count++;
477 }
478
479 // Handle EOF with loop
480 if (!frame && ffmpeg_decoder_at_end(source->video_decoder)) {
481 if (source->loop_enabled && source->type == MEDIA_SOURCE_FILE) {
482 log_debug("End of file reached, rewinding for loop");
483 if (media_source_rewind(source) == ASCIICHAT_OK) {
484 // Try reading again after rewind
486 }
487 }
488 }
489
490 // Unlock shared decoder
491 if (source->is_shared_decoder) {
492 mutex_unlock(&source->decoder_mutex);
493 }
494
495 return frame;
496 }
497
498 default:
499 return NULL;
500 }
501}
502
504 if (!source) {
505 return false;
506 }
507
508 switch (source->type) {
509 case MEDIA_SOURCE_WEBCAM:
510 case MEDIA_SOURCE_TEST:
511 return true; // Webcam/test always has video
512
513 case MEDIA_SOURCE_FILE:
514 case MEDIA_SOURCE_STDIN:
515 return source->video_decoder && ffmpeg_decoder_has_video(source->video_decoder);
516
517 default:
518 return false;
519 }
520}
521
522/* ============================================================================
523 * Audio Operations
524 * ============================================================================ */
525
526size_t media_source_read_audio(media_source_t *source, float *buffer, size_t num_samples) {
527 if (!source || !buffer || num_samples == 0) {
528 return 0;
529 }
530
531 static uint64_t call_count = 0;
532 call_count++;
533 if (call_count <= 5 || call_count % 1000 == 0) {
534 log_info("media_source_read_audio #%lu: source_type=%d num_samples=%zu", call_count, source->type, num_samples);
535 }
536
537 // Check pause state (thread-safe)
538 mutex_lock(&source->pause_mutex);
539 bool is_paused = source->is_paused;
540 mutex_unlock(&source->pause_mutex);
541
542 // Return silence immediately if paused (maintaining position)
543 if (is_paused) {
544 memset(buffer, 0, num_samples * sizeof(float));
545 return num_samples;
546 }
547
548 switch (source->type) {
549 case MEDIA_SOURCE_WEBCAM:
550 case MEDIA_SOURCE_TEST:
551 // Webcam/test pattern don't provide audio
552 return 0;
553
554 case MEDIA_SOURCE_FILE:
555 case MEDIA_SOURCE_STDIN: {
556 if (!source->audio_decoder) {
557 log_warn("media_source_read_audio: audio_decoder is NULL!");
558 return 0;
559 }
560
561 // Lock seek_access_mutex to prevent audio callback from reading during seek
562 mutex_lock(&source->seek_access_mutex);
563
564 // Lock shared decoder if YouTube URL (protect against concurrent video thread access)
565 if (source->is_shared_decoder) {
566 mutex_lock(&source->decoder_mutex);
567 }
568
569 double audio_pos_before_read = ffmpeg_decoder_get_position(source->audio_decoder);
570 size_t samples_read = ffmpeg_decoder_read_audio_samples(source->audio_decoder, buffer, num_samples);
571 double audio_pos_after_read = ffmpeg_decoder_get_position(source->audio_decoder);
572
573 static double last_audio_pos = 0;
574 if (audio_pos_after_read >= 0 && last_audio_pos >= 0 && audio_pos_after_read < last_audio_pos) {
575 log_warn("AUDIO POSITION WENT BACKWARD: %.2f → %.2f (LOOPING!)", last_audio_pos, audio_pos_after_read);
576 }
577 if (audio_pos_after_read >= 0) {
578 last_audio_pos = audio_pos_after_read;
579 }
580
581 log_info_every(100 * US_PER_MS_INT, "Audio: read %zu samples, pos %.2f → %.2f", samples_read, audio_pos_before_read,
582 audio_pos_after_read);
583
584 // Handle EOF with loop
585 if (samples_read == 0 && ffmpeg_decoder_at_end(source->audio_decoder)) {
586 if (source->loop_enabled && source->type == MEDIA_SOURCE_FILE) {
587 log_debug("End of file reached (audio), rewinding for loop");
588 if (media_source_rewind(source) == ASCIICHAT_OK) {
589 // Try reading again after rewind
590 samples_read = ffmpeg_decoder_read_audio_samples(source->audio_decoder, buffer, num_samples);
591 }
592 }
593 }
594
595 // Unlock shared decoder
596 if (source->is_shared_decoder) {
597 mutex_unlock(&source->decoder_mutex);
598 }
599
600 // Release seek_access_mutex
601 mutex_unlock(&source->seek_access_mutex);
602
603 return samples_read;
604 }
605
606 default:
607 return 0;
608 }
609}
610
612 if (!source) {
613 return false;
614 }
615
616 switch (source->type) {
617 case MEDIA_SOURCE_WEBCAM:
618 case MEDIA_SOURCE_TEST:
619 return false; // Webcam/test don't have audio
620
621 case MEDIA_SOURCE_FILE:
622 case MEDIA_SOURCE_STDIN:
623 return source->audio_decoder && ffmpeg_decoder_has_audio(source->audio_decoder);
624
625 default:
626 return false;
627 }
628}
629
630/* ============================================================================
631 * Playback Control
632 * ============================================================================ */
633
634void media_source_set_loop(media_source_t *source, bool loop) {
635 if (!source) {
636 return;
637 }
638
639 source->loop_enabled = loop;
640
641 if (loop && source->type == MEDIA_SOURCE_STDIN) {
642 log_warn("Loop mode not supported for stdin input (cannot seek)");
643 }
644}
645
647 if (!source) {
648 return true;
649 }
650
651 switch (source->type) {
652 case MEDIA_SOURCE_WEBCAM:
653 case MEDIA_SOURCE_TEST:
654 return false; // Webcam never ends
655
656 case MEDIA_SOURCE_FILE:
657 case MEDIA_SOURCE_STDIN:
658 if (!source->video_decoder) {
659 return true;
660 }
661 // If loop is enabled, we never truly reach end
662 if (source->loop_enabled && source->type == MEDIA_SOURCE_FILE) {
663 return false;
664 }
665 return ffmpeg_decoder_at_end(source->video_decoder);
666
667 default:
668 return true;
669 }
670}
671
672asciichat_error_t media_source_rewind(media_source_t *source) {
673 if (!source) {
674 return ERROR_INVALID_PARAM;
675 }
676
677 switch (source->type) {
678 case MEDIA_SOURCE_WEBCAM:
679 case MEDIA_SOURCE_TEST:
680 return ASCIICHAT_OK; // No-op for webcam
681
682 case MEDIA_SOURCE_FILE:
683 if (!source->video_decoder || !source->audio_decoder) {
684 return ERROR_INVALID_PARAM;
685 }
686
687 // Lock shared decoder if YouTube URL (protect against concurrent thread access)
688 if (source->is_shared_decoder) {
689 mutex_lock(&source->decoder_mutex);
690 }
691
692 // Rewind video decoder
693 asciichat_error_t video_result = ffmpeg_decoder_rewind(source->video_decoder);
694 if (video_result != ASCIICHAT_OK) {
695 if (source->is_shared_decoder) {
696 mutex_unlock(&source->decoder_mutex);
697 }
698 return video_result;
699 }
700
701 // For YouTube (shared decoder), don't rewind audio separately
702 // For local files (separate decoders), rewind audio too
703 asciichat_error_t result = ASCIICHAT_OK;
704 if (!source->is_shared_decoder) {
705 result = ffmpeg_decoder_rewind(source->audio_decoder);
706 }
707
708 // Unlock shared decoder
709 if (source->is_shared_decoder) {
710 mutex_unlock(&source->decoder_mutex);
711 }
712
713 return result;
714
715 case MEDIA_SOURCE_STDIN:
716 return ERROR_NOT_SUPPORTED; // Cannot seek stdin
717
718 default:
719 return ERROR_INVALID_PARAM;
720 }
721}
722
724 // DEPRECATED: This function is deprecated and causes audio playback issues.
725 // Seeking the audio decoder to match video position every ~1 second causes
726 // audio skips and loops. Audio and video naturally stay synchronized when
727 // decoding independently from the same source.
728
729 log_warn("DEPRECATED: media_source_sync_audio_to_video() called - this function causes audio playback issues. "
730 "Use natural decode rates instead.");
731
732 if (!source) {
733 return ERROR_INVALID_PARAM;
734 }
735
736 // Only applicable to FILE and STDIN types
737 if (source->type != MEDIA_SOURCE_FILE && source->type != MEDIA_SOURCE_STDIN) {
738 return ASCIICHAT_OK; // No-op for WEBCAM/TEST
739 }
740
741 // For shared decoders (YouTube URLs), no sync needed (same decoder for both)
742 if (source->is_shared_decoder) {
743 return ASCIICHAT_OK;
744 }
745
746 // NOTE: The actual sync code is disabled because it causes problems.
747 // Get video decoder's current PTS
748 // double video_pts = ffmpeg_decoder_get_position(source->video_decoder);
749 // If we have a valid PTS, seek audio decoder to that position
750 // if (video_pts >= 0.0) {
751 // return ffmpeg_decoder_seek_to_timestamp(source->audio_decoder, video_pts);
752 // }
753
754 return ASCIICHAT_OK;
755}
756
757asciichat_error_t media_source_seek(media_source_t *source, double timestamp_sec) {
758 if (!source) {
759 SET_ERRNO(ERROR_INVALID_PARAM, "Media source is NULL");
760 return ERROR_INVALID_PARAM;
761 }
762
763 if (timestamp_sec < 0.0) {
764 SET_ERRNO(ERROR_INVALID_PARAM, "Timestamp must be >= 0.0");
765 return ERROR_INVALID_PARAM;
766 }
767
768 // WEBCAM and TEST sources: no-op (always return OK)
769 if (source->type == MEDIA_SOURCE_WEBCAM || source->type == MEDIA_SOURCE_TEST) {
770 return ASCIICHAT_OK;
771 }
772
773 // STDIN: not supported
774 if (source->type == MEDIA_SOURCE_STDIN) {
775 SET_ERRNO(ERROR_NOT_SUPPORTED, "Cannot seek stdin");
776 return ERROR_NOT_SUPPORTED;
777 }
778
779 asciichat_error_t result = ASCIICHAT_OK;
780
781 // Clear audio playback buffer BEFORE seeking to prevent old audio from being queued
782 // This ensures fresh audio starts playing immediately after seek completes
783 if (source->audio_ctx) {
784 audio_context_t *audio_ctx = (audio_context_t *)source->audio_ctx;
785 if (audio_ctx->playback_buffer) {
786 audio_ring_buffer_clear(audio_ctx->playback_buffer);
787 }
788 }
789
790 // The seeking_in_progress flag in decoders blocks prefetch thread
791 // seeking_in_progress coordinates with audio callback via condition variable
792
793 uint64_t seek_start_ns = time_get_ns();
794 if (source->video_decoder) {
795 double video_pos_before = ffmpeg_decoder_get_position(source->video_decoder);
796 asciichat_error_t video_err = ffmpeg_decoder_seek_to_timestamp(source->video_decoder, timestamp_sec);
797 double video_pos_after = ffmpeg_decoder_get_position(source->video_decoder);
798 uint64_t video_seek_ns = time_elapsed_ns(seek_start_ns, time_get_ns());
799 if (video_err != ASCIICHAT_OK) {
800 log_warn("Video seek to %.2f failed: error code %d (took %.1fms)", timestamp_sec, video_err,
801 (double)video_seek_ns / NS_PER_MS);
802 result = video_err;
803 } else {
804 log_info("Video SEEK: %.2f → %.2f sec (target %.2f, took %.1fms)", video_pos_before, video_pos_after,
805 timestamp_sec, (double)video_seek_ns / NS_PER_MS);
806 }
807 }
808
809 // Seek audio decoder (if separate from video)
810 if (source->audio_decoder && !source->is_shared_decoder) {
811 log_info("=== Starting audio seek to %.2f sec ===", timestamp_sec);
812 uint64_t audio_seek_start_ns = time_get_ns();
813 double audio_pos_before = ffmpeg_decoder_get_position(source->audio_decoder);
814 log_info("Audio position before seek: %.2f", audio_pos_before);
815 asciichat_error_t audio_err = ffmpeg_decoder_seek_to_timestamp(source->audio_decoder, timestamp_sec);
816 double audio_pos_after = ffmpeg_decoder_get_position(source->audio_decoder);
817 uint64_t audio_seek_ns = time_elapsed_ns(audio_seek_start_ns, time_get_ns());
818 log_info("Audio position after seek: %.2f", audio_pos_after);
819 if (audio_err != ASCIICHAT_OK) {
820 log_warn("Audio seek to %.2f failed: error code %d (took %.1fms)", timestamp_sec, audio_err,
821 (double)audio_seek_ns / NS_PER_MS);
822 result = audio_err;
823 } else {
824 log_info("Audio SEEK COMPLETE: %.2f → %.2f sec (target %.2f, took %.1fms)", audio_pos_before, audio_pos_after,
825 timestamp_sec, (double)audio_seek_ns / NS_PER_MS);
826 }
827 }
828
829 // Prefetch thread automatically resumes when seeking_in_progress is cleared and signaled
830
831 return result;
832}
833
834media_source_type_t media_source_get_type(media_source_t *source) {
835 return source ? source->type : MEDIA_SOURCE_TEST;
836}
837
839 if (!source) {
840 return -1.0;
841 }
842
843 switch (source->type) {
844 case MEDIA_SOURCE_WEBCAM:
845 case MEDIA_SOURCE_TEST:
846 return -1.0; // Infinite
847
848 case MEDIA_SOURCE_FILE:
849 case MEDIA_SOURCE_STDIN:
850 if (!source->video_decoder) {
851 return -1.0;
852 }
854
855 default:
856 return -1.0;
857 }
858}
859
861 if (!source) {
862 return -1.0;
863 }
864
865 switch (source->type) {
866 case MEDIA_SOURCE_WEBCAM:
867 case MEDIA_SOURCE_TEST:
868 return -1.0; // No position concept
869
870 case MEDIA_SOURCE_FILE:
871 case MEDIA_SOURCE_STDIN:
872 if (!source->video_decoder) {
873 return -1.0;
874 }
876
877 default:
878 return -1.0;
879 }
880}
881
883 if (!source) {
884 return 0.0;
885 }
886
887 switch (source->type) {
888 case MEDIA_SOURCE_WEBCAM:
889 case MEDIA_SOURCE_TEST:
890 return 0.0; // Variable rate, no fixed FPS
891
892 case MEDIA_SOURCE_FILE:
893 case MEDIA_SOURCE_STDIN:
894 if (!source->video_decoder) {
895 return 0.0;
896 }
898
899 default:
900 return 0.0;
901 }
902}
903
904/* ============================================================================
905 * Pause/Resume Control
906 * ============================================================================ */
907
909 if (!source) {
910 return;
911 }
912 mutex_lock(&source->pause_mutex);
913 source->is_paused = true;
914 mutex_unlock(&source->pause_mutex);
915}
916
918 if (!source) {
919 return;
920 }
921 mutex_lock(&source->pause_mutex);
922 source->is_paused = false;
923 mutex_unlock(&source->pause_mutex);
924}
925
927 if (!source) {
928 return false;
929 }
930 mutex_lock(&source->pause_mutex);
931 bool paused = source->is_paused;
932 mutex_unlock(&source->pause_mutex);
933 return paused;
934}
935
937 if (!source) {
938 return;
939 }
940 mutex_lock(&source->pause_mutex);
941 source->is_paused = !source->is_paused;
942 mutex_unlock(&source->pause_mutex);
943}
944
945void media_source_set_audio_context(media_source_t *source, void *audio_ctx) {
946 if (source) {
947 source->audio_ctx = audio_ctx;
948 }
949}
bool ffmpeg_decoder_at_end(ffmpeg_decoder_t *decoder)
double ffmpeg_decoder_get_position(ffmpeg_decoder_t *decoder)
image_t * ffmpeg_decoder_read_video_frame(ffmpeg_decoder_t *decoder)
bool ffmpeg_decoder_has_video(ffmpeg_decoder_t *decoder)
double ffmpeg_decoder_get_video_fps(ffmpeg_decoder_t *decoder)
asciichat_error_t ffmpeg_decoder_rewind(ffmpeg_decoder_t *decoder)
bool ffmpeg_decoder_has_audio(ffmpeg_decoder_t *decoder)
asciichat_error_t ffmpeg_decoder_start_prefetch(ffmpeg_decoder_t *decoder)
Start the background frame prefetching thread.
asciichat_error_t ffmpeg_decoder_seek_to_timestamp(ffmpeg_decoder_t *decoder, double timestamp_sec)
void ffmpeg_decoder_destroy(ffmpeg_decoder_t *decoder)
ffmpeg_decoder_t * ffmpeg_decoder_create(const char *path)
size_t ffmpeg_decoder_read_audio_samples(ffmpeg_decoder_t *decoder, float *buffer, size_t num_samples)
ffmpeg_decoder_t * ffmpeg_decoder_create_stdin(void)
double ffmpeg_decoder_get_duration(ffmpeg_decoder_t *decoder)
void audio_ring_buffer_clear(audio_ring_buffer_t *rb)
char * platform_strdup(const char *s)
bool media_source_at_end(media_source_t *source)
Definition source.c:646
bool media_source_is_paused(media_source_t *source)
Definition source.c:926
bool media_source_has_video(media_source_t *source)
Definition source.c:503
size_t media_source_read_audio(media_source_t *source, float *buffer, size_t num_samples)
Definition source.c:526
bool media_source_has_audio(media_source_t *source)
Definition source.c:611
double media_source_get_position(media_source_t *source)
Definition source.c:860
void media_source_destroy(media_source_t *source)
Definition source.c:360
void media_source_resume(media_source_t *source)
Definition source.c:917
double media_source_get_video_fps(media_source_t *source)
Definition source.c:882
double media_source_get_duration(media_source_t *source)
Definition source.c:838
void media_source_pause(media_source_t *source)
Definition source.c:908
image_t * media_source_read_video(media_source_t *source)
Definition source.c:408
asciichat_error_t media_source_seek(media_source_t *source, double timestamp_sec)
Definition source.c:757
media_source_type_t media_source_get_type(media_source_t *source)
Definition source.c:834
void media_source_set_audio_context(media_source_t *source, void *audio_ctx)
Definition source.c:945
media_source_t * media_source_create(media_source_type_t type, const char *path)
Definition source.c:172
asciichat_error_t media_source_sync_audio_to_video(media_source_t *source)
Definition source.c:723
void media_source_toggle_pause(media_source_t *source)
Definition source.c:936
asciichat_error_t media_source_rewind(media_source_t *source)
Definition source.c:672
void media_source_set_loop(media_source_t *source, bool loop)
Definition source.c:634
FFmpeg decoder state for video and audio decoding.
Media source for video and audio capture.
Definition source.c:31
webcam_context_t * webcam_ctx
Webcam context (NULL for non-webcam types)
Definition source.c:37
bool is_shared_decoder
True if both streams share same decoder (YouTube URLs)
Definition source.c:43
char * file_path
File path (for FILE type)
Definition source.c:51
void * audio_ctx
Audio context for clearing buffers on seek (opaque)
Definition source.c:55
char * original_youtube_url
Original YouTube URL for re-extraction.
Definition source.c:52
media_source_type_t type
Type of media source (webcam, file, stdin, test)
Definition source.c:32
mutex_t seek_access_mutex
Protects decoder access during seeks.
Definition source.c:47
mutex_t decoder_mutex
Protects shared decoder access (YouTube URLs)
Definition source.c:46
mutex_t pause_mutex
Protects pause state.
Definition source.c:48
unsigned short int webcam_index
Webcam device index.
Definition source.c:38
ffmpeg_decoder_t * audio_decoder
Audio decoder (separate or shared with video)
Definition source.c:42
bool is_paused
Whether playback is paused.
Definition source.c:34
ffmpeg_decoder_t * video_decoder
Video decoder (separate or shared with audio)
Definition source.c:41
bool loop_enabled
Whether to loop playback (for files)
Definition source.c:33
int mutex_init(mutex_t *mutex)
Definition threading.c:16
int mutex_destroy(mutex_t *mutex)
Definition threading.c:21
uint64_t time_get_ns(void)
Definition util/time.c:48
uint64_t time_elapsed_ns(uint64_t start_ns, uint64_t end_ns)
Definition util/time.c:90
asciichat_error_t webcam_init_context(webcam_context_t **ctx, unsigned short int device_index)
image_t * webcam_read(void)
image_t * webcam_read_context(webcam_context_t *ctx)
void webcam_cleanup_context(webcam_context_t *ctx)
asciichat_error_t yt_dlp_extract_stream_url(const char *url, const char *yt_dlp_options, char *output_url, size_t output_size)
Definition yt_dlp.c:147