پاسخ‌گوی تلفنی باید بداند چه زمانی می‌تواند نوبت تازهٔ پاسخ را آغاز کند. مکث کوتاه لزوماً پایان درخواست نیست و بی‌گفتار بودن چند قاب، پایان معنایی جمله را ثابت نمی‌کند. در این درس، فقط یک قاعدهٔ قابل آزمون می‌سازیم: پس از مشاهدهٔ برچسب گفتار، با رسیدن شمار برچسب‌های بی‌گفتارِ پیوسته به آستانه، رویداد پایان صادر شود. ورودی را خودمان می‌نویسیم؛ هیچ صوت، مدل VAD یا تشخیص گفتار واقعی اجرا نمی‌شود.

۱. برچسب قاب را از پایان واقعی صحبت جدا کنیم

قاب هم‌اندازه و تفاوت برچسب با صدای واقعی
تصویر مفهومی: قاب هم‌اندازه و تفاوت برچسب با صدای واقعی؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

هر عضو دنباله، یک قاب فرضیِ بیست میلی‌ثانیه‌ای را نمایندگی می‌کند. True یعنی برچسب گفتار و False یعنی برچسب بی‌گفتارِ تألیفی. False نه اثبات سکوت کامل آکوستیکی است و نه تضمین اینکه گوینده قصد ادامه ندارد. از بلندی صدا، واژه‌ها، زبان یا هویت گوینده اطلاعاتی نداریم. مدت قاب نیز قرارداد ورودی است؛ از نرخ نمونه‌برداری فایل محاسبه نشده است.

در بخش Endpointing مستندات NVIDIA NeMo-Speech.cpp، مسیر مبتنی بر خروجی رمزگشا از مسیر مبتنی بر خط زمانی VAD جدا شده است. همین تفکیک نشان می‌دهد نام «سکوت» باید به سیگنال مورد استفاده وابسته باشد. مثال ما هیچ‌یک از آن مسیرها یا تنظیمات پیش‌فرضشان را بازسازی نمی‌کند؛ تنها تصمیم پس از دریافت برچسب‌های معلوم را بررسی می‌کند.

راهنمای Riva در بخش آغاز و پایان گفتار نیز دربارهٔ قاب‌های blank رمزگشا توضیح می‌دهد. blank رمزگشا را با False این تمرین یکسان نمی‌گیریم. زمان آخرین قابِ برچسب‌خوردهٔ گفتار، مرجع داخلی ماست؛ پایان فیزیکی حرف آخر درون آن قاب مشخص نیست. بنابراین فاصلهٔ محاسبه‌شده، تأخیر اندازه‌گیری‌شدهٔ تجربهٔ کاربر نیست.

۲. شمارش پیوسته و زمان دقیق صدور رویداد

مکث کوتاه میان برچسب‌ها بدون پایان زودهنگام
تصویر مفهومی: مکث کوتاه میان برچسب‌ها بدون پایان زودهنگام؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

آستانه را سه قاب بی‌گفتار قرار می‌دهیم؛ حاصل‌ضرب سه در بیست، شصت میلی‌ثانیه است. تا پیش از اولین True، هر تعداد False نوبت ایجاد نمی‌کند. پس از True، شمارندهٔ بی‌گفتار از صفر شروع می‌شود. هر False آن را یک واحد زیاد می‌کند و هر True تازه، شمارنده را صفر می‌کند. قاعده دربارهٔ پیوستگی است؛ جمع مکث‌های جداگانه جای آن را نمی‌گیرد.

برچسب هر قاب را در انتهای همان قاب در دسترس فرض می‌کنیم. اگر گفتار در بازهٔ صفر تا بیست برچسب خورده و سه قاب بعدی False باشند، رویداد در هشتاد میلی‌ثانیه صادر می‌شود. آخرین انتهای قاب گفتار بیست است و انتظار قراردادی شصت. صادر کردن رویداد در شروع قاب سوم، یک قاب زودتر از قرارداد خواهد بود. برنامه زمان‌ها را با اندیس و میلی‌ثانیهٔ صحیح می‌سازد؛ ساعت و sleep ندارد.

