초기 커밋: 5130 레거시 시스템

- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경
- DB 연결 하드코딩 → .env 기반으로 변경
- MySQL strict mode DATE 오류 수정
This commit is contained in:
2025-12-10 20:14:31 +09:00
commit aca1767eb9
6728 changed files with 1863265 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
<?php
/**
* Gemini API Key 조회 API
* 보안을 위해 서버 측에서 API 키를 관리합니다.
*/
header('Content-Type: application/json; charset=utf-8');
// API 키 파일 경로 (프로젝트 루트의 apikey 디렉토리)
$apiKeyFile = dirname(dirname(__DIR__)) . '/apikey/gemini_api_key.txt';
try {
if (file_exists($apiKeyFile)) {
$apiKey = trim(file_get_contents($apiKeyFile));
// 플레이스홀더 텍스트 체크
if (empty($apiKey) ||
strpos($apiKey, '================================') !== false ||
strpos($apiKey, 'GEMINI API KEY') !== false ||
strpos($apiKey, '여기에') !== false ||
strlen($apiKey) < 20) {
echo json_encode([
'success' => false,
'error' => 'API Key가 설정되지 않았습니다. apikey/gemini_api_key.txt 파일에 유효한 API Key를 입력해주세요.'
], JSON_UNESCAPED_UNICODE);
return;
}
echo json_encode([
'success' => true,
'apiKey' => $apiKey
], JSON_UNESCAPED_UNICODE);
} else {
// 파일이 없으면 생성 안내
echo json_encode([
'success' => false,
'error' => 'API Key 파일을 찾을 수 없습니다. apikey/gemini_api_key.txt 파일을 생성하고 API Key를 입력해주세요.'
], JSON_UNESCAPED_UNICODE);
}
} catch (Exception $e) {
echo json_encode([
'success' => false,
'error' => 'API Key를 읽는 중 오류가 발생했습니다: ' . $e->getMessage()
], JSON_UNESCAPED_UNICODE);
}
?>

578
ai_sound/index.php Normal file
View File

