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

۱. نمونهٔ صوت بدون شناسنامهٔ داده کافی نیست

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

نگه‌دارندگان PyTorch در نوشتهٔ ۵ اکتبر ۲۰۲۶ دربارهٔ ابزارهای رسانه، TorchCodec را برای خواندن و نوشتن رسانه و TorchAudio را برای تبدیل‌های صوت معرفی می‌کنند. این تغییر، بهانهٔ مناسبی برای بازبینی قرارداد بین مراحل است. مثال ما مستقل از این چارچوب‌هاست؛ نصب TorchCodec یا FFmpeg لازم ندارد و اجرای موفق آن، سازگاری یک مهاجرت واقعی را تأیید نمی‌کند.

طبق مستندات AudioSamples در TorchCodec، دادهٔ صوت، نمونه‌های شناور با شکل کانال در نمونه است و نرخ نمونه‌برداری با هرتز بیان می‌شود. اینجا همان آرایش را شکل معیار انتخاب می‌کنیم، اما ورودی می‌تواند زمان در کانال باشد؛ فراخواننده باید محور را صریح اعلام کند. نام آرایه یا کوچک‌تر بودن یک بُعد، مدرک کافی برای حدس زدن آن نیست.

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

۲. جابه‌جایی محور با تغییر شکل آرایه فرق دارد

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

ردیف نخست آرایه، کانال چپ فرضی با مقدارهای ۰٫۵، ۰٫۲۵، منفی ۰٫۲۵ و منفی ۰٫۵ است. ردیف دوم مقدارهای صفر، منفی ۰٫۲۵، مثبت ۰٫۲۵ و صفر دارد. شکل معیار (2, 4) است. اگر همین داده از رابطی با شکل (4, 2) برسد، ستون‌ها کانال هستند و باید محور یک به جایگاه صفر منتقل شود.

تابع moveaxis در NumPy برای جابه‌جایی محور به کار می‌رود. در مقابل، reshape شکل و ترتیب پیمایش را مدیریت می‌کند و معنای صوتی کانال را نمی‌داند. روی همین داده، reshape کردن ورودی زمان‌اول به دو در چهار، مقدارهای کانال را مانند جابه‌جایی محور کنار هم نمی‌چیند. برابر شدن شکل خروجی، اثبات درستی ترتیب نمونه‌ها نیست.

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

۳. کد کامل اعتبارسنجی و هجده بررسی اجراشده

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

کد با پایتون ۳٫۱۴٫۵ و NumPy نسخهٔ ۲٫۴٫۶ اجرا شده است. آن را در فایل audio_array_contracts.py ذخیره کنید و با python audio_array_contracts.py اجرا کنید. تابع فقط آرایهٔ دوبعدی، غیرخالی و شناور می‌پذیرد؛ نرخ و تعداد کانال، عدد صحیح مثبت پایتون هستند و bool پذیرفته نمی‌شود. سیاست دامنه نیز صریح است: مقدار بیرون از بازهٔ منفی یک تا مثبت یک رد می‌شود.

"""Synthetic array-boundary experiment; no decoder, recording, model or resampler."""
import json
import platform
import numpy as np


def audio_contract(data, *, sample_rate, channel_axis, expected_channels):
    """Return an owned channels-first float64 array and its declared rate."""
    if type(sample_rate) is not int or sample_rate <= 0:
        raise ValueError('sample_rate must be a positive Python int, in Hz')
    if type(channel_axis) is not int or channel_axis not in (0, 1):
        raise ValueError('channel_axis must explicitly be 0 or 1')
    if type(expected_channels) is not int or expected_channels <= 0:
        raise ValueError('expected_channels must be a positive Python int')
    if type(data) is not np.ndarray or data.ndim != 2 or 0 in data.shape:
        raise ValueError('Expected a nonempty two-dimensional NumPy array')
    if not np.issubdtype(data.dtype, np.floating):
        raise ValueError('Expected normalized floating-point samples, not PCM integers')
    if data.shape[channel_axis] != expected_channels:
        raise ValueError('Declared channel count does not match the chosen axis')
    if not np.isfinite(data).all() or np.any(np.abs(data) > 1):
        raise ValueError('Samples must be finite and within [-1, 1]')
    canonical = np.array(np.moveaxis(data, channel_axis, 0),
                         dtype=np.float64, copy=True)
    return canonical, sample_rate