پس از پایان، حالت بسته می‌شود و Falseهای بیشتر همان پایان را تکرار نمی‌کنند. یک True بعدی، نوبت جدا می‌سازد. پایان فایل نیز به‌تنهایی پایان نوبت نیست؛ اگر شاهد کافی نرسیده باشد، نوبت باز را برمی‌گردانیم. این سیاستِ صریح برای دادهٔ ناقص است، نه تنها سیاست ممکن. در کاربرد واقعی، قطع اتصال، سقف مدت و لغو درخواست باید قراردادهای جدا داشته باشند.

۳. کد کامل و مرجع مستقل بر پایهٔ طول دنباله‌ها

فاصلهٔ آخرین برچسب گفتار تا رویداد پایان
تصویر مفهومی: فاصلهٔ آخرین برچسب گفتار تا رویداد پایان؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

فایل vad_turn_end.py با Python 3.14.5 و کتابخانهٔ استاندارد اجرا شده است. ظرف ورودی فقط list یا tuple دقیق و اعضا فقط bool دقیق‌اند؛ عدد صفر، یک، NaN و رشته به برچسب تبدیل نمی‌شوند. مدت قاب و تعداد قاب آستانه، صحیح مثبت‌اند و bool یا عدد اعشاری پذیرفته نمی‌شود. این کنترل، از اشتباه نوع جلوگیری می‌کند؛ درست بودن طبقه‌بندی صوت را نمی‌آزماید.

"""Original offline end-event rule on authored bool labels, not a VAD model."""
import itertools
import json
import math
import platform


def detect_turn_ends(labels, frame_ms=20, silence_frames=3):
    if type(labels) not in (list, tuple) or any(type(x) is not bool for x in labels):
        raise TypeError('Exact list/tuple of exact bool labels required')
    for value in (frame_ms, silence_frames):
        if type(value) is not int or value <= 0:
            raise ValueError('Frame milliseconds and silence frames must be exact positive ints')
    start = last_speech_end = None
    trailing = 0
    events = []
    for index, speech in enumerate(labels):
        frame_start = index * frame_ms
        frame_end = frame_start + frame_ms
        if speech:
            if start is None:
                start = frame_start
            last_speech_end = frame_end
            trailing = 0
        elif start is not None:
            trailing += 1
            if trailing == silence_frames:
                events.append({'start_ms': start,
                               'last_speech_label_end_ms': last_speech_end,
                               'trigger_ms': frame_end,
                               'observed_non_speech_ms': trailing * frame_ms})
                start = last_speech_end = None
                trailing = 0
    open_turn = None if start is None else {
        'start_ms': start, 'last_speech_label_end_ms': last_speech_end,
        'trailing_non_speech_ms': trailing * frame_ms}
    return {'events': events, 'open_turn': open_turn,
            'duration_ms': len(labels) * frame_ms,
            'threshold_ms': frame_ms * silence_frames}


def run_length_oracle(labels, frame_ms, silence_frames):
    """Independent tiny-fixture reference through consecutive run lengths."""
    cursor = 0
    start = last_speech_end = None
    trailing = 0
    events = []
    for speech, group in itertools.groupby(labels):
        count = sum(1 for _ in group)
        if speech:
            if start is None:
                start = cursor * frame_ms
            last_speech_end = (cursor + count) * frame_ms
            trailing = 0
        elif start is not None:
            if count >= silence_frames:
                events.append({'start_ms': start,
                               'last_speech_label_end_ms': last_speech_end,
                               'trigger_ms': (cursor + silence_frames) * frame_ms,
                               'observed_non_speech_ms': silence_frames * frame_ms})
                start = last_speech_end = None
                trailing = 0
            else:
                trailing = count
        cursor += count
    return {'events': events,
            'open_turn': None if start is None else {
                'start_ms': start, 'last_speech_label_end_ms': last_speech_end,
                'trailing_non_speech_ms': trailing * frame_ms},
            'duration_ms': len(labels) * frame_ms,
            'threshold_ms': frame_ms * silence_frames}


def rejects(call):
    try:
        call()
    except (ValueError, TypeError):
        return True
    return False


