Files
sam-manage/app/Services/Video/BgmService.php
김보곤 01efd99004 fix:자막 중앙배치+크기2배, TTS 볼륨 증가, BGM 앰비언트 자동생성
- 자막: 하단→중앙(Alignment=5), 48→96pt, 외곽선 5px, 줄바꿈 12자
- TTS: 나레이션 볼륨 1.0→2.0 (2배 증가)
- BGM: 무음 대신 분위기별 앰비언트 화음 자동생성 (FFmpeg aevalsrc)
- BGM 볼륨: 0.15→0.4 (나레이션 방해 안 하는 수준)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:45:22 +09:00

213 lines
6.7 KiB
PHP

<?php
namespace App\Services\Video;
use Illuminate\Support\Facades\Log;
class BgmService
{
/**
* 분위기별 BGM 매핑 (로열티프리 BGM 파일 풀)
* storage/app/bgm/ 디렉토리에 미리 준비
*/
private array $moodMap = [
'upbeat' => ['upbeat_01.mp3', 'upbeat_02.mp3'],
'energetic' => ['energetic_01.mp3', 'energetic_02.mp3'],
'exciting' => ['exciting_01.mp3', 'exciting_02.mp3'],
'calm' => ['calm_01.mp3', 'calm_02.mp3'],
'dramatic' => ['dramatic_01.mp3', 'dramatic_02.mp3'],
'happy' => ['happy_01.mp3', 'happy_02.mp3'],
'sad' => ['sad_01.mp3', 'sad_02.mp3'],
'mysterious' => ['mysterious_01.mp3', 'mysterious_02.mp3'],
'inspiring' => ['inspiring_01.mp3', 'inspiring_02.mp3'],
];
/**
* 분위기에 맞는 BGM 선택
*
* @return string|null BGM 파일 경로
*/
public function select(string $mood, string $savePath): ?string
{
$bgmDir = storage_path('app/bgm');
// 분위기 키워드 매칭 (부분 일치 지원)
$matchedFiles = [];
$moodLower = strtolower($mood);
foreach ($this->moodMap as $key => $files) {
if (str_contains($moodLower, $key)) {
$matchedFiles = array_merge($matchedFiles, $files);
}
}
// 매칭되는 분위기가 없으면 기본값
if (empty($matchedFiles)) {
$matchedFiles = $this->moodMap['upbeat'] ?? ['default.mp3'];
}
// 랜덤 선택
$selectedFile = $matchedFiles[array_rand($matchedFiles)];
$sourcePath = "{$bgmDir}/{$selectedFile}";
// BGM 파일 존재 확인
if (! file_exists($sourcePath)) {
Log::warning('BgmService: BGM 파일 없음', [
'path' => $sourcePath,
'mood' => $mood,
]);
// BGM 디렉토리에서 아무 파일이나 선택
return $this->selectFallback($bgmDir, $savePath);
}
$dir = dirname($savePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
copy($sourcePath, $savePath);
Log::info('BgmService: BGM 선택 완료', [
'mood' => $mood,
'file' => $selectedFile,
]);
return $savePath;
}
/**
* 폴백: BGM 디렉토리에서 아무 MP3 선택
*/
private function selectFallback(string $bgmDir, string $savePath): ?string
{
if (! is_dir($bgmDir)) {
Log::error('BgmService: BGM 디렉토리 없음', ['dir' => $bgmDir]);
return null;
}
$files = glob("{$bgmDir}/*.mp3");
if (empty($files)) {
Log::error('BgmService: BGM 파일이 하나도 없음');
return null;
}
$selected = $files[array_rand($files)];
$dir = dirname($savePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
copy($selected, $savePath);
return $savePath;
}
/**
* 분위기별 앰비언트 BGM 생성 (FFmpeg 사용, BGM 파일이 없을 때 폴백)
*
* 로컬 BGM 파일이 없을 때 FFmpeg의 오디오 합성으로 간단한 앰비언트 배경음 생성
*/
public function generateAmbient(string $mood, int $durationSec, string $savePath): ?string
{
$dir = dirname($savePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
// 분위기별 코드 주파수 설정 (근음, 3도, 5도, 옥타브)
$chords = $this->getMoodChord($mood);
// FFmpeg aevalsrc로 화음 + 볼륨 모듈레이션(호흡 효과) 생성
$expr = '';
foreach ($chords as $i => $freq) {
$vol = 0.06 - ($i * 0.01); // 각 음의 볼륨을 점차 줄여 자연스럽게
$modFreq = 0.08 + ($i * 0.03); // 각 음의 떨림 속도를 다르게
$expr .= ($expr ? ' + ' : '') . "{$vol}*sin({$freq}*2*PI*t)*((0.5+0.5*sin({$modFreq}*2*PI*t)))";
}
// 페이드인(3초) + 페이드아웃(3초) 추가
$cmd = sprintf(
'ffmpeg -y -f lavfi -i "aevalsrc=%s:s=44100:d=%d" '
. '-af "lowpass=f=1500,highpass=f=80,afade=t=in:d=3,afade=t=out:st=%d:d=3" '
. '-c:a libmp3lame -q:a 4 %s 2>&1',
escapeshellarg($expr),
$durationSec + 2, // 페이드아웃 여유분
max(0, $durationSec - 2),
escapeshellarg($savePath)
);
Log::info('BgmService: 앰비언트 BGM 생성 시작', ['mood' => $mood, 'duration' => $durationSec]);
exec($cmd, $output, $returnCode);
if ($returnCode !== 0) {
Log::error('BgmService: 앰비언트 BGM 생성 실패, 무음 폴백', [
'output' => implode("\n", array_slice($output, -10)),
]);
return $this->generateSilence($durationSec, $savePath);
}
Log::info('BgmService: 앰비언트 BGM 생성 완료', ['path' => $savePath]);
return $savePath;
}
/**
* 분위기별 화음 주파수 반환
*/
private function getMoodChord(string $mood): array
{
$moodLower = strtolower($mood);
// 분위기별 코드 (근음 기준 메이저/마이너 코드)
if (str_contains($moodLower, 'sad') || str_contains($moodLower, 'dramatic')) {
// A minor (La-Do-Mi) - 슬프거나 극적인 분위기
return [220.00, 261.63, 329.63, 440.00];
}
if (str_contains($moodLower, 'calm') || str_contains($moodLower, 'mysterious')) {
// D minor7 (Re-Fa-La-Do) - 잔잔하거나 신비로운 분위기
return [146.83, 174.61, 220.00, 261.63];
}
if (str_contains($moodLower, 'inspiring') || str_contains($moodLower, 'happy')) {
// G major (Sol-Si-Re) - 영감을 주거나 밝은 분위기
return [196.00, 246.94, 293.66, 392.00];
}
// 기본: C major (Do-Mi-Sol) - 밝고 에너지 넘치는 분위기
return [130.81, 164.81, 196.00, 261.63];
}
/**
* 무음 BGM 생성 (FFmpeg 사용, 최종 폴백)
*/
public function generateSilence(int $durationSec, string $savePath): ?string
{
$dir = dirname($savePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$cmd = sprintf(
'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=stereo -t %d -c:a libmp3lame -q:a 9 %s 2>&1',
$durationSec,
escapeshellarg($savePath)
);
exec($cmd, $output, $returnCode);
if ($returnCode !== 0) {
Log::error('BgmService: 무음 BGM 생성 실패', ['output' => implode("\n", $output)]);
return null;
}
return $savePath;
}
}