diff --git a/Util/FFmpeg/FFmpegNativeEncoder.cs b/Util/FFmpeg/FFmpegNativeEncoder.cs index 8a1cc26..27162f4 100644 --- a/Util/FFmpeg/FFmpegNativeEncoder.cs +++ b/Util/FFmpeg/FFmpegNativeEncoder.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.InteropServices; using FFmpeg.AutoGen; using MelonLoader; using Unity.Collections; @@ -40,6 +41,36 @@ namespace ADOFAIRenderer.Util.FFmpeg /// /// 인코더에 전달할 설정값을 담는 구조체입니다. /// + /// + /// 오디오 인코딩에 전달할 설정값을 담는 구조체입니다. + /// + public struct AudioSettings + { + /// 입력 오디오의 샘플 레이트 (Hz). 예: 44100, 48000. + public int SampleRate; + + /// 입력 오디오의 채널 수. 예: 1(모노), 2(스테레오). + public int Channels; + + /// + /// 입력 오디오의 샘플 포맷. 기본값은 AV_SAMPLE_FMT_FLT (32-bit float). + /// Unity의 AudioClip.GetData()는 float[] 형태이므로 FLT가 적합합니다. + /// + public AVSampleFormat InputSampleFormat; + + /// + /// 사용할 오디오 코덱 ID. 기본값(0)이면 AAC를 사용합니다. + /// 예: AV_CODEC_ID_AAC, AV_CODEC_ID_FLAC, AV_CODEC_ID_OPUS 등. + /// + public AVCodecID AudioCodecId; + + /// + /// 오디오 비트레이트 (bps 단위). 기본값(0)이면 128000 bps를 사용합니다. + /// AAC의 경우 128000~320000 bps가 일반적입니다. + /// + public int Bitrate; + } + public struct EncoderSettings { /// 출력 비디오의 가로 해상도 (픽셀). @@ -71,6 +102,12 @@ namespace ADOFAIRenderer.Util.FFmpeg /// 소프트웨어 CPU 인코더를 강제 사용할지 여부. public bool ForceSoftware; + + /// + /// 오디오 인코딩 설정. null이면 오디오 스트림 없이 비디오만 인코딩합니다 (기존 동작과 동일). + /// 값을 설정하면 비디오와 오디오가 함께 먹싱(muxing)됩니다. + /// + public AudioSettings? Audio; } // ────────────────────────────────────────────────────────────── @@ -97,7 +134,7 @@ namespace ADOFAIRenderer.Util.FFmpeg /// 여기서는 비디오 스트림 하나만 사용합니다. /// 참고: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html /// - private AVStream* _pStream = null; + private AVStream* _pVideoStream = null; // ────────────────────────────────────────────────────────────── // 프레임 버퍼 및 색공간 변환기(스케일러) 포인터들 @@ -174,6 +211,44 @@ namespace ADOFAIRenderer.Util.FFmpeg private AVFrame* _pHwFrame = null; private bool _loggedConversionMode; + // ────────────────────────────────────────────────────────────── + // 오디오 인코딩 관련 FFmpeg 구조체 포인터들 + // ────────────────────────────────────────────────────────────── + + /// + /// 오디오 코덱(인코더)의 상태를 관리하는 컨텍스트. + /// 샘플 레이트, 채널 레이아웃, 샘플 포맷 등 오디오 인코딩 파라미터가 설정됩니다. + /// + private AVCodecContext* _pAudioCodecContext = null; + + /// 출력 파일 내의 오디오 스트림. + private AVStream* _pAudioStream = null; + + /// 오디오 인코딩된 패킷 데이터를 임시로 담을 패킷 구조체. + private AVPacket* _pAudioPacket = null; + + /// + /// 오디오 리샘플링 컨텍스트 (SwrContext). + /// 입력 PCM 포맷(예: float interleaved) → 인코더 요구 포맷(예: float planar)으로 변환합니다. + /// 참고: https://ffmpeg.org/doxygen/trunk/group__lswr.html + /// + private SwrContext* _pSwrContext = null; + + /// + /// 오디오 FIFO 버퍼. 임의 크기의 입력 샘플을 인코더가 요구하는 frame_size 단위로 모아서 전달합니다. + /// 참고: https://ffmpeg.org/doxygen/trunk/group__lavu__audiofifo.html + /// + private AVAudioFifo* _pAudioFifo = null; + + /// 오디오 인코더에 전달할 프레임 구조체. + private AVFrame* _pAudioFrame = null; + + /// 현재까지 인코딩한 오디오 샘플의 누적 인덱스. PTS 값으로 사용됩니다. + private long _audioSampleIndex; + + /// 오디오 인코딩이 활성화되어 있는지 여부. + private bool _audioEnabled; + /// 생성 시 전달받은 인코딩 설정값 (불변). private readonly EncoderSettings _settings; @@ -236,7 +311,7 @@ namespace ADOFAIRenderer.Util.FFmpeg byte* buffer = stackalloc byte[(int)bufferSize]; if (ffmpeg.av_strerror(errnum, buffer, bufferSize) >= 0) { - return System.Runtime.InteropServices.Marshal.PtrToStringAnsi((IntPtr)buffer) ?? $"알 수 없는 에러 (Code: {errnum})"; + return Marshal.PtrToStringAnsi((IntPtr)buffer) ?? $"알 수 없는 에러 (Code: {errnum})"; } return $"알 수 없는 에러 (Code: {errnum})"; } @@ -282,8 +357,8 @@ namespace ADOFAIRenderer.Util.FFmpeg // 컨테이너 안에 비디오 트랙(스트림)을 하나 생성합니다. // 오디오가 필요하면 여기서 오디오 스트림을 추가로 만들 수 있습니다. // 참고: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html - _pStream = ffmpeg.avformat_new_stream(_pFormatContext, null); - if (_pStream == null) throw new Exception("비디오 스트림을 생성하지 못했습니다."); + _pVideoStream = ffmpeg.avformat_new_stream(_pFormatContext, null); + if (_pVideoStream == null) throw new Exception("비디오 스트림을 생성하지 못했습니다."); // ── 3단계: 인코더 선택 및 열기 ── // GetEncoderCandidates()가 반환하는 우선순위 목록을 순회하며 @@ -444,7 +519,7 @@ namespace ADOFAIRenderer.Util.FFmpeg var qsvReady = false; int err; - bool isWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows); + bool isWindows = RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows); string childDeviceName = isWindows ? "d3d11va" : "vaapi"; AVHWDeviceType childDeviceType = isWindows ? AVHWDeviceType.AV_HWDEVICE_TYPE_D3D11VA : AVHWDeviceType.AV_HWDEVICE_TYPE_VAAPI; @@ -640,9 +715,16 @@ namespace ADOFAIRenderer.Util.FFmpeg // 코덱 컨텍스트에 설정된 파라미터(해상도, 코덱 ID, 프로파일 등)를 // 스트림의 codecpar에 복사합니다. 컨테이너 헤더에 기록될 정보입니다. // 참고: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html - if (ffmpeg.avcodec_parameters_from_context(_pStream->codecpar, _pCodecContext) < 0) + if (ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext) < 0) throw new Exception("코덱 파라미터를 스트림에 복사하는 데 실패했습니다."); + // ── 4.5단계 (선택): 오디오 스트림 초기화 ── + // EncoderSettings.Audio가 설정되어 있으면 오디오 스트림을 추가합니다. + if (_settings.Audio.HasValue) + { + InitializeAudioStream(_settings.Audio.Value); + } + // ── 5단계: 출력 파일 I/O 열기 ── // AVFMT_NOFILE 플래그가 없는 포맷은 실제 파일 I/O가 필요합니다. // avio_open으로 파일을 쓰기 모드로 열고, 포맷 컨텍스트의 pb(I/O 컨텍스트)에 연결합니다. @@ -1220,8 +1302,8 @@ namespace ADOFAIRenderer.Util.FFmpeg } // 코덱 타임베이스 → 스트림 타임베이스로 PTS/DTS 변환 - ffmpeg.av_packet_rescale_ts(_pPacket, _pCodecContext->time_base, _pStream->time_base); - _pPacket->stream_index = _pStream->index; + ffmpeg.av_packet_rescale_ts(_pPacket, _pCodecContext->time_base, _pVideoStream->time_base); + _pPacket->stream_index = _pVideoStream->index; // av_interleaved_write_frame: 패킷을 컨테이너에 기록합니다. // "interleaved"는 여러 스트림(비디오+오디오)의 패킷을 시간 순서대로 @@ -1269,7 +1351,7 @@ namespace ADOFAIRenderer.Util.FFmpeg { if (_disposed || !_isInitialized) return; - // 1. 지연된 프레임 플러시 (null 프레임 전송으로 인코더에 "입력 끝" 신호) + // 1. 비디오 인코더의 지연된 프레임 플러시 (null 프레임 전송으로 인코더에 "입력 끝" 신호) try { SendFrameAndWritePackets(null); @@ -1277,7 +1359,20 @@ namespace ADOFAIRenderer.Util.FFmpeg catch (Exception ex) { // 플러시 과정 중 발생한 에러 기록 - UnityEngine.Debug.LogError($"인코더 플러시 중 오류 발생: {ex.Message}"); + UnityEngine.Debug.LogError($"비디오 인코더 플러시 중 오류 발생: {ex.Message}"); + } + + // 1.5. 오디오 FIFO에 남은 샘플 플러시 + 오디오 인코더 플러시 + if (_audioEnabled) + { + try + { + FlushAudioEncoder(); + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"오디오 인코더 플러시 중 오류 발생: {ex.Message}"); + } } // 2. 컨테이너 트레일러 작성 (moov atom 등 파일 종료 메타데이터) @@ -1370,6 +1465,9 @@ namespace ADOFAIRenderer.Util.FFmpeg _pCodecContext = null; } + // ── 오디오 관련 리소스 해제 ── + DisposeAudioResources(); + // 포맷 컨텍스트 및 I/O 닫기 if (_pFormatContext != null) { @@ -1400,5 +1498,371 @@ namespace ADOFAIRenderer.Util.FFmpeg /// 명시적 Dispose() 호출로 리소스를 관리하세요. /// ~FFmpegNativeEncoder() { Dispose(); } + + // ================================================================ + // 오디오 인코딩 (Audio Encoding) + // ================================================================ + + /// + /// 오디오 스트림을 초기화합니다. + /// + /// 수행 단계: + /// 1. 오디오 인코더(코덱) 검색 및 코덱 컨텍스트 할당 + /// 2. 샘플 레이트, 채널 레이아웃, 샘플 포맷, 비트레이트 설정 + /// 3. 오디오 스트림(AVStream) 추가 및 코덱 파라미터 복사 + /// 4. 리샘플러(SwrContext) 초기화 (입력 포맷 → 인코더 포맷 변환) + /// 5. FIFO 버퍼 초기화 (임의 크기 입력 → frame_size 단위로 모으기) + /// 6. 오디오 프레임 및 패킷 구조체 할당 + /// + /// 참고: https://ffmpeg.org/doxygen/trunk/group__lavc__encoding.html + /// + private void InitializeAudioStream(AudioSettings audioSettings) + { + // ── 1. 오디오 코덱 결정 및 검색 ── + var audioCodecId = audioSettings.AudioCodecId != 0 + ? audioSettings.AudioCodecId + : AVCodecID.AV_CODEC_ID_AAC; + + var pAudioCodec = ffmpeg.avcodec_find_encoder(audioCodecId); + if (pAudioCodec == null) + throw new Exception($"오디오 인코더를 찾을 수 없습니다: {audioCodecId}"); + + // ── 2. 코덱 컨텍스트 할당 및 파라미터 설정 ── + _pAudioCodecContext = ffmpeg.avcodec_alloc_context3(pAudioCodec); + if (_pAudioCodecContext == null) + throw new Exception("오디오 코덱 컨텍스트를 할당하지 못했습니다."); + + var sampleRate = audioSettings.SampleRate > 0 ? audioSettings.SampleRate : 44100; + var channels = audioSettings.Channels > 0 ? audioSettings.Channels : 2; + var bitrate = audioSettings.Bitrate > 0 ? audioSettings.Bitrate : 128000; + + // 인코더가 지원하는 샘플 포맷 중 첫 번째를 선택합니다. + // 대부분의 인코더는 AV_SAMPLE_FMT_FLTP(float planar)를 선호합니다. + AVSampleFormat encoderSampleFmt; +#pragma warning disable CS0618 // AVCodec.sample_fmts is deprecated but still functional; avcodec_get_supported_config() is the replacement + if (pAudioCodec->sample_fmts != null) + { + encoderSampleFmt = pAudioCodec->sample_fmts[0]; + } +#pragma warning restore CS0618 + else + { + encoderSampleFmt = AVSampleFormat.AV_SAMPLE_FMT_FLTP; + } + + _pAudioCodecContext->sample_rate = sampleRate; + _pAudioCodecContext->sample_fmt = encoderSampleFmt; + _pAudioCodecContext->bit_rate = bitrate; + _pAudioCodecContext->strict_std_compliance = ffmpeg.FF_COMPLIANCE_EXPERIMENTAL; + + // 채널 레이아웃 설정 (FFmpeg 7.x+: AVChannelLayout 사용) + ffmpeg.av_channel_layout_default(&_pAudioCodecContext->ch_layout, channels); + + // time_base: 오디오는 일반적으로 1/sample_rate를 사용합니다. + _pAudioCodecContext->time_base = new AVRational { num = 1, den = sampleRate }; + + // 글로벌 헤더 플래그 + if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_GLOBALHEADER) != 0) + { + _pAudioCodecContext->flags |= ffmpeg.AV_CODEC_FLAG_GLOBAL_HEADER; + } + + // 인코더 열기 + var ret = ffmpeg.avcodec_open2(_pAudioCodecContext, pAudioCodec, null); + if (ret < 0) + throw new Exception($"오디오 인코더 구동 실패: {GetFFmpegErrorString(ret)} (Code: {ret})"); + + Melon.Logger.Msg($"오디오 인코더 구동 성공: {Marshal.PtrToStringAnsi((IntPtr)pAudioCodec->name)} (sample_rate={sampleRate}, channels={channels}, fmt={encoderSampleFmt})"); + + // ── 3. 오디오 스트림 추가 ── + _pAudioStream = ffmpeg.avformat_new_stream(_pFormatContext, null); + if (_pAudioStream == null) + throw new Exception("오디오 스트림을 생성하지 못했습니다."); + + if (ffmpeg.avcodec_parameters_from_context(_pAudioStream->codecpar, _pAudioCodecContext) < 0) + throw new Exception("오디오 코덱 파라미터를 스트림에 복사하는 데 실패했습니다."); + + _pAudioStream->time_base = _pAudioCodecContext->time_base; + + // ── 4. 리샘플러(SwrContext) 초기화 ── + // 입력: interleaved float (Unity AudioClip.GetData 기본) → 출력: 인코더 요구 포맷 + var inputSampleFmt = audioSettings.InputSampleFormat != 0 + ? audioSettings.InputSampleFormat + : AVSampleFormat.AV_SAMPLE_FMT_FLT; + + // SwrContext 할당 + fixed (SwrContext** ppSwr = &_pSwrContext) + { + // 입력 채널 레이아웃 설정 + AVChannelLayout inLayout; + ffmpeg.av_channel_layout_default(&inLayout, channels); + + ret = ffmpeg.swr_alloc_set_opts2( + ppSwr, + &_pAudioCodecContext->ch_layout, encoderSampleFmt, sampleRate, + &inLayout, inputSampleFmt, sampleRate, + 0, null); + + if (ret < 0 || _pSwrContext == null) + throw new Exception($"오디오 리샘플러 옵션 설정 실패: {GetFFmpegErrorString(ret)} (Code: {ret})"); + } + + ret = ffmpeg.swr_init(_pSwrContext); + if (ret < 0) + throw new Exception($"오디오 리샘플러 초기화 실패: {GetFFmpegErrorString(ret)} (Code: {ret})"); + + // ── 5. FIFO 버퍼 초기화 ── + _pAudioFifo = ffmpeg.av_audio_fifo_alloc(encoderSampleFmt, channels, 1); + if (_pAudioFifo == null) + throw new Exception("오디오 FIFO 버퍼를 할당하지 못했습니다."); + + // ── 6. 오디오 프레임 및 패킷 할당 ── + _pAudioFrame = ffmpeg.av_frame_alloc(); + _pAudioPacket = ffmpeg.av_packet_alloc(); + if (_pAudioFrame == null || _pAudioPacket == null) + throw new Exception("오디오 프레임 또는 패킷 구조체를 할당하지 못했습니다."); + + // 프레임 크기 결정: 일부 코덱(AAC 등)은 고정 frame_size를 가짐. + // frame_size가 0인 경우(예: PCM 계열) 기본값 1024 사용. + var frameSize = _pAudioCodecContext->frame_size > 0 ? _pAudioCodecContext->frame_size : 1024; + _pAudioFrame->nb_samples = frameSize; + _pAudioFrame->format = (int)encoderSampleFmt; + ffmpeg.av_channel_layout_copy(&_pAudioFrame->ch_layout, &_pAudioCodecContext->ch_layout); + _pAudioFrame->sample_rate = sampleRate; + + ret = ffmpeg.av_frame_get_buffer(_pAudioFrame, 0); + if (ret < 0) + throw new Exception($"오디오 프레임 버퍼 할당 실패: {GetFFmpegErrorString(ret)} (Code: {ret})"); + + _audioSampleIndex = 0; + _audioEnabled = true; + } + + /// + /// 원시 PCM 오디오 샘플 데이터를 받아 오디오 스트림에 인코딩합니다. + /// + /// Unity의 AudioClip.GetData()가 반환하는 float[] 배열이나 기타 PCM 소스를 전달할 수 있습니다. + /// 내부적으로 리샘플링 → FIFO 버퍼링 → frame_size 단위 인코딩 파이프라인을 수행합니다. + /// + /// 스레드 안전: 내부적으로 _lock을 사용하므로 여러 스레드에서 호출해도 안전합니다. + /// + /// PCM 오디오 샘플 데이터. 인터리브드 포맷(LRLRLR...)이어야 합니다. + /// 채널당 샘플 수 (= 총 float 개수 / 채널 수). + public void WriteAudioSamples(float[] samples, int numSamples) + { + lock (_lock) + { + if (_disposed || !_isInitialized || !_audioEnabled) return; + + fixed (float* pSamples = samples) + { + WriteAudioSamplesInternal((byte*)pSamples, numSamples); + } + } + } + + /// + /// 원시 PCM 오디오 샘플 데이터를 포인터로 받아 오디오 스트림에 인코딩합니다. + /// + /// PCM 오디오 샘플 데이터의 포인터. + /// 채널당 샘플 수. + public void WriteAudioSamples(IntPtr samples, int numSamples) + { + lock (_lock) + { + if (_disposed || !_isInitialized || !_audioEnabled) return; + WriteAudioSamplesInternal((byte*)samples.ToPointer(), numSamples); + } + } + + /// + /// 오디오 샘플 인코딩의 내부 구현. + /// + /// 처리 흐름: + /// 1. swr_convert로 입력 PCM → 인코더 포맷으로 리샘플링 + /// 2. 리샘플링된 데이터를 FIFO 버퍼에 추가 + /// 3. FIFO에 frame_size 이상 쌓이면 꺼내서 인코더에 전달 + /// + /// 참고: + /// swr_convert: https://ffmpeg.org/doxygen/trunk/group__lswr.html + /// av_audio_fifo: https://ffmpeg.org/doxygen/trunk/group__lavu__audiofifo.html + /// + private void WriteAudioSamplesInternal(byte* inputBuffer, int numSamples) + { + // ── 1. 리샘플링: 입력 포맷 → 인코더 포맷 ── + // 출력 버퍼를 위한 프레임 할당 (리샘플링 결과를 담을 임시 프레임) + var pConvertedFrame = ffmpeg.av_frame_alloc(); + if (pConvertedFrame == null) return; + + try + { + pConvertedFrame->nb_samples = numSamples; + pConvertedFrame->format = (int)_pAudioCodecContext->sample_fmt; + ffmpeg.av_channel_layout_copy(&pConvertedFrame->ch_layout, &_pAudioCodecContext->ch_layout); + pConvertedFrame->sample_rate = _pAudioCodecContext->sample_rate; + + var ret = ffmpeg.av_frame_get_buffer(pConvertedFrame, 0); + if (ret < 0) + throw new Exception($"리샘플링 출력 프레임 버퍼 할당 실패: {GetFFmpegErrorString(ret)} (Code: {ret})"); + + // swr_convert: 입력 데이터를 인코더가 요구하는 포맷으로 변환합니다. + var convertedSamples = ffmpeg.swr_convert( + _pSwrContext, + (byte**)&pConvertedFrame->data, numSamples, + &inputBuffer, numSamples); + + if (convertedSamples < 0) + throw new Exception($"오디오 리샘플링 실패: {GetFFmpegErrorString(convertedSamples)} (Code: {convertedSamples})"); + + if (convertedSamples == 0) return; + + // ── 2. FIFO 버퍼에 추가 ── + var written = ffmpeg.av_audio_fifo_write(_pAudioFifo, (void**)&pConvertedFrame->data, convertedSamples); + if (written < convertedSamples) + throw new Exception("오디오 FIFO 쓰기 실패"); + + // ── 3. FIFO에서 frame_size 단위로 꺼내어 인코딩 ── + var frameSize = _pAudioCodecContext->frame_size > 0 ? _pAudioCodecContext->frame_size : 1024; + while (ffmpeg.av_audio_fifo_size(_pAudioFifo) >= frameSize) + { + ffmpeg.av_frame_make_writable(_pAudioFrame); + _pAudioFrame->nb_samples = frameSize; + + var read = ffmpeg.av_audio_fifo_read(_pAudioFifo, (void**)&_pAudioFrame->data, frameSize); + if (read < frameSize) + throw new Exception("오디오 FIFO 읽기 실패"); + + _pAudioFrame->pts = _audioSampleIndex; + _audioSampleIndex += frameSize; + + SendAudioFrameAndWritePackets(_pAudioFrame); + } + } + finally + { + ffmpeg.av_frame_free(&pConvertedFrame); + } + } + + /// + /// 오디오 프레임을 인코더에 전송하고, 결과 패킷을 파일에 기록합니다. + /// 비디오의 SendFrameAndWritePackets와 동일한 Send/Receive 패턴을 사용합니다. + /// + private void SendAudioFrameAndWritePackets(AVFrame* pFrame) + { + var response = ffmpeg.avcodec_send_frame(_pAudioCodecContext, pFrame); + if (response < 0) + { + throw new Exception($"오디오 인코더에 프레임을 전송하는 도중 에러가 발생했습니다: {GetFFmpegErrorString(response)} (Code: {response})"); + } + + try + { + while (true) + { + response = ffmpeg.avcodec_receive_packet(_pAudioCodecContext, _pAudioPacket); + + if (response == ffmpeg.AVERROR(ffmpeg.EAGAIN) || response == ffmpeg.AVERROR_EOF) + { + break; + } + + if (response < 0) + { + throw new Exception($"오디오 인코더에서 패킷을 수신하는 도중 에러가 발생했습니다: {GetFFmpegErrorString(response)} (Code: {response})"); + } + + // 코덱 타임베이스 → 스트림 타임베이스로 변환 + ffmpeg.av_packet_rescale_ts(_pAudioPacket, _pAudioCodecContext->time_base, _pAudioStream->time_base); + _pAudioPacket->stream_index = _pAudioStream->index; + + response = ffmpeg.av_interleaved_write_frame(_pFormatContext, _pAudioPacket); + if (response < 0) + { + throw new Exception($"오디오 패킷을 파일에 쓰는 도중 에러가 발생했습니다: {GetFFmpegErrorString(response)} (Code: {response})"); + } + + ffmpeg.av_packet_unref(_pAudioPacket); + } + } + finally + { + ffmpeg.av_packet_unref(_pAudioPacket); + } + } + + /// + /// 오디오 인코더를 플러시합니다. + /// FIFO에 남아 있는 잔여 샘플(frame_size 미만)을 패딩하여 인코딩한 뒤, + /// null 프레임을 전송하여 인코더의 지연 프레임을 모두 출력합니다. + /// + private void FlushAudioEncoder() + { + if (_pAudioFifo == null || _pAudioCodecContext == null) return; + + // FIFO에 남은 잔여 샘플 인코딩 (frame_size보다 적은 마지막 청크) + var remaining = ffmpeg.av_audio_fifo_size(_pAudioFifo); + if (remaining > 0) + { + ffmpeg.av_frame_make_writable(_pAudioFrame); + _pAudioFrame->nb_samples = remaining; + + var read = ffmpeg.av_audio_fifo_read(_pAudioFifo, (void**)&_pAudioFrame->data, remaining); + if (read > 0) + { + _pAudioFrame->pts = _audioSampleIndex; + _audioSampleIndex += read; + SendAudioFrameAndWritePackets(_pAudioFrame); + } + } + + // null 프레임 전송으로 인코더 플러시 + SendAudioFrameAndWritePackets(null); + } + + /// + /// 오디오 관련 FFmpeg 리소스를 해제합니다. + /// Dispose() 내부에서 호출됩니다. + /// + private void DisposeAudioResources() + { + if (_pSwrContext != null) + { + fixed (SwrContext** ppSwr = &_pSwrContext) + { + ffmpeg.swr_free(ppSwr); + } + _pSwrContext = null; + } + + if (_pAudioFifo != null) + { + ffmpeg.av_audio_fifo_free(_pAudioFifo); + _pAudioFifo = null; + } + + if (_pAudioFrame != null) + { + var pFrame = _pAudioFrame; + ffmpeg.av_frame_free(&pFrame); + _pAudioFrame = null; + } + + if (_pAudioPacket != null) + { + var pPacket = _pAudioPacket; + ffmpeg.av_packet_free(&pPacket); + _pAudioPacket = null; + } + + if (_pAudioCodecContext != null) + { + var pCtx = _pAudioCodecContext; + ffmpeg.avcodec_free_context(&pCtx); + _pAudioCodecContext = null; + } + + _audioEnabled = false; + } } } \ No newline at end of file