def run_example():
    labels = [False, False, True, True, False, True, False, False,
              False, False, True, False, False, False, True, True]
    before = labels.copy()
    actual = detect_turn_ends(labels)
    expected_events = [
        {'start_ms': 40, 'last_speech_label_end_ms': 120, 'trigger_ms': 180, 'observed_non_speech_ms': 60},
        {'start_ms': 200, 'last_speech_label_end_ms': 220, 'trigger_ms': 280, 'observed_non_speech_ms': 60}]
    short = [True, False, False]
    exact = [True, False, False, False]
    resumed = [True, False, False, True, False, False, False]
    all_fixtures = [list(bits) for n in range(7) for bits in itertools.product((False, True), repeat=n)]
    checks = {
        'hand_two_events_match_all_four_time_fields': actual['events'] == expected_events,
        'hand_final_open_turn_is_preserved': actual['open_turn'] == {
            'start_ms': 280, 'last_speech_label_end_ms': 320, 'trailing_non_speech_ms': 0},
        'duration_320_and_threshold_60_are_integer_milliseconds': actual['duration_ms'] == 320 and actual['threshold_ms'] == 60,
        'leading_non_speech_does_not_create_a_turn': detect_turn_ends([False] * 8)['events'] == []
            and detect_turn_ends([False] * 8)['open_turn'] is None,
        'empty_input_has_no_events_or_open_turn': detect_turn_ends([])['events'] == []
            and detect_turn_ends([])['open_turn'] is None and detect_turn_ends([])['duration_ms'] == 0,
        'continuous_speech_remains_open_without_forced_close': detect_turn_ends([True] * 8)['events'] == []
            and detect_turn_ends([True] * 8)['open_turn']['last_speech_label_end_ms'] == 160,
        'one_frame_short_of_threshold_remains_open': detect_turn_ends(short)['events'] == []
            and detect_turn_ends(short)['open_turn']['trailing_non_speech_ms'] == 40,
        'exact_threshold_fires_at_end_of_third_non_speech_frame': detect_turn_ends(exact)['events'] == [
            {'start_ms': 0, 'last_speech_label_end_ms': 20, 'trigger_ms': 80, 'observed_non_speech_ms': 60}],
        'longer_non_speech_does_not_repeat_same_end_event': len(detect_turn_ends(exact + [False] * 10)['events']) == 1,
        'resumed_speech_resets_consecutive_non_speech_count': detect_turn_ends(resumed)['events'] == [
            {'start_ms': 0, 'last_speech_label_end_ms': 80, 'trigger_ms': 140, 'observed_non_speech_ms': 60}],
        'new_speech_after_close_rearms_a_distinct_turn': detect_turn_ends(exact + exact)['events'][1] == {
            'start_ms': 80, 'last_speech_label_end_ms': 100, 'trigger_ms': 160, 'observed_non_speech_ms': 60},
        'one_frame_threshold_has_explicit_20ms_labeled_wait': detect_turn_ends([True, False], 20, 1)['events'][0]['trigger_ms'] == 40,
        'increasing_threshold_delays_this_same_isolated_turn': detect_turn_ends([True] + [False] * 5, 20, 5)['events'][0]['trigger_ms']
            - detect_turn_ends([True] + [False] * 5, 20, 3)['events'][0]['trigger_ms'] == 40,
        'frame_duration_scaling_changes_all_time_units': detect_turn_ends(exact, 10)['events'][0] == {
            'start_ms': 0, 'last_speech_label_end_ms': 10, 'trigger_ms': 40, 'observed_non_speech_ms': 30},
        'all_127_short_bit_fixtures_match_run_length_oracle': len(all_fixtures) == 127
            and all(detect_turn_ends(bits, 20, threshold) == run_length_oracle(bits, 20, threshold)
                    for bits in all_fixtures for threshold in (1, 2, 3)),
        'non_bool_labels_nan_numbers_and_wrong_containers_rejected': all(rejects(lambda x=x: detect_turn_ends(x))
            for x in ([0], [1], [math.nan], [None], ['speech'], '101', iter([True]))),
        'nonpositive_bool_float_nan_duration_and_threshold_rejected': all(rejects(lambda x=x: detect_turn_ends([True], x, 3))
            and rejects(lambda x=x: detect_turn_ends([True], 20, x)) for x in (0, -1, True, 20.0, math.nan, math.inf, '20')),
        'input_preserved_tuple_agrees_and_prefix_cannot_emit_early': labels == before
            and detect_turn_ends(tuple(labels)) == actual
            and detect_turn_ends(labels[:8])['events'] == [],
    }
    assert len(checks) == 18 and all(type(v) is bool and v for v in checks.values()), checks
    return {'python': platform.python_version(), 'scope': 'Authored bool labels only; no audio, learned VAD, ASR, semantic turn detector or real latency measurement',
            'label_contract': {'true': 'authored speech label', 'false': 'authored non-speech label',
                               'frame_ms': 20, 'silence_frames': 3, 'no_end_of_input_forced_close': True},
            'fixture_labels': labels, 'observed': actual,
            'oracle_bit_sequences': 127, 'oracle_threshold_choices': [1, 2, 3], 'checks': checks}


