🔧 رفع خطای MediaFoundationReader

پیاده‌سازی کامل ConvertToWavAsync با پشتیبانی از تمام فرمت‌های صوتی
👨‍💻 توسعه‌دهنده: هادی خزاعی اصل
🏢 شرکت: فن آوران ساحر علم
📅 تاریخ: یکشنبه ۱۲ مهر ۱۴۰۵
📦 پروژه: xAiApi

🔍 گام ۱: تحلیل ریشه خطا

در کتابخانه NAudio، کلاس MediaFoundationReader فقط مسیر فایل را به عنوان ورودی می‌پذیرد و constructor ای برای Stream ندارد:

❌ خطای کامپایل:
// ❌ این کد کار نمی‌کند
new MediaFoundationReader(memoryStream);  // Error: No constructor takes Stream

Constructor های موجود MediaFoundationReader:

Constructor وضعیت
MediaFoundationReader(string audioFile) ✅ موجود
MediaFoundationReader(string audioFile, MediaFoundationReaderSettings settings) ✅ موجود
MediaFoundationReader(Stream stream) ❌ موجود نیست

💡 گام ۲: استراتژی اصلاح

برای حل این مشکل، از یک رویکرد ترکیبی بر اساس نوع فرمت صوتی استفاده می‌کنیم:

🎵 WAV (PCM)

  • استفاده مستقیم از WaveFileReader
  • بدون نیاز به فایل موقت
  • فقط در صورت نیاز Resample

🎤 MP3

  • استفاده از Mp3FileReader
  • پشتیبانی از MemoryStream
  • بدون نیاز به فایل موقت

📦 M4A / OGG / WebM

  • نوشتن به فایل موقت
  • استفاده از MediaFoundationReader(path)
  • حذف فایل پس از پردازش

🔄 Resample

  • تبدیل به 16kHz Mono 16-bit
  • استفاده از MediaFoundationResampler
  • خروجی WAV استاندارد

🛠️ گام ۳: پیاده‌سازی ConvertToWavAsync

MODIFY xAiApi/Providers/Extractors/XAudioFileContentExtractor.cs
using System;
using System.IO;
using System.Linq;
using NAudio.Wave;
using Whisper.net;
using System.Threading;
using xAiModels.Models;
using NAudio.MediaFoundation;
using System.Threading.Tasks;
using xAiApi.Interfaces.Extractors;

namespace xAiApi.Providers.Extractors
{
    public class XAudioFileContentExtractor : IXAudioFileContentExtractor
    {
        // ... (کدهای قبلی)

        /// <summary>
        /// Audio Format Converting to WAV 16kHz Mono 16-bit PCM ...
        /// </summary>
        private async Task<Stream> ConvertToWavAsync(
            Stream inputStream,
            string mimeType,
            CancellationToken cancellationToken
        )
        {
            return await Task.Run(() =>
            {
                // ۱. کپی به MemoryStream برای Seekable بودن
                var memoryStream = new MemoryStream();
                inputStream.CopyTo(memoryStream);
                memoryStream.Position = 0;

                // ۲. فرمت هدف: 16kHz, 16-bit, Mono (الزامی برای Whisper)
                var targetFormat = new WaveFormat(16000, 16, 1);
                string tempFilePath = null;

                try
                {
                    // ۳. دریافت Reader مناسب بر اساس MIME Type
                    WaveStream reader = GetAudioReader(memoryStream, mimeType, ref tempFilePath);

                    if (reader == null)
                    {
                        throw new NotSupportedException(
                            $"Unsupported audio format: {mimeType}"
                        );
                    }

                    // ۴. بررسی نیاز به تبدیل
                    var needsConversion =
                        reader.WaveFormat.SampleRate != 16000 ||
                        reader.WaveFormat.Channels != 1 ||
                        reader.WaveFormat.BitsPerSample != 16 ||
                        reader.WaveFormat.Encoding != WaveFormatEncoding.Pcm;

                    var outputStream = new MemoryStream();

                    if (!needsConversion)
                    {
                        // فرمت از قبل صحیح است - فقط کپی کن
                        reader.CopyTo(outputStream);
                        reader.Dispose();
                    }
                    else
                    {
                        // ۵. Resample به فرمت هدف
                        MediaFoundationApi.Startup();
                        using var resampler = new MediaFoundationResampler(
                            reader,
                            targetFormat
                        );
                        resampler.ResamplerQuality = 60; // کیفیت بالا

                        WaveFileWriter.WriteWavFileToStream(outputStream, resampler);
                        reader.Dispose();
                    }

                    outputStream.Position = 0;
                    return outputStream;
                }
                finally
                {
                    // ۶. پاکسازی فایل موقت (در صورت وجود)
                    if (!string.IsNullOrEmpty(tempFilePath) &&
                        File.Exists(tempFilePath))
                    {
                        try
                        {
                            File.Delete(tempFilePath);
                        }
                        catch { /* Ignore cleanup errors */ }
                    }

                    memoryStream.Dispose();
                }
            }, cancellationToken);
        }
    }
}

