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

۱. قرارداد صوت: نمونه، فریم، کانال و بایت

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

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

رابطهٔ نرخ داده را مستقل از اندازهٔ فایل حساب می‌کنیم: ۸۰۰۰ ضرب در یک کانال ضرب در دو بایت، برابر ۱۶۰۰۰ بایت در ثانیه است. معادل بیتی آن ۱۲۸۰۰۰ بیت در ثانیه، فقط برای محمولهٔ PCM است. تعریف WAVEFORMATEX مایکروسافت رابطهٔ نرخ بایت و هم‌ترازی بلوک را مشخص می‌کند. عدد محاسبه‌شده در اینجا هزینهٔ سربرگ شبکه، بسته‌بندی RTP یا نرخ یک کدک فشرده نیست.

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

۲. دامنهٔ نامتقارن PCM16 و سیاست اشباع

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

بازهٔ عدد صحیح علامت‌دارِ مورد استفاده از منفی ۳۲۷۶۸ تا مثبت ۳۲۷۶۷ است. در قرارداد تألیفی ما، دامنهٔ بی‌واحد ورودی ابتدا به بازهٔ [-1, 32767/32768] محدود می‌شود، سپس در ۳۲۷۶۸ ضرب و گرد می‌شود. بنابراین منفی یک به کمینه می‌رسد، اما مثبت یک باید اشباع شود؛ بیشینهٔ مثبت نرمال‌شده اندکی کمتر از یک است. این سیاست را نباید با مقیاس‌کردن همهٔ نمونه‌ها بر اساس قلهٔ همان فایل اشتباه گرفت.

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

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

۳. کد کامل ساخت WAV و آزمون بازخوانی

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

کد با پایتون ۳٫۱۴٫۵ اجرا شده و بستهٔ جانبی لازم ندارد. آن را در فایل pcm16_wave.py ذخیره و با دستور python pcm16_wave.py اجرا کنید. برای مرجع API، مستندات ۳٫۱۴ خوانده شده‌اند. تغییرات پایتون ۳٫۱۵ قابلیت صوت شناور در wave را نیز معرفی می‌کند، اما این درس آن قابلیت یا نسخه را آزمایش نکرده است؛ قرارداد حاضر همان PCM16 است.

"""Synthetic mono PCM16 WAVE roundtrip, entirely in memory; no microphone or network."""
import io
import json
import math
import platform
import struct
import wave

SAMPLE_RATE_HZ = 8000
LOWER = -1.0
UPPER = 32767 / 32768
FIXTURE = (-1.25, -1.0, -0.5, -1/32768, 0.0, 1/32768,
           0.5, UPPER, 1.0, 1.25, 0.25, -0.25)


def to_pcm16(values):
    codes = []
    clipped = 0
    for value in values:
        if not math.isfinite(value):
            raise ValueError('A finite normalized amplitude is required')
        limited = min(UPPER, max(LOWER, value))
        clipped += int(limited != value)
        # Python round uses ties-to-even; scale and saturation are our contract.
        codes.append(round(limited * 32768))
    return tuple(codes), clipped


def make_wave(codes):
    payload = struct.pack('<' + 'h' * len(codes), *codes)
    stream = io.BytesIO()
    with wave.open(stream, 'wb') as writer:
        writer.setnchannels(1)
        writer.setsampwidth(2)
        writer.setframerate(SAMPLE_RATE_HZ)
        writer.writeframes(payload)
    return stream.getvalue(), payload


def fixture_chunks(blob):
    """Inspect complete chunks of this generated RIFF fixture, not untrusted files."""
    chunks = {}
    offset = 12
    while offset + 8 <= len(blob):
        chunk_id, size = struct.unpack_from('<4sI', blob, offset)
        start = offset + 8
        end = start + size
        if end > len(blob):
            raise ValueError('Truncated fixture chunk')
        if chunk_id in chunks:
            raise ValueError('Duplicate fixture chunk')
        chunks[chunk_id] = blob[start:end]
        offset = end + (size % 2)
    if offset != len(blob):
        raise ValueError('Incomplete fixture chunk boundary')
    return chunks


