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