🎯 گام ۴: متد کمکی GetAudioReader

این متد بر اساس MIME Type، Reader مناسب را انتخاب می‌کند:

/// <summary>
/// Get appropriate WaveStream reader based on MIME type ...
/// </summary>
/// <param name="stream">Input stream (MemoryStream)</param>
/// <param name="mimeType">MIME type of audio file</param>
/// <param name="tempFilePath">Path to temp file (if created)</param>
/// <returns>WaveStream reader or null if unsupported</returns>
private WaveStream GetAudioReader(
    MemoryStream stream,
    string mimeType,
    ref string tempFilePath
)
{
    try
    {
        var normalizedMime = mimeType?.ToLowerInvariant() ?? string.Empty;

        switch (normalizedMime)
        {
            // WAV: استفاده مستقیم از MemoryStream
            case "audio/wav":
            case "audio/wave":
            case "audio/x-wav":
                return new WaveFileReader(stream);

            // MP3: استفاده مستقیم از MemoryStream
            case "audio/mpeg":
            case "audio/mp3":
                return new Mp3FileReader(stream);

            // M4A, OGG, WebM, MP4: نیاز به فایل موقت
            case "audio/m4a":
            case "audio/mp4":
            case "audio/aac":
            case "audio/ogg":
            case "audio/webm":
            case "audio/x-m4a":
                // ۱. تولید نام فایل موقت
                var extension = normalizedMime switch
                {
                    "audio/m4a" or "audio/mp4" or "audio/aac" or "audio/x-m4a" => ".m4a",
                    "audio/ogg" => ".ogg",
                    "audio/webm" => ".webm",
                    _ => ".tmp"
                };
                tempFilePath = Path.Combine(
                    Path.GetTempPath(),
                    $"audio_{Guid.NewGuid()}{extension}"
                );

                // ۲. نوشتن Stream به فایل موقت
                stream.Position = 0;
                using (var fileStream = File.Create(tempFilePath))
                {
                    stream.CopyTo(fileStream);
                }

                // ۳. استفاده از MediaFoundationReader با مسیر فایل
                MediaFoundationApi.Startup();
                return new MediaFoundationReader(tempFilePath);

            default:
                // تلاش عمومی با MediaFoundationReader
                tempFilePath = Path.Combine(
                    Path.GetTempPath(),
                    $"audio_{Guid.NewGuid()}.tmp"
                );
                stream.Position = 0;
                using (var fileStream = File.Create(tempFilePath))
                {
                    stream.CopyTo(fileStream);
                }
                try
                {
                    MediaFoundationApi.Startup();
                    return new MediaFoundationReader(tempFilePath);
                }
                catch
                {
                    return null;
                }
        }
    }
    catch (Exception)
    {
        return null;
    }
}
}

🔄 گام ۵: به‌روزرسانی ExtractRichAsync

متد ExtractRichAsync باید mimeType را به ConvertToWavAsync ارسال کند:

public async Task<XFileExtractionResult> ExtractRichAsync(
    Stream fileStream,
    string fileName,
    string mimeType,
    CancellationToken cancellationToken = default
)
{
    var result = new XFileExtractionResult
    {
        FileName = fileName,
        MimeType = mimeType
    };

    try
    {
        using var memoryStream = new MemoryStream();
        await fileStream.CopyToAsync(memoryStream, cancellationToken);
        memoryStream.Position = 0;

        // ✅ ارسال mimeType به ConvertToWavAsync
        using var wavStream = await ConvertToWavAsync(
            memoryStream,
            mimeType,
            cancellationToken
        );

        using var factory = WhisperFactory.FromPath(whisperModelPath);
        using var processor = factory.CreateBuilder()
            .WithLanguage(language)
            .Build();

        var segments = new System.Text.StringBuilder();
        await foreach (var segment in processor.ProcessAsync(wavStream, cancellationToken))
        {
            segments.Append(segment.Text);
        }

        result.AudioTranscript = segments.ToString().Trim();
        result.Text = result.AudioTranscript;
    }
    catch (Exception ex)
    {
        result.ErrorMessage = $"Audio extraction failed: {ex.Message}";
    }

    return result;
}

📋 گام ۶: کد کامل کلاس XAudioFileContentExtractor

using System;
using System.IO;
using System.Linq;
using NAudio.Wave;
using Whisper.net;
using System.Threading;
using xAiModels.Models;
using NAudio.MediaFoundation;
using System.Threading.Tasks;
using xAiApi.Interfaces.Extractors;

