|
| 1 | +/* |
| 2 | + * AAC-ELD Decoder using FFmpeg as a subprocess. |
| 3 | + * |
| 4 | + * The fdk-aac NuGet package returns error 0x5 (AAC_DEC_UNSUPPORTED_ER_FORMAT) |
| 5 | + * for AAC-ELD because the pre-built binary doesn't include ER format support. |
| 6 | + * SharpJaad.AAC also doesn't support AAC-ELD. |
| 7 | + * |
| 8 | + * This decoder pipes raw AAC-ELD frames (wrapped in ADTS) through FFmpeg for |
| 9 | + * decoding, which supports AAC-ELD natively. This approach works with any |
| 10 | + * FFmpeg version the user has installed. |
| 11 | + * |
| 12 | + * Reference: UxPlay uses GStreamer's avdec_aac (which wraps FFmpeg) for the same purpose. |
| 13 | + */ |
| 14 | + |
| 15 | +using System; |
| 16 | +using System.Diagnostics; |
| 17 | +using System.IO; |
| 18 | +using System.Threading; |
| 19 | +using AirPlay.Models.Enums; |
| 20 | + |
| 21 | +namespace AirPlay.Decoders.Implementations |
| 22 | +{ |
| 23 | + public class FFmpegAacEldDecoder : IDecoder, IDisposable |
| 24 | + { |
| 25 | + private const int MAX_READ_ATTEMPTS = 50; |
| 26 | + private const int PROCESS_EXIT_TIMEOUT_MS = 1000; |
| 27 | + |
| 28 | + private Process _ffmpegProcess; |
| 29 | + private BinaryWriter _ffmpegInput; |
| 30 | + private Stream _ffmpegOutput; |
| 31 | + private int _pcmOutputSize; |
| 32 | + private int _channels; |
| 33 | + private int _sampleRate; |
| 34 | + private int _frameLength; |
| 35 | + private bool _disposed; |
| 36 | + private bool _initialized; |
| 37 | + private readonly byte[] _adtsHeader = new byte[7]; |
| 38 | + private int _adtsProfile; |
| 39 | + private int _adtsFreqIdx; |
| 40 | + private int _adtsChanCfg; |
| 41 | + |
| 42 | + public AudioFormat Type => AudioFormat.AAC_ELD; |
| 43 | + |
| 44 | + public FFmpegAacEldDecoder() |
| 45 | + { |
| 46 | + } |
| 47 | + |
| 48 | + public int Config(int sampleRate, int channels, int bitDepth, int frameLength) |
| 49 | + { |
| 50 | + _channels = channels; |
| 51 | + _sampleRate = sampleRate; |
| 52 | + _frameLength = frameLength; |
| 53 | + _pcmOutputSize = frameLength * channels * (bitDepth / 8); |
| 54 | + |
| 55 | + // ADTS profile=2 (AAC-LC) since ADTS doesn't support AAC-ELD profile encoding. |
| 56 | + // FFmpeg's parser will still decode the content correctly based on the actual bitstream. |
| 57 | + _adtsProfile = 2; |
| 58 | + _adtsFreqIdx = GetSampleRateIndex(sampleRate); |
| 59 | + _adtsChanCfg = channels; |
| 60 | + |
| 61 | + try |
| 62 | + { |
| 63 | + // Start FFmpeg process: |
| 64 | + // Input: AAC frames wrapped in ADTS headers via stdin pipe |
| 65 | + // Output: raw PCM S16LE via stdout pipe |
| 66 | + var psi = new ProcessStartInfo |
| 67 | + { |
| 68 | + FileName = "ffmpeg", |
| 69 | + Arguments = $"-hide_banner -loglevel error -f aac -i pipe:0 -f s16le -acodec pcm_s16le -ar {sampleRate} -ac {channels} pipe:1", |
| 70 | + UseShellExecute = false, |
| 71 | + RedirectStandardInput = true, |
| 72 | + RedirectStandardOutput = true, |
| 73 | + RedirectStandardError = true, |
| 74 | + CreateNoWindow = true, |
| 75 | + }; |
| 76 | + |
| 77 | + _ffmpegProcess = new Process { StartInfo = psi }; |
| 78 | + _ffmpegProcess.Start(); |
| 79 | + |
| 80 | + _ffmpegInput = new BinaryWriter(_ffmpegProcess.StandardInput.BaseStream); |
| 81 | + _ffmpegOutput = _ffmpegProcess.StandardOutput.BaseStream; |
| 82 | + |
| 83 | + // Drain stderr in background to prevent pipe deadlock |
| 84 | + var stderrThread = new Thread(() => |
| 85 | + { |
| 86 | + try { _ffmpegProcess.StandardError.ReadToEnd(); } |
| 87 | + catch { /* ignore */ } |
| 88 | + }); |
| 89 | + stderrThread.IsBackground = true; |
| 90 | + stderrThread.Start(); |
| 91 | + |
| 92 | + _initialized = true; |
| 93 | + Console.WriteLine($"FFmpeg AAC-ELD decoder started: {sampleRate}Hz, {channels}ch, {bitDepth}bit, frameLength={frameLength}"); |
| 94 | + return 0; |
| 95 | + } |
| 96 | + catch (Exception ex) |
| 97 | + { |
| 98 | + Console.WriteLine($"FFmpeg AAC-ELD decoder failed to start: {ex.Message}"); |
| 99 | + Console.WriteLine("Please ensure 'ffmpeg' is in your system PATH."); |
| 100 | + return -1; |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + public int GetOutputStreamLength() |
| 105 | + { |
| 106 | + return _pcmOutputSize; |
| 107 | + } |
| 108 | + |
| 109 | + public int DecodeFrame(byte[] input, ref byte[] output, int length) |
| 110 | + { |
| 111 | + if (!_initialized || _ffmpegProcess == null || _ffmpegProcess.HasExited) |
| 112 | + return -1; |
| 113 | + |
| 114 | + try |
| 115 | + { |
| 116 | + // Wrap the raw AAC frame in an ADTS header for FFmpeg to parse |
| 117 | + int frameLen = input.Length + 7; // ADTS header is 7 bytes |
| 118 | + BuildAdtsHeader(_adtsHeader, frameLen, _adtsProfile, _adtsFreqIdx, _adtsChanCfg); |
| 119 | + |
| 120 | + // Write ADTS header + raw AAC data to FFmpeg stdin |
| 121 | + _ffmpegInput.Write(_adtsHeader, 0, 7); |
| 122 | + _ffmpegInput.Write(input, 0, input.Length); |
| 123 | + _ffmpegInput.Flush(); |
| 124 | + |
| 125 | + // Read decoded PCM from FFmpeg stdout |
| 126 | + int bytesToRead = _pcmOutputSize; |
| 127 | + int totalRead = 0; |
| 128 | + int maxAttempts = MAX_READ_ATTEMPTS; |
| 129 | + |
| 130 | + while (totalRead < bytesToRead && maxAttempts > 0) |
| 131 | + { |
| 132 | + int read = _ffmpegOutput.Read(output, totalRead, bytesToRead - totalRead); |
| 133 | + if (read <= 0) |
| 134 | + { |
| 135 | + Thread.Sleep(1); |
| 136 | + maxAttempts--; |
| 137 | + continue; |
| 138 | + } |
| 139 | + totalRead += read; |
| 140 | + } |
| 141 | + |
| 142 | + if (totalRead < bytesToRead) |
| 143 | + { |
| 144 | + // Partial read - zero fill the rest |
| 145 | + Array.Clear(output, totalRead, bytesToRead - totalRead); |
| 146 | + } |
| 147 | + |
| 148 | + return 0; |
| 149 | + } |
| 150 | + catch (Exception) |
| 151 | + { |
| 152 | + return -1; |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + /// <summary> |
| 157 | + /// Build an ADTS header for wrapping raw AAC frames. |
| 158 | + /// </summary> |
| 159 | + private static void BuildAdtsHeader(byte[] header, int packetLen, int profile, int freqIdx, int chanCfg) |
| 160 | + { |
| 161 | + header[0] = 0xFF; |
| 162 | + header[1] = 0xF1; // MPEG-4, Layer 0, no CRC |
| 163 | + header[2] = (byte)(((profile - 1) << 6) | (freqIdx << 2) | (chanCfg >> 2)); |
| 164 | + header[3] = (byte)(((chanCfg & 3) << 6) | (packetLen >> 11)); |
| 165 | + header[4] = (byte)((packetLen >> 3) & 0xFF); |
| 166 | + header[5] = (byte)(((packetLen & 7) << 5) | 0x1F); |
| 167 | + header[6] = 0xFC; |
| 168 | + } |
| 169 | + |
| 170 | + private static int GetSampleRateIndex(int sampleRate) |
| 171 | + { |
| 172 | + return sampleRate switch |
| 173 | + { |
| 174 | + 96000 => 0, 88200 => 1, 64000 => 2, 48000 => 3, |
| 175 | + 44100 => 4, 32000 => 5, 24000 => 6, 22050 => 7, |
| 176 | + 16000 => 8, 12000 => 9, 11025 => 10, 8000 => 11, |
| 177 | + 7350 => 12, _ => 4, |
| 178 | + }; |
| 179 | + } |
| 180 | + |
| 181 | + public void Dispose() |
| 182 | + { |
| 183 | + if (!_disposed) |
| 184 | + { |
| 185 | + _disposed = true; |
| 186 | + try |
| 187 | + { |
| 188 | + // Close stdin first for graceful FFmpeg shutdown |
| 189 | + _ffmpegInput?.Close(); |
| 190 | + |
| 191 | + if (_ffmpegProcess != null && !_ffmpegProcess.HasExited) |
| 192 | + { |
| 193 | + // Wait for graceful exit, then force kill if needed |
| 194 | + if (!_ffmpegProcess.WaitForExit(PROCESS_EXIT_TIMEOUT_MS)) |
| 195 | + { |
| 196 | + _ffmpegProcess.Kill(); |
| 197 | + _ffmpegProcess.WaitForExit(PROCESS_EXIT_TIMEOUT_MS); |
| 198 | + } |
| 199 | + } |
| 200 | + _ffmpegProcess?.Dispose(); |
| 201 | + } |
| 202 | + catch { /* ignore cleanup errors */ } |
| 203 | + } |
| 204 | + } |
| 205 | + } |
| 206 | +} |
0 commit comments