elevenlabs_field-1.0.0-beta7/src/ElevenLabsFFmpegService.php

src/ElevenLabsFFmpegService.php
<?php

namespace Drupal\elevenlabs_field;

use Drupal\Component\Uuid\Php;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Utility\Token;
use Drupal\elevenlabs_field\Exceptions\ElevenLabsFFmpegFailure;
use Drupal\field\Entity\FieldConfig;
use Drupal\file\FileStorageInterface;

/**
 * Takes care of concanating an file.
 */
class ElevenLabsFFmpegService {

  /**
   * Array with all instructions.
   */
  protected array $instructions = [];

  /**
   * Total calculated time.
   */
  protected int $totalTime = 0;

  /**
   * Temp file.
   */
  protected string $tmpFile = '';

  /**
   * Temp file for garbage collection.
   */
  protected array $tmpFiles = [];

  /**
   * Target field config.
   */
  protected array $targetFieldSettings = [];

  /**
   * Origin field config.
   */
  protected array $originFieldSettings = [];

  /**
   * The entity.
   */
  protected ContentEntityInterface $entity;

  /**
   * The file storage interface.
   */
  protected FileStorageInterface $fileStorage;

  /**
   * The file system.
   */
  protected FileSystemInterface $fileSystem;

  /**
   * The token system.
   */
  protected Token $token;

  /**
   * The UUID system.
   */
  protected Php $uuid;

  /**
   * Construtor of FFmpeg service.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityType
   *   The entity type manager interface.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   The file system interface.
   * @param \Drupal\Core\Utility\Token $token
   *   The token system.
   * @param \Drupal\Component\Uuid\Php $uuid
   *   The uuid system.
   */
  public function __construct(EntityTypeManagerInterface $entityType, FileSystemInterface $fileSystem, Token $token, Php $uuid) {
    $this->fileStorage = $entityType->getStorage('file');
    $this->fileSystem = $fileSystem;
    $this->token = $token;
    $this->uuid = $uuid;
  }

  /**
   * Destructor to garbage collect.
   */
  public function __destruct() {
    foreach ($this->tmpFiles as $tmpFile) {
      if (file_exists($tmpFile)) {
        unlink($tmpFile);
      }
    }
  }

  /**
   * Create an audio file from a field value.
   *
   * @param mixed $fieldValues
   *   The elevenlabs field values.
   * @param string $originFieldName
   *   The field name to save from.
   * @param string $targetFieldName
   *   The field name to save to.
   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
   *   The entity to save to.
   *
   * @return \Drupal\file\Entity\File
   *   The file entity.
   */
  public function concanateAudios($fieldValues, $originFieldName, $targetFieldName, ContentEntityInterface $entity) {
    $this->tmpFile = "";
    $this->instructions = [];
    $this->totalTime = 0;
    $this->targetFieldSettings = [];
    $this->originFieldSettings = [];
    $this->tmpFiles = [];
    if (empty($fieldValues[0])) {
      return NULL;
    }
    $this->entity = $entity;
    $this->setInstructions($fieldValues);
    $this->ensureOriginFieldSettings($originFieldName);
    $this->ensureTargetFieldSettings($targetFieldName);
    $this->runMergeCommand();
    $this->runExtraCommands();
    $this->normalizeFile();
    return $this->generateFileEntity();
  }

  /**
   * Generates the file entity.
   *
   * @return \Drupal\file\Entity\File
   *   The file entity.
   */
  protected function generateFileEntity() {
    $fileName = 'elevenlabs.mp3';
    $dir = $this->token->replace($this->targetFieldSettings['uri_scheme'] . '://' . rtrim($this->targetFieldSettings['file_directory'], '/'));
    $this->fileSystem->prepareDirectory($dir, FileSystemInterface::CREATE_DIRECTORY);
    $destination = $dir . '/' . $fileName;
    $destination = $this->fileSystem->copy($this->tmpFile, $destination, FileSystemInterface::EXISTS_RENAME);
    /** @var \Drupal\file\Entity\File */
    $file = $this->fileStorage->create([
      'uri' => $destination,
    ]);
    $file->setPermanent();
    $file->save();
    // Cleanup.
    unlink($this->tmpFile);
    $this->tmpFile = "";
    return $file;
  }

  /**
   * Ensures the origin field config.
   *
   * @param string $fieldName
   *   The field name to save to.
   */
  private function ensureOriginFieldSettings($fieldName) {
    $config = FieldConfig::loadByName($this->entity->getEntityTypeId(), $this->entity->bundle(), $fieldName);
    $this->originFieldSettings = $config->getSettings();
  }

  /**
   * Ensures the target field config.
   *
   * @param string $fieldName
   *   The field name to save to.
   */
  private function ensureTargetFieldSettings($fieldName) {
    $config = FieldConfig::loadByName($this->entity->getEntityTypeId(), $this->entity->bundle(), $fieldName);
    $this->targetFieldSettings = $config->getSettings();
  }