def run_example():
    codes, clipped = to_pcm16(FIXTURE)
    blob, payload = make_wave(codes)
    chunks = fixture_chunks(blob)
    fmt = struct.unpack('<HHIIHH', chunks[b'fmt '])
    with wave.open(io.BytesIO(blob), 'rb') as reader:
        channels = reader.getnchannels()
        sample_width = reader.getsampwidth()
        rate = reader.getframerate()
        frames = reader.getnframes()
        compression = reader.getcomptype()
        first = reader.readframes(5)
        rest = reader.readframes(100)
        end_of_file = reader.readframes(1)
    restored = struct.unpack('<' + 'h' * frames, first + rest)
    nonfinite_rejected = []
    for value in (float('nan'), float('inf'), -float('inf')):
        try:
            to_pcm16([value])
        except ValueError:
            nonfinite_rejected.append(True)
        else:
            nonfinite_rejected.append(False)
    expected = (-32768, -32768, -16384, -1, 0, 1,
                16384, 32767, 32767, 32767, 8192, -8192)
    checks = {
        'twelve_labeled_codes_match': codes == expected,
        'three_out_of_range_inputs_clipped': clipped == 3,
        'two_bytes_per_mono_frame': len(payload) == 2 * len(FIXTURE),
        'signed_little_endian_known_bytes': struct.pack('<hhhh', -32768, -1, 1, 32767).hex() == '0080ffff0100ff7f',
        'mono_channel_metadata': channels == 1,
        'sample_width_is_two_bytes': sample_width == 2,
        'sample_rate_is_eight_kilohertz': rate == 8000,
        'frame_count_is_twelve': frames == 12,
        'uncompressed_pcm_metadata': compression == 'NONE',
        'split_reads_preserve_frame_boundaries': len(first) == 10 and len(rest) == 14,
        'payload_bytes_roundtrip_exactly': first + rest == payload == chunks[b'data'],
        'signed_codes_roundtrip_exactly': restored == expected,
        'reading_after_end_returns_empty_bytes': end_of_file == b'',
        'riff_wave_identifiers': blob[:4] == b'RIFF' and blob[8:12] == b'WAVE',
        'riff_size_excludes_first_eight_bytes': struct.unpack_from('<I', blob, 4)[0] == len(blob) - 8,
        'format_fields_match_pcm_contract': fmt == (1, 1, 8000, 16000, 2, 16),
        'nonfinite_amplitudes_rejected': all(nonfinite_rejected),
        'halfway_rounding_is_ties_to_even': to_pcm16((0.5/32768, 1.5/32768, -1.5/32768))[0] == (0, 2, -2),
    }
    assert all(checks.values()), checks
    return {'python': platform.python_version(), 'sample_rate_hz': rate,
            'channels': channels, 'sample_width_bytes': sample_width,
            'frames': frames, 'duration_ms': frames / rate * 1000,
            'normalized_input_range': [LOWER, UPPER], 'clipped_inputs': clipped,
            'pcm_codes': codes, 'payload_bytes': len(payload),
            'wave_file_bytes': len(blob), 'pcm_payload_bits_per_second': rate * channels * sample_width * 8,
            'format_fields': dict(zip(('format_tag', 'channels', 'sample_rate_hz',
                                      'byte_rate', 'block_align', 'bits_per_sample'), fmt)),
            'checks': checks}


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

نشانهٔ < در قالب struct ترتیب کم‌ارزش‌ترین بایت در ابتدا را صریح می‌کند و h عدد علامت‌دار دوبایتی می‌سازد. برای نمونه، عدد مثبت یک به دو بایت 01 00 تبدیل می‌شود. تکیه بر ترتیب بومی پردازنده، قرارداد قابل انتقالی نمی‌سازد. جدول مورد انتظار کد مستقل نوشته شده است تا مقایسه فقط خروجی یک تابع با خودش نباشد.

