using System.Diagnostics; using System.IO; namespace vcecht_exp_helper; public static class FfmpegOpusConverter { /// <summary> /// Asynchronously converts a WAV (or any PCM) stream into an Opus stream. /// </summary> /// <param name="inputStream"> /// Any Stream (e.g. FileStream, MemoryStream, NetworkStream) containing raw audio /// FFmpeg can decode (here assumed WAV/PCM). /// </param> /// <param name="cancellationToken"> /// CancellationToken to abort the operation mid‐stream. /// </param> /// <returns> /// A MemoryStream containing the Opus output. The returned stream’s Position is set to 0. /// </returns> public static async Task<Stream> ConvertWavToOpusAsync( Stream inputStream, CancellationToken cancellationToken = default) { if (inputStream == null) throw new ArgumentNullException(nameof(inputStream)); // Build ffmpeg arguments (quiet, read stdin, write stdout in Opus) string ffmpegArgs = string.Join(" ", new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-i", "pipe:0", "-c:a", "libopus", "-b:a", "16k", "-compression_level", "10", "-vbr", "on", "-application", "voip", "-frame_size", "20", "-deadline", "realtime", "-ar", "24000", "-f", "opus", "-fflags"," +genpts", "pipe:1" }); var psi = new ProcessStartInfo { FileName = "ffmpeg", Arguments = ffmpegArgs, UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; var ffmpeg = new Process { StartInfo = psi, EnableRaisingEvents = true }; try { if (!ffmpeg.Start()) throw new InvalidOperationException("Failed to start FFmpeg process."); // // 1) Write inputStream → ffmpeg.StandardInput.BaseStream // Task writeStdin = Task.Run(async () => { try { Stream stdinPipe = ffmpeg.StandardInput.BaseStream; await inputStream.CopyToAsync(stdinPipe, bufferSize: 81920, cancellationToken).ConfigureAwait(false); // Signal EOF to ffmpeg ffmpeg.StandardInput.Close(); } catch (Exception ex) when (!(ex is OperationCanceledException)) { // If writing fails, kill the process so it doesn’t hang try { ffmpeg.Kill(); } catch { /* ignore */ } throw; } }, cancellationToken); // // 2) Read ffmpeg.StandardError (to avoid deadlocks and capture any error text) // Task<string> readStderr = Task.Run(async () => { using (var sr = ffmpeg.StandardError) { return await sr.ReadToEndAsync().ConfigureAwait(false); } }, cancellationToken); // // 3) Read ffmpeg.StandardOutput → MemoryStream // var outputMem = new MemoryStream(); Task readStdout = Task.Run(async () => { try { Stream stdoutPipe = ffmpeg.StandardOutput.BaseStream; await stdoutPipe.CopyToAsync(outputMem, bufferSize: 81920, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (!(ex is OperationCanceledException)) { try { ffmpeg.Kill(); } catch { /* ignore */ } throw; } }, cancellationToken); // Wait for both copying tasks to finish await Task.WhenAll(writeStdin, readStdout).ConfigureAwait(false); // Now wait for ffmpeg to exit await Task.Run(() => ffmpeg.WaitForExit(), cancellationToken).ConfigureAwait(false); if (ffmpeg.ExitCode != 0) { string stderrText = await readStderr.ConfigureAwait(false); throw new InvalidOperationException( $"FFmpeg exited with code {ffmpeg.ExitCode}. Details:\n{stderrText}"); } // Rewind and return outputMem.Position = 0; return outputMem; } finally { ffmpeg.Dispose(); } } /// <summary> /// Asynchronously converts a WAV (or any PCM) stream into an Opus file. /// </summary> /// <param name="inputStream"> /// Any Stream (e.g. FileStream, MemoryStream, NetworkStream) containing raw audio /// FFmpeg can decode (here assumed WAV/PCM). /// </param> /// <param name="outputFileName"> /// The name of the output file (without path). /// </param> /// <param name="cancellationToken"> /// CancellationToken to abort the operation mid‐stream. /// </param> /// <returns> /// The absolute path to the generated Opus file. /// </returns> public static async Task<string> ConvertWavToOpusFileAsync( Stream inputStream, string outputFileName, CancellationToken cancellationToken = default) { if (inputStream == null) throw new ArgumentNullException(nameof(inputStream)); if (string.IsNullOrWhiteSpace(outputFileName)) throw new ArgumentException("Output file name cannot be empty", nameof(outputFileName)); // Ensure the output directory exists string outputDir = "/tmp/vcecht"; Directory.CreateDirectory(outputDir); string outputPath = Path.Combine(outputDir, outputFileName); // Build ffmpeg arguments (quiet, read stdin, write to file in Opus) string ffmpegArgs = string.Join(" ", new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-i", "pipe:0", "-c:a", "libopus", "-b:a", "16k", "-compression_level", "10", "-vbr", "on", "-application", "voip", "-frame_duration", "20", "-movflags", "+faststart", "-ar", "24000", "-f", "Opus", $"\"{outputPath}\"" // Output to file instead of stdout }); var psi = new ProcessStartInfo { FileName = "ffmpeg", Arguments = ffmpegArgs, UseShellExecute = false, RedirectStandardInput = true, RedirectStandardError = true, CreateNoWindow = true }; var ffmpeg = new Process { StartInfo = psi, EnableRaisingEvents = true }; try { if (!ffmpeg.Start()) throw new InvalidOperationException("Failed to start FFmpeg process."); // Write inputStream → ffmpeg.StandardInput.BaseStream await Task.Run(async () => { try { Stream stdinPipe = ffmpeg.StandardInput.BaseStream; await inputStream.CopyToAsync(stdinPipe, bufferSize: 81920, cancellationToken).ConfigureAwait(false); // Signal EOF to ffmpeg ffmpeg.StandardInput.Close(); } catch (Exception ex) when (!(ex is OperationCanceledException)) { // If writing fails, kill the process so it doesn't hang try { ffmpeg.Kill(); } catch { /* ignore */ } throw; } }, cancellationToken); // Read stderr to capture any error text string stderrText = await Task.Run(async () => { using (var sr = ffmpeg.StandardError) { return await sr.ReadToEndAsync().ConfigureAwait(false); } }, cancellationToken); // Wait for ffmpeg to finish await Task.Run(() => ffmpeg.WaitForExit(), cancellationToken).ConfigureAwait(false); if (ffmpeg.ExitCode != 0) { throw new InvalidOperationException( $"FFmpeg exited with code {ffmpeg.ExitCode}. Details:\n{stderrText}"); } // Verify the file was created if (!File.Exists(outputPath)) { throw new InvalidOperationException("FFmpeg did not create the expected output file."); } return outputPath; } finally { ffmpeg.Dispose(); } } /// <summary> /// Reads a file from the specified path and returns it as a memory stream. /// </summary> /// <param name="filePath">The path to the file to read.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <returns>A MemoryStream containing the file contents.</returns> /// <exception cref="ArgumentNullException">Thrown when filePath is null or empty.</exception> /// <exception cref="FileNotFoundException">Thrown when the specified file does not exist.</exception> public static async Task<MemoryStream> ReadFileToStreamAsync(string filePath, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentNullException(nameof(filePath)); if (!File.Exists(filePath)) throw new FileNotFoundException($"The file '{filePath}' was not found.", filePath); var memoryStream = new MemoryStream(); try { using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan)) { await fileStream.CopyToAsync(memoryStream, 81920, cancellationToken).ConfigureAwait(false); } memoryStream.Position = 0; // Reset position to the beginning of the stream return memoryStream; } catch { await memoryStream.DisposeAsync(); throw; } } /// <summary> /// Writes the contents of a stream to the specified file path. /// </summary> /// <param name="stream">The stream to write to the file.</param> /// <param name="filePath">The path where the file will be written.</param> /// <param name="overwrite">Whether to overwrite the file if it already exists. Default is true.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <returns>A task that represents the asynchronous write operation.</returns> /// <exception cref="ArgumentNullException">Thrown when stream or filePath is null or empty.</exception> /// <exception cref="IOException">Thrown when the file already exists and overwrite is false.</exception> public static async Task WriteStreamToFileAsync(Stream stream, string filePath, bool overwrite = true, CancellationToken cancellationToken = default) { if (stream == null) throw new ArgumentNullException(nameof(stream)); if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentNullException(nameof(filePath)); // Ensure the directory exists var directory = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } if (File.Exists(filePath)) { if (!overwrite) throw new IOException($"The file '{filePath}' already exists and overwrite is set to false."); File.Delete(filePath); } using (var fileStream = new FileStream( filePath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan)) { await stream.CopyToAsync(fileStream, 81920, cancellationToken).ConfigureAwait(false); await fileStream.FlushAsync(cancellationToken).ConfigureAwait(false); } } /// <summary> /// Converts an audio file to Opus format by reading and writing directly from/to files. /// </summary> /// <param name="inputFilePath">Path to the input audio file.</param> /// <param name="outputFilePath">Path where the output Opus file will be saved.</param> /// <param name="cancellationToken">CancellationToken to abort the operation.</param> /// <returns>The path to the converted file (same as outputFilePath).</returns> public static async Task<string> ConvertFileToOpusFileAsync( string inputFilePath, string outputFilePath, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(inputFilePath)) throw new ArgumentNullException(nameof(inputFilePath)); if (string.IsNullOrWhiteSpace(outputFilePath)) throw new ArgumentNullException(nameof(outputFilePath)); if (!File.Exists(inputFilePath)) throw new FileNotFoundException($"Input file not found: {inputFilePath}", inputFilePath); // Ensure the output directory exists string outputDir = Path.GetDirectoryName(outputFilePath); if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) { Directory.CreateDirectory(outputDir); } // Build ffmpeg arguments string ffmpegArgs = string.Join(" ", new[] { "-hide_banner", "-loglevel", "error", "-i", $"\"{inputFilePath}\"", "-c:a", "libopus", "-b:a", "16k", "-compression_level", "10", "-vbr", "on", "-application", "voip", "-frame_size", "20", "-deadline", "realtime", "-ar", "24000", "-f", "opus", "-fflags"," +genpts", $"\"{outputFilePath}\"" }); var psi = new ProcessStartInfo { FileName = "ffmpeg", Arguments = ffmpegArgs, UseShellExecute = false, RedirectStandardError = true, CreateNoWindow = true }; using var ffmpeg = new Process { StartInfo = psi, EnableRaisingEvents = true }; if (!ffmpeg.Start()) throw new InvalidOperationException("Failed to start FFmpeg process."); // Read stderr to capture any error text string stderrText = await Task.Run(async () => { using var sr = ffmpeg.StandardError; return await sr.ReadToEndAsync().ConfigureAwait(false); }, cancellationToken); // Wait for ffmpeg to finish await Task.Run(() => ffmpeg.WaitForExit(), cancellationToken).ConfigureAwait(false); if (ffmpeg.ExitCode != 0) { throw new InvalidOperationException( $"FFmpeg exited with code {ffmpeg.ExitCode}. Details:\n{stderrText}"); } if (!File.Exists(outputFilePath)) { throw new InvalidOperationException("FFmpeg did not create the expected output file."); } return outputFilePath; } /// <summary> /// Converts an audio file to Opus format and returns it as a stream. /// </summary> /// <param name="inputFilePath">Path to the input audio file.</param> /// <param name="cancellationToken">CancellationToken to abort the operation.</param> /// <returns>A MemoryStream containing the converted audio in Opus format.</returns> public static async Task<Stream> ConvertFileToOpusStreamAsync( string inputFilePath, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(inputFilePath)) throw new ArgumentNullException(nameof(inputFilePath)); if (!File.Exists(inputFilePath)) throw new FileNotFoundException($"Input file not found: {inputFilePath}", inputFilePath); // Build ffmpeg arguments string ffmpegArgs = string.Join(" ", new[] { "-hide_banner", "-loglevel", "error", "-i", $"\"{inputFilePath}\"", "-c:a", "libopus", "-b:a", "16k", "-compression_level", "10", "-vbr", "on", "-application", "voip", "-frame_size", "20", "-deadline", "realtime", "-ar", "24000", "-f", "opus", "-fflags"," +genpts", "pipe:1" }); var psi = new ProcessStartInfo { FileName = "ffmpeg", Arguments = ffmpegArgs, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; var outputMem = new MemoryStream(); var ffmpeg = new Process { StartInfo = psi, EnableRaisingEvents = true }; try { if (!ffmpeg.Start()) throw new InvalidOperationException("Failed to start FFmpeg process."); // Read stdout into memory stream await Task.Run(async () => { try { await ffmpeg.StandardOutput.BaseStream.CopyToAsync(outputMem, 81920, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (!(ex is OperationCanceledException)) { try { ffmpeg.Kill(); } catch { /* ignore */ } throw; } }, cancellationToken); // Read stderr to capture any error text string stderrText = await Task.Run(async () => { using var sr = ffmpeg.StandardError; return await sr.ReadToEndAsync().ConfigureAwait(false); }, cancellationToken); // Wait for ffmpeg to finish await Task.Run(() => ffmpeg.WaitForExit(), cancellationToken).ConfigureAwait(false); if (ffmpeg.ExitCode != 0) { throw new InvalidOperationException( $"FFmpeg exited with code {ffmpeg.ExitCode}. Details:\n{stderrText}"); } outputMem.Position = 0; return outputMem; } catch { await outputMem.DisposeAsync(); throw; } finally { ffmpeg.Dispose(); } } }