namespace xAiApi.Providers.Extractors
{
    /// <summary>
    /// Extracts text content from Audio files using Whisper ...
    /// </summary>
    public class XAudioFileContentExtractor : IXAudioFileContentExtractor
    {
        /// <summary>
        /// Supported MIME Types ...
        /// </summary>
        private static readonly string[] SupportedMimeTypes =
        [
            "audio/mpeg",
            "audio/mp3",
            "audio/wav",
            "audio/wave",
            "audio/x-wav",
            "audio/ogg",
            "audio/m4a",
            "audio/mp4",
            "audio/aac",
            "audio/x-m4a",
            "audio/webm"
        ];

        private readonly string language;
        private readonly string whisperModelPath;

        public XAudioFileContentExtractor() : this(
            language: "fa",
            whisperModelPath: "Models/ggml-base.bin"
        )
        { }

        public XAudioFileContentExtractor(
            string whisperModelPath = "Models/ggml-base.bin",
            string language = "fa"
        )
        {
            this.language = language;
            this.whisperModelPath = whisperModelPath;
        }

        /// <summary>
        /// Check if this extractor supports the specified MIME type ...
        /// </summary>
        public bool CanExtract(string mimeType)
        {
            return SupportedMimeTypes.Contains(
                mimeType?.ToLowerInvariant() ?? string.Empty
            );
        }

        /// <summary>
        /// Extract text content from file stream ...
        /// </summary>
        public async Task<string> ExtractAsync(
            Stream fileStream,
            string mimeType,
            CancellationToken cancellationToken = default
        )
        {
            var result = await ExtractRichAsync(
                fileStream,
                "audio",
                mimeType,
                cancellationToken
            );
            return result.AudioTranscript;
        }

        /// <summary>
        /// Extract content from stream as Rich Result ...
        /// </summary>
        public async Task<XFileExtractionResult> ExtractRichAsync(
            Stream fileStream,
            string fileName,
            string mimeType,
            CancellationToken cancellationToken = default
        )
        {
            var result = new XFileExtractionResult
            {
                FileName = fileName,
                MimeType = mimeType
            };

            try
            {
                using var memoryStream = new MemoryStream();
                await fileStream.CopyToAsync(memoryStream, cancellationToken);
                memoryStream.Position = 0;

                using var wavStream = await ConvertToWavAsync(
                    memoryStream,
                    mimeType,
                    cancellationToken
                );

                using var factory = WhisperFactory.FromPath(whisperModelPath);
                using var processor = factory.CreateBuilder()
                    .WithLanguage(language)
                    .Build();

                var segments = new System.Text.StringBuilder();
                await foreach (var segment in processor.ProcessAsync(wavStream, cancellationToken))
                {
                    segments.Append(segment.Text);
                }

                result.AudioTranscript = segments.ToString().Trim();
                result.Text = result.AudioTranscript;
            }
            catch (Exception ex)
            {
                result.ErrorMessage = $"Audio extraction failed: {ex.Message}";
            }

            return result;
        }

        /// <summary>
        /// Audio Format Converting to WAV 16kHz Mono 16-bit PCM ...
        /// </summary>
        private async Task<Stream> ConvertToWavAsync(
            Stream inputStream,
            string mimeType,
            CancellationToken cancellationToken
        )
        {
            return await Task.Run(() =>
            {
                var memoryStream = new MemoryStream();
                inputStream.CopyTo(memoryStream);
                memoryStream.Position = 0;

                var targetFormat = new WaveFormat(16000, 16, 1);
                string tempFilePath = null;

                try
                {
                    WaveStream reader = GetAudioReader(memoryStream, mimeType, ref tempFilePath);

                    if (reader == null)
                    {
                        throw new NotSupportedException(
                            $"Unsupported audio format: {mimeType}"
                        );
                    }

                    var needsConversion =
                        reader.WaveFormat.SampleRate != 16000 ||
                        reader.WaveFormat.Channels != 1 ||
                        reader.WaveFormat.BitsPerSample != 16 ||
                        reader.WaveFormat.Encoding != WaveFormatEncoding.Pcm;

                    var outputStream = new MemoryStream();

                    if (!needsConversion)
                    {
                        reader.CopyTo(outputStream);
                        reader.Dispose();
                    }
                    else
                    {
                        MediaFoundationApi.Startup();
                        using var resampler = new MediaFoundationResampler(
                            reader,
                            targetFormat
                        );
                        resampler.ResamplerQuality = 60;

                        WaveFileWriter.WriteWavFileToStream(outputStream, resampler);
                        reader.Dispose();
                    }

                    outputStream.Position = 0;
                    return outputStream;
                }
                finally
                {
                    if (!string.IsNullOrEmpty(tempFilePath) &&
                        File.Exists(tempFilePath))
                    {
                        try
                        {
                            File.Delete(tempFilePath);
                        }
                        catch { }
                    }

                    memoryStream.Dispose();
                }
            }, cancellationToken);
        }