@@ -0,0 +1,578 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAM Sonic Branding - Signature Sound Generator</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Google GenAI SDK - ES Module로 로드 -->
<script type="module">
// Google GenAI SDK 동적 로드
const loadGenAI = async () => {
try {
const module = await import('https://aistudiocdn.com/@google/genai@^1.32.0');
window.GoogleGenAI = module.GoogleGenAI;
window.Modality = module.Modality;
window.GenAILoaded = true;
// 로드 완료 이벤트 발생
window.dispatchEvent(new Event('genai-loaded'));
} catch (error) {
console.error('Failed to load Google GenAI SDK:', error);
window.GenAILoaded = false;
}
};
loadGenAI();
</script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fade-in 0.3s ease-out;
}
</style>
</head>
<body class="bg-gray-50 text-gray-900">
<div id="root"></div>
<script type="text/babel">
const { useState, useRef, useEffect } = React;
// Constants
const SOUND_PROFILES = [
{
id: 'REVENUE',
title: "Revenue Sound",
subtitle: "입금·매출·수주 알림",
concept: "승리의 소리 (Victory)",
emotion: "기쁨, 성취감, 밝고 고급스러운 희열",
defaultText: [
"쌤~ 대표님, 돈 들어왔습니다!",
"쌤. 매출이 확정되었습니다.",
"쌤! 나이스 샷!"
],
soundDesignNote: "Rising Tone, Metallic Chime",
colorFrom: "from-yellow-400",
colorTo: "to-orange-500",
icon: "BadgeDollarSign",
voiceStyle: "Kore"
},
{
id: 'RISK',
title: "Risk Sound",
subtitle: "미수·재고 부족·이상징후",
concept: "직관적 경고 (Warning)",
emotion: "묵직한 경고, 안정된 긴장감",
defaultText: [
"쌤, 체크하실 사항이 있습니다.",
"쌤. 재고 부족 알림입니다.",
"미수금 변동이 감지되었습니다, 쌤."
],
soundDesignNote: "Low Bass, Vibration",
colorFrom: "from-red-500",
colorTo: "to-red-900",
icon: "ShieldAlert",
voiceStyle: "Fenrir"
},
{
id: 'APPROVAL',
title: "Approval Sound",
subtitle: "결재·보고·흐름 진행",
concept: "업무의 흐름 (Flow)",
emotion: "부드러움, 안정감, 신뢰",
defaultText: [
"쌤. 결재 서류가 도착했습니다.",
"쌤. 오늘의 브리핑이 준비되었습니다.",
"쌤. 승인 완료."
],
soundDesignNote: "Paper flip, Click, Business Tone",
colorFrom: "from-blue-400",
colorTo: "to-teal-600",
icon: "FileCheck",
voiceStyle: "Zephyr"
}
];
// WAV 헤더 추가 함수
const writeString = (view, offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
const addWavHeader = (samples, sampleRate = 24000, numChannels = 1, bitDepth = 16) => {
const buffer = new ArrayBuffer(44 + samples.length);
const view = new DataView(buffer);
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + samples.length, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * (bitDepth / 8), true);
view.setUint16(32, numChannels * (bitDepth / 8), true);
view.setUint16(34, bitDepth, true);
writeString(view, 36, 'data');
view.setUint32(40, samples.length, true);
const bytes = new Uint8Array(buffer);
bytes.set(samples, 44);
return buffer;
};
// Gemini TTS 서비스
const generateSpeech = async (text, voiceName, apiKey) => {
// GenAI SDK가 로드될 때까지 대기
if (!window.GoogleGenAI) {
if (window.GenAILoaded === false) {
throw new Error('Google GenAI SDK 로드 실패');
}
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Google GenAI SDK 로드 타임아웃'));
}, 10000);
if (window.GoogleGenAI) {
clearTimeout(timeout);
resolve();
return;
}
const checkInterval = setInterval(() => {
if (window.GoogleGenAI) {
clearInterval(checkInterval);
clearTimeout(timeout);
resolve();
}
}, 100);
window.addEventListener('genai-loaded', () => {
clearInterval(checkInterval);
clearTimeout(timeout);
resolve();
}, { once: true });
});
}
const ai = new window.GoogleGenAI({ apiKey });
// SAM을 쌤으로 변환
const processedText = text.replace(/SAM/gi, "");
// 20초 타임아웃
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("요청 시간 초과 (20초). 다시 시도해주세요.")), 20000)
);
const apiCall = ai.models.generateContent({
model: "gemini-2.5-flash-preview-tts",
contents: [{ parts: [{ text: processedText }] }],
config: {
responseModalities: [window.Modality.AUDIO],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: voiceName
},
},
},
},
});
const response = await Promise.race([apiCall, timeoutPromise]);
const base64Audio = response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
if (!base64Audio) {
throw new Error("Gemini에서 오디오 데이터를 받지 못했습니다.");
}
// Base64를 Uint8Array로 변환 (Raw PCM)
const binaryString = window.atob(base64Audio);
const len = binaryString.length;
const pcmBytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
pcmBytes[i] = binaryString.charCodeAt(i);
}
// WAV 헤더 추가
const wavBuffer = addWavHeader(pcmBytes);
// Blob 생성
const blob = new Blob([wavBuffer], { type: "audio/wav" });
return URL.createObjectURL(blob);
};
// Icon 컴포넌트
const Icon = ({ name, className = '' }) => {
useEffect(() => {
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons();
}
}, [name]);
const iconMap = {
BadgeDollarSign: <i data-lucide="badge-dollar-sign" className={className}></i>,
ShieldAlert: <i data-lucide="shield-alert" className={className}></i>,
FileCheck: <i data-lucide="file-check" className={className}></i>,
Volume2: <i data-lucide="volume-2" className={className}></i>,
Info: <i data-lucide="info" className={className}></i>,
Play: <i data-lucide="play" className={className}></i>,
Download: <i data-lucide="download" className={className}></i>,
RefreshCw: <i data-lucide="refresh-cw" className={className}></i>,
};
return iconMap[name] || null;
};
// SoundGeneratorCard 컴포넌트
const SoundGeneratorCard = ({ profile, apiKey }) => {
const [inputText, setInputText] = useState(profile.defaultText[0]);
const [isLoading, setIsLoading] = useState(false);
const [audioUrl, setAudioUrl] = useState(null);
const [error, setError] = useState(null);
const audioRef = useRef(null);
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
return () => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
};
}, [audioUrl]);
useEffect(() => {
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons();
}
}, [audioUrl, error]);
const handleGenerate = async () => {
setIsLoading(true);
setError(null);
setAudioUrl(null);
try {
const url = await generateSpeech(inputText, profile.voiceStyle, apiKey);
if (isMounted.current) {
setAudioUrl(url);
}
} catch (err) {
if (isMounted.current) {
console.error(err);
let msg = err.message || "오디오 생성에 실패했습니다.";
setError(msg);
}
} finally {
if (isMounted.current) {
setIsLoading(false);
}
}
};
const handlePlay = () => {
if (audioRef.current) {
audioRef.current.currentTime = 0;
audioRef.current.play();
}
};
const handlePresetClick = (text) => {
setInputText(text);
setAudioUrl(null);
setError(null);
};
return (
<div className="bg-white rounded-3xl shadow-xl overflow-hidden flex flex-col h-full border border-gray-100 hover:shadow-2xl transition-shadow duration-300">
{/* Header Section */}
<div className={`p-6 bg-gradient-to-r ${profile.colorFrom} ${profile.colorTo}`}>
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-white/20 rounded-lg backdrop-blur-sm">
<Icon name={profile.icon} className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-white tracking-tight">{profile.title}</h2>
</div>
<p className="text-white/90 font-medium text-sm">{profile.subtitle}</p>
</div>
</div>
<div className="mt-4 inline-block px-3 py-1 bg-black/20 backdrop-blur-md rounded-full text-xs text-white font-semibold">
{profile.concept}
</div>
</div>
{/* Content Section */}
<div className="p-6 flex-1 flex flex-col gap-6">
{/* Concept Details */}
<div className="space-y-3 text-sm text-gray-600 bg-gray-50 p-4 rounded-xl border border-gray-100">
<div className="flex gap-2">
<span className="font-semibold text-gray-800 min-w-[60px]">Emotion:</span>
<span>{profile.emotion}</span>
</div>
<div className="flex gap-2">
<span className="font-semibold text-gray-800 min-w-[60px]">Sound:</span>
<span>{profile.soundDesignNote}</span>
</div>
</div>
{/* Input Area */}
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">Message</label>
<div className="relative">
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
className="w-full p-4 rounded-xl border border-gray-200 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 outline-none resize-none h-28 text-gray-700 font-medium"
placeholder="Enter text to generate..."
/>
<div className="absolute bottom-3 right-3 flex gap-1">
{profile.defaultText.map((text, idx) => (
<button
key={idx}
onClick={() => handlePresetClick(text)}
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-xs rounded-md text-gray-600 transition-colors"
title="Use preset"
>
Preset {idx + 1}
</button>
))}
</div>
</div>
<p className="text-xs text-gray-400 text-right">
* "SAM" 자동으로 ""으로 발음됩니다
</p>
</div>
{/* Action Area */}
<div className="mt-auto space-y-4">
{error && (
<div className="text-red-500 text-sm bg-red-50 p-3 rounded-lg flex items-center gap-2">
<Icon name="ShieldAlert" className="w-4 h-4" /> {error}
</div>
)}
{audioUrl ? (
<div className="animate-in">
<div className="flex items-center gap-3 bg-gray-900 text-white p-2 rounded-xl pr-4">
<button
onClick={handlePlay}
className="w-10 h-10 flex items-center justify-center bg-white text-black rounded-lg hover:bg-gray-200 transition-colors"
>
<Icon name="Play" className="w-5 h-5" />
</button>
<div className="flex-1 min-w-0">
<div className="h-8 flex items-center">
{/* Fake Waveform Visual */}
<div className="flex gap-1 items-center h-full w-full opacity-60">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="w-1 bg-white rounded-full animate-pulse"
style={{
height: `${Math.random() * 80 + 20}%`,
animationDelay: `${i * 0.1}s`
}}
></div>
))}
</div>
</div>
</div>
<a
href={audioUrl}
download={`SAM_${profile.id}_Alert.wav`}
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
title="Download Ringtone"
>
<Icon name="Download" className="w-5 h-5" />
</a>
<audio ref={audioRef} src={audioUrl} className="hidden" />
</div>
<button
onClick={() => setAudioUrl(null)}
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-800 flex items-center justify-center gap-2"
>
<Icon name="RefreshCw" className="w-4 h-4" /> Generate New
</button>
</div>
) : (
<button
onClick={handleGenerate}
disabled={isLoading || !inputText.trim() || !apiKey}
className={`w-full py-4 rounded-xl font-bold text-white shadow-lg flex items-center justify-center gap-2 transition-all transform active:scale-95
${isLoading || !apiKey ? 'bg-gray-400 cursor-not-allowed' : `bg-gradient-to-r ${profile.colorFrom} ${profile.colorTo} hover:brightness-110`}
`}
>
{isLoading ? (
<>
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Generating Voice...
</>
) : (
<>
Generate Ringtone
</>
)}
</button>
)}
</div>
</div>
</div>
);
};
// Main App Component
const App = () => {
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState(null);
useEffect(() => {
// API 키를 서버에서 가져오기
fetch('api/get_api_key.php')
.then(res => res.json())
.then(data => {
if (data.success && data.apiKey) {
setApiKey(data.apiKey);
} else {
setError(data.error || 'API Key를 가져올 수 없습니다.');
}
})
.catch(err => {
console.error('API Key fetch error:', err);
setError('API Key를 가져오는 중 오류가 발생했습니다.');
});
}, []);
useEffect(() => {
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons();
}
}, []);
return (
<div className="min-h-screen bg-gray-50 font-sans text-gray-900 selection:bg-indigo-100 selection:text-indigo-900">
{/* Navbar */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center text-white font-bold text-lg">
S
</div>
<h1 className="text-xl font-bold tracking-tight text-gray-900">
SAM <span className="text-gray-400 font-normal">Sonic Branding</span>
</h1>
</div>
<div className="hidden sm:flex items-center gap-4 text-sm font-medium text-gray-500">
<div className="flex items-center gap-1 bg-gray-100 px-3 py-1 rounded-full">
<Icon name="Info" className="w-4 h-4" />
<span>Pronunciation: "" (/sæm/)</span>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<div className="relative overflow-hidden bg-indigo-900 text-white py-16 sm:py-24">
<div className="absolute inset-0 bg-[url('https://picsum.photos/1920/1080?blur=10')] opacity-20 bg-cover bg-center"></div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div className="mx-auto w-16 h-16 bg-white/10 backdrop-blur-lg rounded-2xl flex items-center justify-center mb-6 ring-1 ring-white/20">
<Icon name="Volume2" className="w-8 h-8 text-indigo-300" />
</div>
<h2 className="text-4xl sm:text-5xl font-extrabold tracking-tight mb-4">
Signature Sound Generator
</h2>
<p className="text-lg text-indigo-200 max-w-2xl mx-auto mb-8">
Generate consistent, brand-aligned voice notifications for SAM.
Designed for Revenue, Risk, and Approval workflows.
</p>
</div>
</div>
{/* Main Grid */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 -mt-10 relative z-10">
{error && (
<div className="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{SOUND_PROFILES.map((profile) => (
<SoundGeneratorCard key={profile.id} profile={profile} apiKey={apiKey} />
))}
</div>
{/* Info Section */}
<div className="mt-20 border-t border-gray-200 pt-10 pb-20">
<h3 className="text-xl font-bold mb-6 text-center">Implementation Guidelines</h3>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
<h4 className="font-semibold text-indigo-600 mb-2">Pronunciation Rules</h4>
<p className="text-gray-600 text-sm leading-relaxed">
The AI model enforces the pronunciation of "SAM" as exactly "" (IPA: /sæm/).
It uses a soft 's' and a short 'æ' vowel. Avoid ssam, sam, saam variations that sound too English or too hard.
</p>
</div>
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
<h4 className="font-semibold text-indigo-600 mb-2">Technical Note</h4>
<p className="text-gray-600 text-sm leading-relaxed">
Generated audio is provided in high-quality WAV format suitable for mobile app push notifications (APNS/FCM) or desktop alerts.
The "Sound Design" layers (chimes, bass) are described conceptually but the generator focuses on the voice actor delivery.
</p>
</div>
</div>
</div>
</main>
{/* Footer */}
<footer className="bg-white border-t border-gray-200 py-8">
<div className="max-w-7xl mx-auto px-4 text-center text-gray-400 text-sm">
&copy; {new Date().getFullYear()} SAM Sonic Branding System. All rights reserved.
</div>
</footer>
</div>
);
};
// Render App
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>