לדלג לתוכן

צינור מדיה בעל דרישות זמן

נתונים שמגיעים בקצב שאינו מחכה לנו

בקובץ רגיל אפשר לעיתים להאט את הקורא בלי לאבד מידע. במיקרופון או במצלמה המקור ממשיך לייצר דגימות לפי שעון משלו. אם הצרכן מפגר, צריך להחליט היכן ממתינים הנתונים ומה עושים כאשר המקום נגמר. תור בלתי מוגבל דוחה את ההחלטה ומחליף אובדן מיידי בגידול זיכרון ובהשהיה הולכת וגדלה.

נבחין בין קצב דגימה, מספר ערוצים וגודל דגימה. בקצב של ארבעים ושמונה אלף מסגרות לשנייה, שני ערוצים ושני בתים לדגימת ערוץ, שנייה אחת מכילה מאה תשעים ושניים אלף בתים. מסגרת שמע מכילה דגימה לכל ערוץ; היא אינה זהה לבית או לדגימה יחידה כאשר יש כמה ערוצים.

באפר גדול קונה זמן ומשלם בהשהיה

בממשקי שמע כמו ALSA קיימים באפרים ותקופות עבודה שמאפשרים להעביר קבוצות מסגרות. אם היישום לא מספק נתונים בזמן לניגון, מתרחש חוסר. אם היישום לא קורא הקלטה בזמן, נתונים יכולים להידרס. שני המצבים מכונים לעיתים חריגות זרם, אבל כיוון התקלה שונה.

הגדלת הבאפר יכולה להפחית חריגות תחת עומס רגעי, אך מגדילה את זמן ההמתנה עד שהמידע נשמע או מעובד. היא אינה מתקנת צרכן שאיטי מן המקור לאורך זמן. נחשב את תקציב הזמן לכל שלב ונמדוד גם את זנב ההתפלגות, מפני שעיכוב נדיר יכול להספיק לשבירת זרם.

שרת מדיה כמו PipeWire מחבר צמתים ויציאות בגרף ומנהל משא ומתן על פורמטים ובאפרים. מנהל מושב נפרד יכול לקבוע מדיניות חיבור. הפעלת השרת אינה הוכחה שהיישום קיבל את הקצב או הפורמט שביקש, ולכן נקרא את התוצאה שהתקבלה ונבחן המרות שנוספו למסלול.

צינור מוגבל שממחיש צרכן איטי

נריץ מודל שמייצר מאה מסגרות עבודה בקצב יעד של עשרה מילישניות למסגרת. כל מסגרת מייצגת תקופת מדיה ומחזיקה באפר קבוע. הצרכן דורש עשרים וחמש מילישניות, ולכן לאורך זמן אינו יכול לעמוד בקצב. המדיניות שנבחרה היא השמטת המסגרת החדשה כשהתור מלא.

use std::sync::mpsc::{self, TrySendError};
use std::thread;
use std::time::{Duration, Instant};

struct Frame { sequence: u64, due: Instant, samples: Vec<i16> }
fn main() {
    let (sender, receiver) = mpsc::sync_channel::<Frame>(4);
    let consumer = thread::spawn(move || {
        let mut received = 0;
        let mut previous = None;
        let mut max_age = Duration::ZERO;
        for frame in receiver {
            if let Some(old) = previous { assert!(frame.sequence > old); }
            previous = Some(frame.sequence);
            max_age = max_age.max(frame.due.elapsed());
            let checksum: i64 = frame.samples.iter().map(|&v| i64::from(v)).sum();
            std::hint::black_box(checksum);
            thread::sleep(Duration::from_millis(25));
            received += 1;
        }
        (received, max_age)
    });
    let start = Instant::now();
    let mut dropped = 0;
    for sequence in 0..100u64 {
        let due = start + Duration::from_millis(sequence * 10);
        if let Some(wait) = due.checked_duration_since(Instant::now()) {
            thread::sleep(wait);
        }
        let frame = Frame { sequence, due, samples: vec![1; 960] };
        match sender.try_send(frame) {
            Ok(()) => {}
            Err(TrySendError::Full(_frame)) => dropped += 1,
            Err(TrySendError::Disconnected(_frame)) => panic!("consumer stopped"),
        }
    }
    drop(sender);
    let (received, max_age) = consumer.join().unwrap();
    assert_eq!(received + dropped, 100);
    println!("received={received} dropped={dropped} max_age_us={}",
             max_age.as_micros());
}