if __name__ == '__main__':
    print(json.dumps(run_example(), ensure_ascii=False, indent=2))

مسیر اصلی قاب‌به‌قاب پیش می‌رود. مرجع دوم با groupby پایتون طول گروه‌های متوالیِ برچسب برابر را می‌شمارد. دنباله را مرتب نمی‌کنیم؛ مرتب کردن، زمان را نابود می‌کند. مرجع گروهی، برای یک گروه بلند False، لحظهٔ رسیدن به نخستین سه قاب را حساب می‌کند، نه پایان کل گروه را. پاسخ دستی نیز مستقل از توافق این دو پیاده‌سازی ثبت شده است.

هجده گروه بررسی اجرا شده‌اند. یکی از آن‌ها ۱۲۷ دنبالهٔ دودویی با طول صفر تا شش را برای آستانه‌های یک، دو و سه مقایسه می‌کند؛ این پوشش محدود، اثبات همهٔ طول‌ها نیست. مرز یک قاب کمتر، تساوی آستانه، ادامهٔ گفتار پس از مکث، ورود نوبت تازه و حفظ ورودی بررسی می‌شوند. تعداد حالت‌های درون یک گروه را به‌عنوان مقاله یا آزمون نام‌دار تازه نمی‌شماریم.

۴. دو پایان معلوم و یک نوبت ناتمام

شروع دوباره پس از پایان و باقی ماندن نوبت باز
تصویر مفهومی: شروع دوباره پس از پایان و باقی ماندن نوبت باز؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

دنبالهٔ اصلی شانزده قاب و در مجموع ۳۲۰ میلی‌ثانیه دارد. دو قاب نخست False هستند. نخستین نوبت در چهل شروع می‌شود؛ پس از یک مکث کوتاه، برچسب گفتار تا انتهای ۱۲۰ دیده می‌شود. سه False بعدی رویداد را در ۱۸۰ می‌سازند. این گزارش، شروع نوبت، آخرین انتهای قاب گفتار و زمان رویداد را در سه فیلد جدا نگه می‌دارد تا معنای آن‌ها مخلوط نشود.

{
  "python": "3.14.5",
  "scope": "Authored bool labels only; no audio, learned VAD, ASR, semantic turn detector or real latency measurement",
  "label_contract": {
    "true": "authored speech label",
    "false": "authored non-speech label",
    "frame_ms": 20,
    "silence_frames": 3,
    "no_end_of_input_forced_close": true
  },
  "fixture_labels": [
    false,
    false,
    true,
    true,
    false,
    true,
    false,
    false,
    false,
    false,
    true,
    false,
    false,
    false,
    true,
    true
  ],
  "observed": {
    "events": [
      {
        "start_ms": 40,
        "last_speech_label_end_ms": 120,
        "trigger_ms": 180,
        "observed_non_speech_ms": 60
      },
      {
        "start_ms": 200,
        "last_speech_label_end_ms": 220,
        "trigger_ms": 280,
        "observed_non_speech_ms": 60
      }
    ],
    "open_turn": {
      "start_ms": 280,
      "last_speech_label_end_ms": 320,
      "trailing_non_speech_ms": 0
    },
    "duration_ms": 320,
    "threshold_ms": 60
  },
  "oracle_bit_sequences": 127,
  "oracle_threshold_choices": [
    1,
    2,
    3
  ],
  "checks": {
    "hand_two_events_match_all_four_time_fields": true,
    "hand_final_open_turn_is_preserved": true,
    "duration_320_and_threshold_60_are_integer_milliseconds": true,
    "leading_non_speech_does_not_create_a_turn": true,
    "empty_input_has_no_events_or_open_turn": true,
    "continuous_speech_remains_open_without_forced_close": true,
    "one_frame_short_of_threshold_remains_open": true,
    "exact_threshold_fires_at_end_of_third_non_speech_frame": true,
    "longer_non_speech_does_not_repeat_same_end_event": true,
    "resumed_speech_resets_consecutive_non_speech_count": true,
    "new_speech_after_close_rearms_a_distinct_turn": true,
    "one_frame_threshold_has_explicit_20ms_labeled_wait": true,
    "increasing_threshold_delays_this_same_isolated_turn": true,
    "frame_duration_scaling_changes_all_time_units": true,
    "all_127_short_bit_fixtures_match_run_length_oracle": true,
    "non_bool_labels_nan_numbers_and_wrong_containers_rejected": true,
    "nonpositive_bool_float_nan_duration_and_threshold_rejected": true,
    "input_preserved_tuple_agrees_and_prefix_cannot_emit_early": true
  }
}

نوبت دوم از ۲۰۰ شروع و آخرین قاب گفتارش در ۲۲۰ تمام می‌شود؛ پایان قراردادی آن در ۲۸۰ است. همان لحظه، قاب بعدی True می‌تواند نوبت تازه‌ای را شروع کند. نوبت پایانی از ۲۸۰ تا انتهای داده باز می‌ماند و آخرین انتهای قاب گفتارش ۳۲۰ است. نبود رویداد سوم، خطای پنهان نیست؛ نتیجهٔ سیاست «بدون بستن اجباری در پایان ورودی» است.

اگر آستانه را تغییر دهیم، مکثی که قبلاً داخل نوبت بود ممکن است آن را بشکند، یا دو خوشه در یک نوبت بمانند. فقط در مثال تک‌نوبتیِ جدا، زیاد کردن آستانه از سه به پنج قاب، زمان رویداد را چهل میلی‌ثانیه عقب می‌برد. از این شاهد، بهبود کیفیت عمومی نتیجه نمی‌گیریم. برای مسیر واقعی، معماری تلفن هوشمند سازمانی اجزای دیگر پاسخ را نیز مطرح می‌کند.

۵. چهار تمرین پاسخ‌دار و محدودیت معنای پایان

تفکیک آزمون آفلاین از ارزیابی واقعی مکالمه
تصویر مفهومی: تفکیک آزمون آفلاین از ارزیابی واقعی مکالمه؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

تمرین اول: چرا دو False پس از True کافی نیست؟ پاسخ: آستانه سه قاب است؛ فقط چهل میلی‌ثانیه بی‌گفتارِ برچسبی دیده‌ایم. حالت باز می‌ماند و پایان ورودی هم شاهد سوم تولید نمی‌کند.

تمرین دوم: اگر پس از دو False یک True برسد، شمارنده چه می‌شود؟ پاسخ: صفر می‌شود؛ برای رویداد بعدی سه False پیوستهٔ تازه لازم است. مکث قبلی را با مکث بعدی جمع نمی‌کنیم، ولی شروع همان نوبت تا زمان بسته شدن حفظ می‌شود.

تمرین سوم: قاب ده میلی‌ثانیه‌ای با آستانه سه چه انتظاری می‌دهد؟ پاسخ: سی میلی‌ثانیه نسبت به آخرین انتهای قاب گفتار. برای الگوی یک True و سه False، زمان رویداد چهل است. این عدد، سرعت محاسبهٔ پایتون یا تأخیر شبکه نیست.

تمرین چهارم: آیا رویداد پایان، کامل بودن جمله یا درستی رونویسی را ثابت می‌کند؟ پاسخ: خیر؛ این کد حتی متن و صوت ندارد. آموزش هم‌ترازی و خطای واژه نیز معیار دیگری را بررسی می‌کند. برای سنجش واقعی، برچسب مرجع و سناریوی مکث و ادامه لازم است. منابع در ۲۴ مهر ۱۴۰۵ بازخوانی شده‌اند؛ هیچ مدل، سرویس یا کیفیت محصول در این درس تأیید نشده است.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.