2026-02-15 08:46:28 +09:00
< ? php
namespace App\Services\Video ;
2026-02-15 13:51:38 +09:00
use App\Models\System\AiConfig ;
use App\Services\GoogleCloudService ;
use Illuminate\Support\Facades\Http ;
2026-02-15 08:46:28 +09:00
use Illuminate\Support\Facades\Log ;
class BgmService
{
2026-02-15 13:51:38 +09:00
private GoogleCloudService $googleCloud ;
public function __construct ( GoogleCloudService $googleCloud )
{
$this -> googleCloud = $googleCloud ;
}
/**
* Google Lyria API로 AI 배경음악 생성
*
* @ return string | null 생성된 MP3 파일 경로
*/
public function generateWithLyria ( string $mood , int $durationSec , string $savePath ) : ? string
{
$config = AiConfig :: getActiveGemini ();
if ( ! $config || ! $config -> isVertexAi ()) {
Log :: info ( 'BgmService: Vertex AI 설정 없음, Lyria 건너뜀' );
return null ;
}
$token = $this -> googleCloud -> getAccessToken ();
if ( ! $token ) {
Log :: warning ( 'BgmService: Lyria 액세스 토큰 실패' );
return null ;
}
try {
$projectId = $config -> getProjectId ();
$region = $config -> getRegion ();
$prompt = $this -> buildMusicPrompt ( $mood );
$url = " https:// { $region } -aiplatform.googleapis.com/v1/projects/ { $projectId } /locations/ { $region } /publishers/google/models/lyria:predict " ;
Log :: info ( 'BgmService: Lyria API 요청' , [ 'mood' => $mood , 'prompt' => $prompt ]);
$response = Http :: withToken ( $token )
-> timeout ( 120 )
-> post ( $url , [
'instances' => [
[ 'prompt' => $prompt ],
],
]);
if ( ! $response -> successful ()) {
Log :: warning ( 'BgmService: Lyria API 실패' , [
'status' => $response -> status (),
'body' => substr ( $response -> body (), 0 , 500 ),
]);
return null ;
}
$data = $response -> json ();
$audioContent = $data [ 'predictions' ][ 0 ][ 'audioContent' ] ? ? null ;
if ( ! $audioContent ) {
Log :: warning ( 'BgmService: Lyria 응답에 오디오 없음' );
return null ;
}
$dir = dirname ( $savePath );
if ( ! is_dir ( $dir )) {
mkdir ( $dir , 0755 , true );
}
// WAV 저장 후 MP3 변환 + 영상 길이에 맞춤
$wavPath = " { $dir } /bgm_lyria.wav " ;
file_put_contents ( $wavPath , base64_decode ( $audioContent ));
// MP3 변환 + 영상 길이 맞춤 (30초 BGM이 짧으면 루프, 길면 트림)
$cmd = sprintf (
'ffmpeg -y -stream_loop -1 -i %s -t %d -af "afade=t=in:d=1,afade=t=out:st=%d:d=3" -c:a libmp3lame -q:a 2 %s 2>&1' ,
escapeshellarg ( $wavPath ),
$durationSec ,
max ( 0 , $durationSec - 3 ),
escapeshellarg ( $savePath )
);
exec ( $cmd , $output , $returnCode );
@ unlink ( $wavPath );
if ( $returnCode !== 0 ) {
Log :: error ( 'BgmService: Lyria WAV→MP3 변환 실패' , [
'output' => implode ( " \n " , array_slice ( $output , - 5 )),
]);
return null ;
}
Log :: info ( 'BgmService: Lyria BGM 생성 완료' , [
'path' => $savePath ,
'duration' => $durationSec ,
]);
return $savePath ;
} catch ( \Exception $e ) {
Log :: error ( 'BgmService: Lyria 예외' , [ 'error' => $e -> getMessage ()]);
return null ;
}
}
/**
* 분위기 → Lyria 영어 프롬프트 변환
*/
private function buildMusicPrompt ( string $mood ) : string
{
$prompts = [
'upbeat' => 'Upbeat cheerful background music for a short health wellness video. Light electronic beat with positive energy, moderate tempo. Instrumental only, no vocals.' ,
'energetic' => 'Energetic motivating background music for a fitness video. Fast-paced electronic beat with driving rhythm. Instrumental only, no vocals.' ,
'exciting' => 'Exciting dynamic background music for a surprising facts video. Building tension with electronic elements. Instrumental only, no vocals.' ,
'calm' => 'Calm soothing background music for a relaxation wellness video. Gentle piano and ambient pads, slow tempo. Instrumental only, no vocals.' ,
'dramatic' => 'Dramatic cinematic background music for a revealing truth video. Orchestral tension with building suspense. Instrumental only, no vocals.' ,
'happy' => 'Happy bright background music for a positive health tips video. Cheerful ukulele and light percussion. Instrumental only, no vocals.' ,
'mysterious' => 'Mysterious intriguing background music for a health secrets video. Dark ambient with subtle electronic textures. Instrumental only, no vocals.' ,
'inspiring' => 'Inspiring uplifting background music for a motivational health video. Warm piano with gentle strings, building progression. Instrumental only, no vocals.' ,
];
$moodLower = strtolower ( $mood );
foreach ( $prompts as $key => $prompt ) {
if ( str_contains ( $moodLower , $key )) {
return $prompt ;
}
}
return 'Light pleasant background music for a short health video. Moderate tempo, positive mood, gentle electronic beat. Instrumental only, no vocals.' ;
}
2026-02-15 08:46:28 +09:00
/**
* 분위기별 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 ;
}
/**
2026-02-15 10:45:22 +09:00
* 분위기별 앰비언트 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 ); // 각 음의 떨림 속도를 다르게
2026-02-25 11:45:01 +09:00
$expr .= ( $expr ? ' + ' : '' ) . " { $vol } *sin( { $freq } *2*PI*t)*((0.5+0.5*sin( { $modFreq } *2*PI*t))) " ;
2026-02-15 10:45:22 +09:00
}
// 페이드인(3초) + 페이드아웃(3초) 추가
$cmd = sprintf (
'ffmpeg -y -f lavfi -i "aevalsrc=%s:s=44100:d=%d" '
2026-02-25 11:45:01 +09:00
. '-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' ,
2026-02-15 10:45:22 +09:00
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 사용 , 최종 폴백 )
2026-02-15 08:46:28 +09:00
*/
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 ;
}
}