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; } }