  /**
   * Create instructions.
   *
   * @param mixed $fieldValues
   *   The elevenlabs field values.
   */
  private function setInstructions($fieldValues) {
    foreach ($fieldValues as $value) {
      if (!empty($value['target_id'])) {
        /** @var \Drupal\file\Entity\File */
        $file = $this->fileStorage->load($value['target_id']);
        $path = $this->fileSystem->realpath($file->getFileUri());
        $this->instructions[$value['target_id']] = [
          'filePath' => $path,
          'duration' => $path ? $this->getDuration($path) : 0,
          'offset' => $value['start_time'],
        ];
      }
    }
    $this->instructions = array_values($this->instructions);
  }

  /**
   * Run the command.
   */
  private function runMergeCommand() {
    $command = $this->getMainCommand();
    exec($command . ' 2>&1', $response, $code);
    if ($code) {
      throw new ElevenLabsFFmpegFailure(implode(",", $response), $code);
    }
  }

  /**
   * Get the command.
   *
   * @return string
   *   The full command.
   */
  private function getMainCommand() {
    $this->tmpFile = $this->generateUniqueFile();

    if (count($this->instructions) === 1) {
      return "cp {$this->instructions[0]['filePath']} {$this->tmpFile}";
    }
    $command = '/usr/bin/ffmpeg -y -nostdin';
    $midCommand = '';
    $endCommand = '';
    $i = 0;
    $lastDuration = 0;
    $totalOffset = 0;
    $totalDuration = 0;
    // Overamp is needed :/.
    foreach ($this->instructions as $key => $value) {
      $command .= ' -i ' . $value['filePath'];
      if ($i) {
        $midCommand .= "[$i:a]adelay=" . $lastDuration . "|" . $lastDuration . ",volume=40[a$i]; ";
      } else {
        $midCommand .= "[$i:a]adelay=" . $lastDuration . "|" . $lastDuration . ",volume=40[a$i]; ";
      }
      $totalDuration += ($value['duration'] * 10);
      $totalOffset += $this->instructions[($key + 1)]['offset'] ?? 0;
      $lastDuration = $totalDuration + $totalOffset;
      $endCommand .= "[a$i]";
      $i++;
    }

    $command .= ' -filter_complex "' . $midCommand . $endCommand . "amix=inputs=" . count($this->instructions) . ":duration=longest:dropout_transition=0,dynaudnorm\"";
    $command .= " -c:a libmp3lame -b:a 128k $this->tmpFile";

    $i = 0;
    return $command;
  }

  /**
   * Run the command.
   */
  private function runExtraCommands() {
    // Check for soundscape.
    if (!empty($this->originFieldSettings['concatenate_advanced']['soundscape'])) {
      $this->runSoundScape();
    }
    // Check for intro.
    if (!empty($this->originFieldSettings['concatenate_advanced']['intro'])) {
      $this->runIntro();
    }

    if (!empty($this->originFieldSettings['concatenate_advanced']['outro'])) {
      $this->runOutro();
    }
  }

  /**
   * Runs the intro logic.
   */
  private function runIntro() {
    $introSettings = $this->originFieldSettings['concatenate_advanced']['intro_details'];
    // Prepare the sound scape with all logic first.
    $introTmp = $this->generateUniqueFile();

    // Give up without a file.
    if (!$inputFile = $this->getInputFile($introSettings['intro_field'], $introSettings['intro_file'])) {
      return;
    }

    $input = $this->fileSystem->realpath($inputFile->getFileUri());
    // If volume is needed.
    if (!empty($introSettings['intro_volume'])) {
      $input = $this->setVolume($input, $introSettings['intro_volume']);
    }

    $listFile = $this->fileSystem->getTempDirectory() . '/list.txt';
    file_put_contents($listFile, "file '$input'\nfile '$this->tmpFile'");

    $command = "/usr/bin/ffmpeg -y -nostdin -f concat -safe 0 -i $listFile -c:a libmp3lame -b:a 128k $introTmp";

    // Run the command.
    $this->runFfmpeg($command);
    unlink($this->tmpFile);
    unlink($listFile);
    $this->tmpFile = $introTmp;
  }

