modifyAudioFile method

Future<File> modifyAudioFile({
  1. required String filepath,
  2. String? modifiedName,
  3. MediaFormat? format,
  4. Map<String, String>? metadata,
  5. FFProbeCliTool? ffProbe,
  6. AudioFileModificationProgressCb? onProgress,
  7. bool overrideOutputFile = true,
})

Implementation

Future<File> modifyAudioFile({
  required String filepath,
  String? modifiedName,
  MediaFormat? format,
  Map<String, String>? metadata,
  FFProbeCliTool? ffProbe,
  AudioFileModificationProgressCb? onProgress,
  bool overrideOutputFile = true,
}) async {
  final inFile = await checkFileExists(filepath).resolveUri();

  // No modifications made, return original file
  if (null == format && null == metadata) return inFile;

  if (null != format && !format.isAudioOnly) {
    throw ArgumentError.value(
      format,
      "format",
      "Must provide an audio-only media format",
    );
  }

  final inFileModel =
      await (ffProbe ?? FFProbeCliTool()).getModel(inFile.path);

  // Check that filepath leads to media file
  if (inFileModel.streams.isEmpty) {
    throw CliArgumentException(
      filepath,
      name: "filepath",
      message: "The provided filepath does not lead to a media file.",
    );
  }

  final streamModel = inFileModel.streams.singleOrNull;

  // Check that there is only a single stream model
  if (null == streamModel) {
    throw CliArgumentException(
      filepath,
      name: "filepath",
      message: "The provided filepath must lead to a media file "
          "which contains only one audio stream, but the file contains "
          "the following ${inFileModel.streams.length} streams:\n\n"
          "${inFileModel.streams.join("\n")}",
    );
  }

  // Check that file is a media file which contains only audio data
  if (streamModel is! FFProbeAudioStreamModel) {
    throw CliArgumentException(
      filepath,
      name: "filepath",
      message: "The provided filepath must lead to a media file "
          "which contains an audio stream, but the file contains "
          "the following stream:\n$streamModel",
    );
  }

  final originalDuration = inFileModel.format.duration;

  final outFileExtension = format?.fileExtension ?? inFile.extension;
  final outFileBasename = modifiedName ?? inFile.nameWithoutExtension;
  final outFile = inFile.parent
      .file("$outFileBasename.$outFileExtension")
      .getNewFileWithIdenticalBasename();

  final proc = await startProcess([
    "-progress", "pipe:1", // write progress to stdout
    "-stats",
    "-stats_period", "0.10", // write stats every 100ms
    "-loglevel", "info", //
    if (overrideOutputFile) "-y", // override output file, if its exists
    "-i", inFile.path, //
    for (final MapEntry(key: k, value: v) in (metadata ?? {}).entries) ...[
      "-metadata",
      "$k=$v",
    ],
    outFile.path,
  ]);

  final progressSub =
      proc.stdout.map(String.fromCharCodes).listen((rawEvent) {
    //+i first event
    // bitrate=N/A
    // total_size=4067
    // out_time_us=N/A
    // out_time_ms=N/A
    // out_time=00:00:00.000000
    // dup_frames=0
    // drop_frames=0
    // speed=   0x
    // progress=continue

    //+i middle event
    // bitrate= 107.0kbits/s
    // total_size=2097152
    // out_time_us=156766621
    // out_time_ms=156766621
    // out_time=00:02:36.766621
    // dup_frames=0
    // drop_frames=0
    // speed=92.1x
    // progress=continue

    //+i last event
    // bitrate= 113.3kbits/s
    // total_size=2977722
    // out_time_us=210323447
    // out_time_ms=210323447
    // out_time=00:03:30.323447
    // dup_frames=0
    // drop_frames=0
    // speed=  92x
    // progress=end

    final event = rawEvent.split("\n").fold(
      <String, String>{},
      (map, line) {
        if (line.isBlank) return map;
        final parts = line.split("=");
        map[parts[0].trim()] = parts[1].trim();
        return map;
      },
    );

    final outTime = Duration(
      microseconds: int.tryParse(event["out_time_us"]!) ?? 0,
    );
    final isFinished = switch (event["progress"]) {
      "continue" => false,
      "end" => true,
      final val => throw FormatException(
          "Value for 'progress' must be 'continue' or 'end', "
          "but it is '$val'",
          rawEvent,
        )
    };

    // When converting the file format, the duration may not be exactly equal
    // afterwards.
    var percent = outTime.inMilliseconds / originalDuration.inMilliseconds;
    if (percent > 0.999 || isFinished) {
      percent = 1.0;
    }

    onProgress?.call(
      AudioFileModificationProgressEvent._(
        outputFilepath: outFile.path,
        outTime: outTime,
        percent: percent,
        totalSize: event["total_size"]!.toInt(),
        isFinished: isFinished,
      ),
    );
  });
  final error = proc.stderr
      .map(String.fromCharCodes)
      .fold("", (prev, e) => "$prev\n\n$e");

  final code = await proc.exitCode;
  await progressSub.cancel();

  if (0 != code) {
    throw CliResultException(
      exitCode: code,
      stderr: await error,
      message: "Failed to modify the file at '$filepath'",
    );
  }

  return outFile;
}