def run_example():
    # Eight scalar values form four stereo frames, not eight frames.
    source = np.array([[0.5, 0.25, -0.25, -0.5],
                       [0.0, -0.25, 0.25, 0.0]], dtype=np.float32)
    x, rate = audio_contract(source, sample_rate=8000,
                             channel_axis=0, expected_channels=2)
    channels, frames = x.shape
    duration = frames / rate
    mono = np.mean(x, axis=0, dtype=np.float64)
    checks = {}

    def check(name, passed):
        if not bool(passed):
            raise AssertionError(name)
        if name in checks:
            raise AssertionError('Duplicate check name: ' + name)
        checks[name] = True

    def rejects(name, candidate, **changes):
        settings = dict(sample_rate=8000, channel_axis=0, expected_channels=2)
        settings.update(changes)
        try:
            audio_contract(candidate, **settings)
        except ValueError:
            check(name, True)
        else:
            raise AssertionError(name)

    check('canonical channels-first shape and float64 dtype',
          x.shape == (2, 4) and x.dtype == np.float64)
    check('known left channel preserved exactly',
          np.array_equal(x[0], [0.5, 0.25, -0.25, -0.5]))
    check('four frames at 8000 Hz span 0.0005 seconds',
          frames == 4 and rate == 8000 and duration == 0.0005)
    time_first = source.T
    converted, _ = audio_contract(time_first, sample_rate=8000,
                                  channel_axis=1, expected_channels=2)
    check('explicit time-first axis conversion preserves both channels',
          np.array_equal(converted, x))
    check('reshape alone does not transpose channel meaning',
          not np.array_equal(time_first.reshape(2, 4), x))
    check('equal channel mean matches independent four-value answer',
          np.array_equal(mono, [0.25, 0.0, 0.0, -0.25]))
    opposite = np.array([[0.5, -0.5], [-0.5, 0.5]])
    opposite_cf, _ = audio_contract(opposite, sample_rate=8000,
                                    channel_axis=0, expected_channels=2)
    check('opposite channels cancel under the chosen mean rule',
          np.array_equal(np.mean(opposite_cf, axis=0), [0.0, 0.0]))
    mono_input, _ = audio_contract(source[:1], sample_rate=8000,
                                   channel_axis=0, expected_channels=1)
    check('one declared channel keeps its sample sequence',
          np.array_equal(np.mean(mono_input, axis=0), x[0]))
    relabeled, changed_rate = audio_contract(source, sample_rate=16000,
                                            channel_axis=0, expected_channels=2)
    check('changing only declared rate leaves samples and halves duration',
          np.array_equal(relabeled, x) and relabeled.shape[1] / changed_rate == duration / 2)
    mutable = source.copy()
    owned, _ = audio_contract(mutable, sample_rate=8000,
                              channel_axis=0, expected_channels=2)
    mutable[0, 0] = 0.0
    check('source mutation cannot alter the copied canonical array',
          owned[0, 0] == 0.5 and not np.shares_memory(owned, mutable))
    rejects('wrong axis conflicts with declared two channels', time_first)
    rejects('zero sample rate rejected', source, sample_rate=0)
    rejects('boolean sample rate rejected', source, sample_rate=True)
    rejects('integer PCM-like array rejected without an implicit scale',
            np.array([[1, 2], [3, 4]], dtype=np.int16))
    bad_nan = source.copy()
    bad_nan[0, 0] = np.nan
    rejects('NaN sample rejected', bad_nan)
    bad_inf = source.copy()
    bad_inf[0, 0] = np.inf
    rejects('infinite sample rejected', bad_inf)
    bad_range = source.copy()
    bad_range[0, 0] = 1.25
    rejects('out-of-range sample rejected rather than clipped', bad_range)
    rejects('empty time axis rejected', np.empty((2, 0), dtype=np.float64))
    assert len(checks) == 18
    return {
        'python_version': platform.python_version(),
        'numpy_version': np.__version__,
        'execution_scope': 'Synthetic NumPy arrays only; no TorchCodec, FFmpeg, audio device or ASR model executed',
        'input_shape_channels_first': list(source.shape),
        'scalar_sample_values': int(source.size),
        'channels': channels, 'frames': frames, 'sample_rate_hz': rate,
        'duration_seconds': duration, 'duration_milliseconds': duration * 1000,
        'equal_channel_mean': mono.tolist(),
        'metadata_only_rate_hz': changed_rate,
        'metadata_only_duration_seconds': relabeled.shape[1] / changed_rate,
        'checks_passed': len(checks), 'checks': checks,
    }


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

NaN و بی‌نهایت پیش از مرحلهٔ بعدی کنار گذاشته می‌شوند؛ آن‌ها سکوت یا مقدار صفر محسوب نمی‌شوند. آرایهٔ PCM عدد صحیح نیز بی‌توضیح مقیاس نمی‌گیرد. تبدیل عدد صحیح به دامنهٔ شناور، قرارداد جداگانه‌ای دارد که در درس ساخت WAV با PCM16 بررسی شده است. اینجا برش دامنه، پرکردن نمونهٔ گمشده یا تعمیر ورودی انجام نمی‌دهیم تا خطای مرحلهٔ قبلی پنهان نماند.

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

۴. خواندن نتیجه و انتخاب قاعدهٔ تک‌کاناله

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