המספר המדויק של השמטות תלוי בתזמון ואינו קבוע צפוי. לעומת זאת, משוואת השימור חייבת להתקיים. לכל היותר ארבע מסגרות נמצאות בתור, אחת בעיבוד ואחת אצל המייצר, ולכן תוכן הדגימות מוגבל לשישה באפרים בגודל אלף תשע מאות ועשרים בתים, בנוסף למטא נתונים ולתקורות הקצאה.

המודל אינו מערכת זמן אמת, וההקצאה בכל מחזור אינה מתאימה למסלול שמע בעל דרישות קשיחות. במימוש כזה נכין מאגר באפרים מראש ונעביר בעלות בלי להקצות או לבצע רישום איטי בזמן הקריאה החוזרת. גם המדידה שלנו משתמשת בשעון תוכנה, ולא בשעון הדגימה של התקן.

שעונים שונים יוצרים בעיה גם בלי עומס

שני התקנים שמצהירים על אותו קצב יכולים לסטות מעט זה מזה. לאורך זמן הפער מצטבר, ולכן תור ביניהם מתמלא או מתרוקן גם כשהצרכן מהיר מספיק בממוצע. פתרון עשוי לכלול בחירת שעון מוביל, התאמת קצב או דגימה מחדש. איפוס התור מדי פעם מסתיר את ההצטברות במחיר הפרעה למידע.

נמדוד את מיקום הדגימה ואת חותמות הזמן שהממשק מספק, ונבדיל בין זמן הגעה לתהליך לבין זמן הלכידה. עבור וידאו ושמע משותפים נדרש גם יחס בין השעונים. חותמת זמן ממונוטוני וחישוב לפי זמן קיר אינם בני החלפה ללא תרגום מפורש.

תור הלכידה של מצלמה

בממשק V4L2 מתחילים בשאילתת יכולות ובמשא ומתן על פורמט. לאחר מכן מבקשים באפרים, בודקים את מספרם ואת מידותיהם בפועל, ממפים אותם ומכניסים אותם לתור של הדרייבר. מרגע שהבאפר בתור, היישום אינו רשאי לעבד אותו כאילו הוא בבעלותו. הוצאה מהתור מחזירה אותו לצרכן, והכנסה חוזרת מאפשרת לכידה נוספת.

נבדיל בין גודל ההקצאה לבין מספר הבתים התקפים במסגרת, ונשתמש במרחק השורות שהוחזר. בפורמט בעל כמה מישורים לכל מישור יכולים להיות גודל ומיפוי משלו. הנחה שכל תמונה היא מערך רציף של שלושה בתים לפיקסל שוברת פורמטים נפוצים גם בלי שום תקלה בדרייבר.

נבחר מצלמת מעבדה ונריץ את כלי התצפית הבא. הפקודה הראשונה מספקת את היכולות, השנייה את הפורמטים, והשלישית לוכדת מספר סופי של מסגרות בפורמט שהוגדר בהתקן. נשמור יחד עם הקובץ גם את תיאור הפורמט; קובץ בתים גולמי אינו נושא בהכרח כותרת שמסבירה אותו.

v4l2-ctl --device=/dev/video0 --all
v4l2-ctl --device=/dev/video0 --list-formats-ext
v4l2-ctl --device=/dev/video0 --stream-mmap=4 --stream-count=60 \
    --stream-to=capture.raw

כשצרכן תמונה איטי מחזיק באפרים זמן רב, הוא יכול לרוקן את מאגר הבאפרים הזמינים לדרייבר. העתקה לבאפר פרטי מחזירה את באפר הלכידה מוקדם אך עולה ברוחב פס ובזיכרון. העברה ללא העתקה חוסכת עבודה אך מאריכה בעלות. ההחלטה תלויה בתקציב ובחוזה, ולא בסיסמה שכל העתקה אסורה.

להרחבה ניעזר בתיעוד שרת המדיה ובממשק לכידה באמצעות מיפוי.