  /**
   * Runs the outro logic.
   */
  private function runOutro() {
    $outroSettings = $this->originFieldSettings['concatenate_advanced']['outro_details'];
    // Prepare the sound scape with all logic first.
    $outroTmp = $this->generateUniqueFile();

    // Give up without a file.
    if (!$inputFile = $this->getInputFile($outroSettings['outro_field'], $outroSettings['outro_file'])) {
      return;
    }

    $input = $this->fileSystem->realpath($inputFile->getFileUri());
    // If volume is needed.
    if (!empty($outroSettings['outro_volume'])) {
      $input = $this->setVolume($input, $outroSettings['outro_volume']);
    }
    $listFile = $this->fileSystem->getTempDirectory() . '/list.txt';
    file_put_contents($listFile, "file '$this->tmpFile'\nfile '$input'");

    $command = "/usr/bin/ffmpeg -y -nostdin -f concat -safe 0 -i $listFile -c:a copy $outroTmp";
    // Run the command.
    $this->runFfmpeg($command);
    unlink($listFile);
    $this->tmpFile = $outroTmp;
  }

  /**
   * Runs the soundscape logic.
   */
  private function runSoundScape() {
    echo 'SoundScape';
    // Settings.
    $soundscapeSettings = $this->originFieldSettings['concatenate_advanced']['soundscape_details'];

    // Give up without a file.
    if (!$inputFile = $this->getInputFile($soundscapeSettings['soundscape_field'], $soundscapeSettings['soundscape_file'])) {
      return;
    }

    // Calculate total length.
    $totalPaddedTime = !empty($soundscapeSettings['soundscape_cut_time']) ? $soundscapeSettings['soundscape_cut_time'] / 1000 : ($this->getDuration($this->tmpFile) / 100) + ($soundscapeSettings['soundscape_prolong'] / 1000) + ($soundscapeSettings['soundscape_start_wait'] / 1000);

    $input = $this->fileSystem->realpath($inputFile->getFileUri());
    // Cut the file.
    $inputFile = $this->cutTrack($input, $totalPaddedTime);

    // Calculate volume.
    if (!empty($soundscapeSettings['soundscape_volume'])) {
      $inputFile = $this->setVolume($inputFile, $soundscapeSettings['soundscape_volume'], TRUE);
    }
    // Fade in.
    if (!empty($soundscapeSettings['soundscape_fade_in'])) {
      $inputFile = $this->setFadeIn($inputFile, $soundscapeSettings['soundscape_fade_in'], TRUE);
    }
    // Fade out.
    if (!empty($soundscapeSettings['soundscape_fade_out'])) {
      $startTime = $totalPaddedTime - ($soundscapeSettings['soundscape_fade_out'] / 1000);
      $inputFile = $this->setFadeOut($inputFile, ($soundscapeSettings['soundscape_fade_out'] / 1000), $startTime, TRUE);
    }
    // Mix.
    $inputFile = $this->mixTracks($inputFile, $this->tmpFile, $soundscapeSettings['soundscape_start_wait'], TRUE);

    $this->tmpFile = $inputFile;
  }

  /**
   * Check which input file to get.
   *
   * @param string $field
   *   The field to use.
   * @param int $fid
   *   The file to use.
   *
   * @return \Drupal\file\Entity\File
   *   The file or empty.
   */
  private function getInputFile($field, $fid = NULL) {
    $inputFile = NULL;
    // If a field exists and an audio exists, use that, otherwise fall back.
    if (!empty($field) && !empty($this->entity->{$field}->entity)) {
      $inputFile = $this->entity->{$field}->entity;
    }
    if (!$inputFile && $fid) {
      $inputFile = $this->fileStorage->load($fid[0]);
    }
    return $inputFile;
  }

  /**
   * Mix two tracks.
   */
  private function mixTracks($file1, $file2, $delay, $delete = FALSE) {
    $tmpVol = $this->generateUniqueFile();
    // Delay.
    if (!empty($delay)) {
      $time = $this->getDuration($file1, FALSE) * 10;
      $af = "[0:a]atrim=0:" . $time . "[a0]; [1:a]adelay=" . $delay . "|" . $delay . "[a1]; [a0][a1]amix=inputs=2:duration=longest:dropout_transition=1000,volume=4";
    } else {
      $af = "amix=inputs=2:duration=longest:dropout_transition=1000,volume=4";
    }
    $command = "ffmpeg -i $file1 -i $file2 -filter_complex \"$af\" $tmpVol";
    $this->runFfmpeg($command);
    if ($delete) {
      unlink($file1);
      unlink($file2);
    }

    return $tmpVol;
  }

  /**
   * Cut track.
   *
   * @param string $inputFile
   *   The file path.
   * @param int $length
   *   The length.
   * @param bool $delete
   *   If the input file should be deleted.
   *
   * @return string
   *   A new file path.
   */
  private function cutTrack($inputFile, $length, $delete = FALSE) {
    $tmpVol = $this->generateUniqueFile();
    $command = "/usr/bin/ffmpeg -y -nostdin -t $length -i $inputFile -c:a copy $tmpVol";
    $this->runFfmpeg($command);
    if ($delete) {
      unlink($inputFile);
    }
    return $tmpVol;
  }