برنامه هجده بررسی موفق ثبت می‌کند. تعداد فریم چهار، تعداد کانال دو و مدت نیم میلی‌ثانیه است. میانگین دو کانال در هر فریم، دنبالهٔ ۰٫۲۵، صفر، صفر و منفی ۰٫۲۵ می‌دهد. این پاسخ را می‌توان از چهار جمع دوتایی مستقل به دست آورد. بررسی‌ها، ورودی با محور اشتباه، نرخ صفر، نرخ بولی، عدد صحیح، NaN، بی‌نهایت، دامنهٔ نامجاز و محور زمانی خالی را نیز پوشش می‌دهند.

{
  "python_version": "3.14.5",
  "numpy_version": "2.4.6",
  "execution_scope": "Synthetic NumPy arrays only; no TorchCodec, FFmpeg, audio device or ASR model executed",
  "input_shape_channels_first": [
    2,
    4
  ],
  "scalar_sample_values": 8,
  "channels": 2,
  "frames": 4,
  "sample_rate_hz": 8000,
  "duration_seconds": 0.0005,
  "duration_milliseconds": 0.5,
  "equal_channel_mean": [
    0.25,
    0.0,
    0.0,
    -0.25
  ],
  "metadata_only_rate_hz": 16000,
  "metadata_only_duration_seconds": 0.00025,
  "checks_passed": 18,
  "checks": {
    "canonical channels-first shape and float64 dtype": true,
    "known left channel preserved exactly": true,
    "four frames at 8000 Hz span 0.0005 seconds": true,
    "explicit time-first axis conversion preserves both channels": true,
    "reshape alone does not transpose channel meaning": true,
    "equal channel mean matches independent four-value answer": true,
    "opposite channels cancel under the chosen mean rule": true,
    "one declared channel keeps its sample sequence": true,
    "changing only declared rate leaves samples and halves duration": true,
    "source mutation cannot alter the copied canonical array": true,
    "wrong axis conflicts with declared two channels": true,
    "zero sample rate rejected": true,
    "boolean sample rate rejected": true,
    "integer PCM-like array rejected without an implicit scale": true,
    "NaN sample rejected": true,
    "infinite sample rejected": true,
    "out-of-range sample rejected rather than clipped": true,
    "empty time axis rejected": true
  }
}

در mean با محور صریح، محور صفرِ آرایهٔ معیار حذف می‌شود و زمان باقی می‌ماند. اگر axis را ننویسیم، میانگین کل آرایه یک عدد می‌شود و دیگر دنبالهٔ صوت نیست. استفاده از انبارهٔ float64 انتخاب عددی مثال است؛ این انتخاب، اطلاعاتی را که در ضبط یا تبدیل قبلی از بین رفته بازسازی نمی‌کند و دقت نامحدود هم نمی‌دهد.

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

۵. نرخ اعلام‌شده، بازنمونه‌برداری و تمرین‌های پاسخ‌دار

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

در آزمایش دوم فقط نرخ اعلام‌شده را ۱۶۰۰۰ می‌گذاریم. چهار فریم و مقدارهای نمونه عوض نمی‌شوند، اما مدت محاسبه‌شده به یک‌چهارم میلی‌ثانیه می‌رسد. این تغییر شناسنامه، بازنمونه‌برداری نیست. گزینهٔ نرخ خروجی AudioDecoder متعلق به یک رمزگشای واقعی است؛ تابع ما آن گزینه را اجرا نمی‌کند. روش تبدیل همراه با بررسی aliasing را در آموزش بازنمونه‌برداری صوت دنبال کنید.

تمرین اول: چرا مدت را با هشت تقسیم بر ۸۰۰۰ حساب نمی‌کنیم؟ پاسخ: هشت، شمار مقدارهای دو کانال است؛ تنها چهار فریم زمانی داریم. تمرین دوم: آیا شکل دو در چهار برای پذیرش کافی است؟ پاسخ: خیر؛ دامنه، مقدار متناهی، نوع، نرخ و معنای محور باید با قرارداد تولیدکننده سازگار باشند.

تمرین سوم: آیا جایگزین کردن reshape با moveaxis همیشه خطای کانال را حل می‌کند؟ پاسخ: خیر؛ محور درست باید از قبل معلوم باشد و دادهٔ مبهم نیازمند شاهد مستقل است. تمرین چهارم: آیا نرخ ۱۶ کیلوهرتز، از همین چهار فریم اطلاعات تازه می‌سازد؟ پاسخ: خیر؛ فقط تفسیر زمان عوض شده است. منابع در ۲۴ مهر ۱۴۰۵ خوانده شده‌اند؛ تاریخ نوشتهٔ خبری با تاریخ دسترسی فرق دارد و مستندات TorchCodec، اجرای آن کتابخانه در این درس نیستند.

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

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