ماژول wave در نسخهٔ ۳٫۱۴ پارامترهای نمونه و کانال را می‌گیرد و محموله را در ظرف WAVE می‌نویسد. جریان BytesIO در حافظه است؛ بستن نویسنده پیش از گرفتن نتیجه، نهایی شدن اطلاعات فایل را ممکن می‌کند. دو خواندن پیاپی، ابتدا پنج فریم و سپس باقی داده را می‌گیرند. این قطعه‌بندی خواندن، بسته‌بندی شبکه نیست و هیچ زمان‌بندی برخطی را شبیه‌سازی نمی‌کند.

۴. خواندن نتایج و بازرسی ظرف RIFF

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

اجرای ثبت‌شده، هجده بررسی موفق دارد: عددهای مرجع، سه اشباع، ترتیب بایت، فریم و کانال، پارامترهای قالب و برابری محمولهٔ بازخوانده‌شده بررسی شده‌اند. محموله ۲۴ بایت و فایل ساخته‌شده ۶۸ بایت است. تفاوت این دو در فایل کوچک حاضر، اطلاعات ظرف است. از این اختلاف نباید نتیجه گرفت که سربرگ همهٔ فایل‌های WAV همیشه ۴۴ بایت است؛ قالب می‌تواند قطعه‌های دیگری هم داشته باشد.

{
  "python": "3.14.5",
  "sample_rate_hz": 8000,
  "channels": 1,
  "sample_width_bytes": 2,
  "frames": 12,
  "duration_ms": 1.5,
  "normalized_input_range": [
    -1.0,
    0.999969482421875
  ],
  "clipped_inputs": 3,
  "pcm_codes": [
    -32768,
    -32768,
    -16384,
    -1,
    0,
    1,
    16384,
    32767,
    32767,
    32767,
    8192,
    -8192
  ],
  "payload_bytes": 24,
  "wave_file_bytes": 68,
  "pcm_payload_bits_per_second": 128000,
  "format_fields": {
    "format_tag": 1,
    "channels": 1,
    "sample_rate_hz": 8000,
    "byte_rate": 16000,
    "block_align": 2,
    "bits_per_sample": 16
  },
  "checks": {
    "twelve_labeled_codes_match": true,
    "three_out_of_range_inputs_clipped": true,
    "two_bytes_per_mono_frame": true,
    "signed_little_endian_known_bytes": true,
    "mono_channel_metadata": true,
    "sample_width_is_two_bytes": true,
    "sample_rate_is_eight_kilohertz": true,
    "frame_count_is_twelve": true,
    "uncompressed_pcm_metadata": true,
    "split_reads_preserve_frame_boundaries": true,
    "payload_bytes_roundtrip_exactly": true,
    "signed_codes_roundtrip_exactly": true,
    "reading_after_end_returns_empty_bytes": true,
    "riff_wave_identifiers": true,
    "riff_size_excludes_first_eight_bytes": true,
    "format_fields_match_pcm_contract": true,
    "nonfinite_amplitudes_rejected": true,
    "halfway_rounding_is_ties_to_even": true
  }
}

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

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

۵. چهار تمرین پاسخ‌دار و قدم بعدی آزمایش

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

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

تمرین سوم: چرا مثبت یک در شمارندهٔ اشباع آمده است؟ پاسخ: با مقیاس ۳۲۷۶۸، بیشینهٔ مثبت قابل نمایش ۳۲۷۶۷ تقسیم بر ۳۲۷۶۸ است. قرارداد ما پیش از گردکردن محدود می‌کند؛ پس مثبت یک بیرون بازه است. این پاسخ به قرارداد انتخاب‌شده وابسته است و نباید آن را بی‌توضیح به تمام کتابخانه‌های صوت نسبت داد.

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

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

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