  /**
   * Set volume on file.
   *
   * @param string $inputFile
   *   The file path.
   * @param int $volume
   *   The volume.
   * @param bool $delete
   *   If the input file should be deleted.
   *
   * @return string
   *   A new file path.
   */
  private function setVolume($inputFile, $volume, $delete = FALSE) {
    $volume = $volume / 100;
    $tmpVol = $this->generateUniqueFile();
    $command = "/usr/bin/ffmpeg -y -nostdin -i $inputFile -af \"volume=$volume\" $tmpVol";
    $this->runFfmpeg($command);
    if ($delete) {
      unlink($inputFile);
    }
    return $tmpVol;
  }

  /**
   * Set fade in on file.
   *
   * @param string $inputFile
   *   The file path.
   * @param int $length
   *   The length to fade in.
   * @param bool $delete
   *   If the input file should be deleted.
   *
   * @return string
   *   A new file path.
   */
  private function setFadeIn($inputFile, $length, $delete = FALSE) {
    $tmpVol = $this->generateUniqueFile();
    $command = "/usr/bin/ffmpeg -y -nostdin -i $inputFile -af \"afade=t=in:st=0:d=$length\" $tmpVol";
    $this->runFfmpeg($command);
    if ($delete) {
      unlink($inputFile);
    }
    return $tmpVol;
  }

  /**
   * Set fade in on file.
   *
   * @param string $inputFile
   *   The file path.
   * @param int $length
   *   The length to fade in.
   * @param int $startTime
   *   The start time.
   * @param bool $delete
   *   If the input file should be deleted.
   *
   * @return string
   *   A new file path.
   */
  private function setFadeOut($inputFile, $length, $startTime, $delete = FALSE) {
    $tmpVol = $this->generateUniqueFile();
    $command = "/usr/bin/ffmpeg -y -nostdin -i $inputFile -af \"afade=t=out:st=$startTime:d=$length\" $tmpVol";
    $this->runFfmpeg($command);
    if ($delete) {
      unlink($inputFile);
    }
    return $tmpVol;
  }

  /**
   * Run a FFMpeg command.
   */
  private function runFfmpeg($command) {
    exec($command . ' 2>&1', $response, $code);
    if ($code) {
      throw new ElevenLabsFFmpegFailure(implode(",", $response), $code);
    }
    return $response;
  }

  /**
   * Normalize audio.
   */
  private function normalizeFile() {
    $tmpNormalized = $this->generateUniqueFile();
    // Run first run to get normalization values.
    $command = "/usr/bin/ffmpeg -y -i \"{$this->tmpFile}\" -pass 1 -filter:a loudnorm=print_format=json -f mp3 /dev/null";
    $response = $this->runFfmpeg($command);
    $json = "";
    $save = FALSE;
    foreach ($response as $line) {
      if ($save) {
        $json .= $line;
      }
      if (strpos($line, '[Parsed_loudnorm') !== FALSE) {
        $save = TRUE;
      }
    }
    // Run second run with the normalization values.
    $normValues = (json_decode($json, TRUE));
    $command = "/usr/bin/ffmpeg -y -nostdin -i \"{$this->tmpFile}\" -pass 2 -filter:a \"loudnorm=linear=false:measured_I={$normValues['input_i']}:measured_LRA={$normValues['input_lra']}:measured_tp={$normValues['input_tp']}:measured_thresh={$normValues['input_thresh']}\" -c:a libmp3lame -b:a 128k \"$tmpNormalized\"";
    $this->runFfmpeg($command);
    unlink($this->tmpFile);
    $this->tmpFile = $tmpNormalized;
  }

  /**
   * Get start time of a clip.
   *
   * @param string $path
   *   The file path.
   * @param bool $add
   *   If it should add to the total time.
   *
   * @return int
   *   The duration in 10th of ms.
   */
  private function getDuration($path, $add = TRUE) {
    $output = [];
    $command = '/usr/bin/ffmpeg -nostdin -i "' . $path . '" 2>&1 | grep "Duration"';
    exec("$command", $output);
    preg_match('/Duration\:(.*)start/i', $output[0], $match);
    $timeParts = explode(':', str_replace([',', '.'], '', $match[1]));
    $time = ($timeParts[0] * 360000) + ($timeParts[1] * 6000) + $timeParts[2];
    if ($add) {
      $this->totalTime += $time;
    }
    return $time;
  }

  /**
   * Generate tmp file.
   *
   * @param string $ext
   *   The extension.
   *
   * @return string
   *   The file path.
   */
  private function generateUniqueFile($ext = 'mp3') {
    $tmpFile = $this->fileSystem->getTempDirectory() . '/' . $this->uuid->generate() . '.' . $ext;
    $this->tmpFiles[] = $tmpFile;
    return $tmpFile;
  }
}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc