초기 커밋: 5130 레거시 시스템
- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
This commit is contained in:
46
ai_sound/api/get_api_key.php
Normal file
46
ai_sound/api/get_api_key.php
Normal 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
578
ai_sound/index.php
Normal 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">
|
||||
© {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>
|
||||
|
||||
Reference in New Issue
Block a user