        /// <summary>
        /// Get appropriate WaveStream reader based on MIME type ...
        /// </summary>
        private WaveStream GetAudioReader(
            MemoryStream stream,
            string mimeType,
            ref string tempFilePath
        )
        {
            try
            {
                var normalizedMime = mimeType?.ToLowerInvariant() ?? string.Empty;

                switch (normalizedMime)
                {
                    case "audio/wav":
                    case "audio/wave":
                    case "audio/x-wav":
                        return new WaveFileReader(stream);

                    case "audio/mpeg":
                    case "audio/mp3":
                        return new Mp3FileReader(stream);

                    case "audio/m4a":
                    case "audio/mp4":
                    case "audio/aac":
                    case "audio/x-m4a":
                    case "audio/ogg":
                    case "audio/webm":
                        var extension = normalizedMime switch
                        {
                            "audio/m4a" or "audio/mp4" or "audio/aac" or "audio/x-m4a" => ".m4a",
                            "audio/ogg" => ".ogg",
                            "audio/webm" => ".webm",
                            _ => ".tmp"
                        };
                        tempFilePath = Path.Combine(
                            Path.GetTempPath(),
                            $"audio_{Guid.NewGuid()}{extension}"
                        );

                        stream.Position = 0;
                        using (var fileStream = File.Create(tempFilePath))
                        {
                            stream.CopyTo(fileStream);
                        }

                        MediaFoundationApi.Startup();
                        return new MediaFoundationReader(tempFilePath);

                    default:
                        tempFilePath = Path.Combine(
                            Path.GetTempPath(),
                            $"audio_{Guid.NewGuid()}.tmp"
                        );
                        stream.Position = 0;
                        using (var fileStream = File.Create(tempFilePath))
                        {
                            stream.CopyTo(fileStream);
                        }
                        try
                        {
                            MediaFoundationApi.Startup();
                            return new MediaFoundationReader(tempFilePath);
                        }
                        catch
                        {
                            return null;
                        }
                }
            }
            catch
            {
                return null;
            }
        }
    }
}

⚠️ گام ۷: نکات مهم و ملاحظات

🪟 محدودیت ویندوز

  • MediaFoundationReader فقط روی ویندوز کار می‌کند
  • برای Linux/Mac نیاز به ffmpeg است
  • در Production حتماً بررسی کنید

📦 پکیج‌های NuGet

  • NAudio - کتابخانه اصلی
  • NAudio.MediaFoundation - برای M4A/OGG
  • Whisper.net - برای Speech-to-Text

🗑️ مدیریت فایل موقت

  • فایل‌های موقت در Path.GetTempPath()
  • در بلوک finally حذف می‌شوند
  • حتی در صورت خطا پاکسازی انجام می‌شود

🎯 فرمت هدف Whisper

  • Sample Rate: 16000 Hz
  • Channels: 1 (Mono)
  • Bits Per Sample: 16
  • Encoding: PCM

جدول فرمت‌ها و روش پردازش:

فرمت MIME Type Reader نیاز به فایل موقت
WAV audio/wav WaveFileReader ❌ خیر
MP3 audio/mpeg Mp3FileReader ❌ خیر
M4A audio/m4a MediaFoundationReader ✅ بله
OGG audio/ogg MediaFoundationReader ✅ بله
WebM audio/webm MediaFoundationReader ✅ بله
MP4 Audio audio/mp4 MediaFoundationReader ✅ بله
AAC audio/aac MediaFoundationReader ✅ بله
✅ نتیجه نهایی:
  • 🎯 خطای کامپایل MediaFoundationReader(stream) کاملاً رفع شد
  • 📦 پشتیبانی از تمام فرمت‌های صوتی رایج (WAV, MP3, M4A, OGG, WebM, MP4, AAC)
  • 🔄 تبدیل خودکار به فرمت استاندارد Whisper (16kHz Mono 16-bit PCM)
  • 🗑️ مدیریت صحیح فایل‌های موقت و جلوگیری از نشت منابع
  • ⚡ بهینه‌سازی: فایل‌های WAV/MP3 بدون فایل موقت پردازش می‌شوند
  • 🛡️ مدیریت خطا در تمام مراحل با cleanup تضمین‌شده
⚠️ نکته مهم برای Production:

اگر سرور شما Linux است، MediaFoundationReader کار نمی‌کند. در این صورت باید از ffmpeg به صورت process خارجی استفاده کنید یا از کتابخانه‌های جایگزین مانند NAudio.Lame و NVorbis بهره ببرید.