/
mikopbx
/
ModuleBeelinePbx
Обзор
Документация
Войти
/
mikopbx
/
ModuleBeelinePbx
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
master
Lib/AudioRecodeHelper.php
204 строки
8 KB
boffart
Инициализация модуля ModuleBeelinePbx
08 июл 2026, 17:11
08 июл 2026, 17:11
1cc4e1a
Код
Авторство
О чём код?
<?php /* * MikoPBX - free phone system for small business * Copyright © 2017-2026 Alexey Portnov and Nikolay Beketov * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with this program. * If not, see <https://www.gnu.org/licenses/>. */ namespace Modules\ModuleBeelinePbx\Lib; use MikoPBX\Core\System\Util; /** * Перекодировщик MP3-записей, скачанных из API Облачной АТС Билайн. * * Билайн отдаёт файл записи как application/octet-stream (MP3), заголовки * которого не всегда корректно парсятся плеером CDR / STT-сервисами. * Перекодирование в валидный CBR 32 kbps снимает проблему: содержимое то же, * заголовки валидны и файл гарантированно проигрывается. * * Раскладку источника СОХРАНЯЕМ: число каналов (mono/stereo) и частоту * дискретизации (8/16 кГц) наследуем от входа — перекодируется только * контейнер/заголовки. Работает через ffmpeg (предпочтительно) или через * sox→lame; если ни одного транскодера нет — тихо возвращает false и * оставляет оригинал. */ class AudioRecodeHelper { /** Целевой битрейт, kbps. */ public const TARGET_BITRATE_KBPS = 32; /** Однажды залогировали отсутствие транскодера — чтобы не спамить лог. */ private static bool $missingTranscoderLogged = false; /** Внешний logger для ошибок перекодирования. */ private static ?Logger $logger = null; /** * Передаёт внешний Logger. В long-running контексте (FPM) обязательно * сбросить в null по завершении работы, иначе static reference переживёт * запрос и Logger будет вызван с закрытым stream. */ public static function setLogger(?Logger $logger): void { self::$logger = $logger; } private static function log(string $msg): void { if (self::$logger !== null) { self::$logger->writeError($msg); return; } Util::sysLogMsg('ModuleBeelinePbx', $msg); } /** * Доступен ли хотя бы один транскодер (ffmpeg или sox+lame). */ public static function isTranscoderAvailable(): bool { if (self::resolveBin('ffmpeg') !== '') { return true; } return self::resolveBin('sox') !== '' && self::resolveBin('lame') !== ''; } /** * Найти полный путь к бинарю, либо вернуть ''. Защита от Util::which(), * который может отдать имя без пути (PATH в sh != PATH у php.backend). */ private static function resolveBin(string $name): string { $candidate = Util::which($name); if ($candidate !== '' && strpos($candidate, '/') !== false && is_executable($candidate)) { return $candidate; } foreach (['/usr/bin/', '/usr/local/bin/', '/sbin/', '/usr/sbin/'] as $dir) { $p = $dir . $name; if (is_executable($p)) { return $p; } } return ''; } /** * Перекодировать MP3 in-place в валидный CBR 32 kbps, сохранив раскладку * источника (число каналов и частоту дискретизации). * * @param string $path Полный путь к MP3-файлу (должен существовать и быть доступен на запись). * @return bool true — файл успешно заменён; false — оставлен оригинал. */ public static function recodeMp3(string $path): bool { if (!is_file($path) || filesize($path) === 0) { return false; } $ffmpeg = self::resolveBin('ffmpeg'); $sox = self::resolveBin('sox'); $lame = self::resolveBin('lame'); if (empty($ffmpeg) && (empty($sox) || empty($lame))) { if (!self::$missingTranscoderLogged) { self::$missingTranscoderLogged = true; self::log('audio recode skipped: neither ffmpeg nor sox+lame are available'); } return false; } // Уникальное имя tmp (pid+uniqid): параллельные процессы не должны // столкнуться на общем временном пути. $tmp = $path . '.recode.tmp.' . getmypid() . '.' . uniqid('', true) . '.mp3'; @unlink($tmp); $ok = !empty($ffmpeg) ? self::recodeWithFfmpeg($ffmpeg, $path, $tmp) : self::recodeWithSoxLame($sox, $lame, $path, $tmp); // exec() не инвалидирует stat-кеш, а @unlink выше прогрел его отрицательно. clearstatcache(true, $tmp); if (!$ok || !is_file($tmp) || filesize($tmp) === 0) { @unlink($tmp); return false; } if (!@rename($tmp, $path)) { @unlink($tmp); self::log("audio recode: failed to replace $path"); return false; } return true; } /** * Перекодирование через ffmpeg. -ac/-ar не задаём — раскладка от источника. */ private static function recodeWithFfmpeg(string $ffmpeg, string $in, string $out): bool { $cmd = escapeshellarg($ffmpeg) . ' -y -loglevel error' . ' -i ' . escapeshellarg($in) . ' -codec:a libmp3lame' . ' -b:a ' . self::TARGET_BITRATE_KBPS . 'k' . ' ' . escapeshellarg($out) . ' 2>&1'; exec($cmd, $output, $rc); if ($rc !== 0) { self::log("ffmpeg recode failed (rc=$rc): " . implode(' | ', $output)); return false; } return true; } /** * Перекодирование через sox→lame: sox декодирует MP3 в WAV, lame жмёт в MP3 32 kbps. */ private static function recodeWithSoxLame(string $sox, string $lame, string $in, string $out): bool { $tmpWav = $in . '.recode.tmp.' . getmypid() . '.' . uniqid('', true) . '.wav'; @unlink($tmpWav); // -t mp3 ДО входа: sox определяет формат по расширению, временный файл // скачивания может иметь «нестандартный» хвост. $cmd1 = escapeshellarg($sox) . ' -t mp3 ' . escapeshellarg($in) . ' -t wav -b 16' . ' ' . escapeshellarg($tmpWav) . ' 2>&1'; exec($cmd1, $out1, $rc1); clearstatcache(true, $tmpWav); if ($rc1 !== 0 || !is_file($tmpWav) || filesize($tmpWav) === 0) { @unlink($tmpWav); self::log("sox decode failed (rc=$rc1): " . implode(' | ', $out1)); return false; } $cmd2 = escapeshellarg($lame) . ' --quiet --cbr -b ' . self::TARGET_BITRATE_KBPS . ' ' . escapeshellarg($tmpWav) . ' ' . escapeshellarg($out) . ' 2>&1'; exec($cmd2, $out2, $rc2); @unlink($tmpWav); if ($rc2 !== 0) { self::log("lame encode failed (rc=$rc2): " . implode(' | ', $out2)); return false; } return true; } }