roots / app.py
haepada's picture
Update app.py
33cb0be verified
raw
history blame
30.7 kB
import gradio as gr
import numpy as np
import librosa
from transformers import pipeline
from datetime import datetime
import os
import requests
import json
from dotenv import load_dotenv
# 환경변수 로드
load_dotenv()
# 상수 정의
WELCOME_MESSAGE = """
# 디지털 굿판에 오신 것을 환영합니다
디지털 굿판은 현대 도시 속에서 잊혀진 전통 굿의 정수를 담아낸 **디지털 의례의 공간**입니다.
이곳에서는 사람들의 목소리와 감정을 통해 **영적 교감**을 나누고, **자연과 도시의 에너지가 연결**됩니다.
이제, 평온함과 치유의 여정을 시작해보세요.
"""
ONCHEON_STORY = """
## 온천천 이야기 🌌
온천천의 물줄기는 신성한 금샘에서 시작됩니다. 금샘은 생명과 창조의 원천이며,
천상의 생명이 지상에서 숨을 틔우는 자리입니다. 도시의 소음 속에서도 신성한 생명력을 느껴보세요.
이곳에서 영적인 교감을 경험하며, 자연과 하나 되는 순간을 맞이해 보시기 바랍니다.
이 앱은 온천천의 사운드스케이프를 녹음하여 제작되었으며,
온천천 온천장역에서 장전역까지 걸으며 더 깊은 체험이 가능합니다.
"""
class SimpleDB:
def __init__(self, reflections_path="data/reflections.json", wishes_path="data/wishes.json"):
self.reflections_path = reflections_path
self.wishes_path = wishes_path
os.makedirs('data', exist_ok=True)
self.reflections = self._load_json(reflections_path)
self.wishes = self._load_json(wishes_path)
def _load_json(self, file_path):
if not os.path.exists(file_path):
with open(file_path, 'w', encoding='utf-8') as f:
json.dump([], f, ensure_ascii=False, indent=2)
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"Error loading {file_path}: {e}")
return []
def save_reflection(self, name, reflection, sentiment, timestamp=None):
if timestamp is None:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
reflection_data = {
"timestamp": timestamp,
"name": name,
"reflection": reflection,
"sentiment": sentiment
}
self.reflections.append(reflection_data)
self._save_json(self.reflections_path, self.reflections)
return True
def save_wish(self, name, wish, emotion_data=None, timestamp=None):
if timestamp is None:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
wish_data = {
"name": name,
"wish": wish,
"emotion": emotion_data,
"timestamp": timestamp
}
self.wishes.append(wish_data)
self._save_json(self.wishes_path, self.wishes)
return True
def _save_json(self, file_path, data):
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
print(f"Error saving to {file_path}: {e}")
return False
def get_all_reflections(self):
return sorted(self.reflections, key=lambda x: x["timestamp"], reverse=True)
def get_all_wishes(self):
return self.wishes
# API 설정
HF_API_TOKEN = os.getenv("roots", "")
if not HF_API_TOKEN:
print("Warning: HuggingFace API token not found. Some features may be limited.")
API_URL = "https://api-inference.huggingface.co/models/stabilityai/stable-diffusion-xl-base-1.0"
headers = {"Authorization": f"Bearer {HF_API_TOKEN}"} if HF_API_TOKEN else {}
# AI 모델 초기화
try:
speech_recognizer = pipeline(
"automatic-speech-recognition",
model="kresnik/wav2vec2-large-xlsr-korean"
)
text_analyzer = pipeline(
"sentiment-analysis",
model="nlptown/bert-base-multilingual-uncased-sentiment"
)
except Exception as e:
print(f"Error initializing AI models: {e}")
speech_recognizer = None
text_analyzer = None
# 필요한 디렉토리 생성
os.makedirs("generated_images", exist_ok=True)
# 음성 분석 관련 함수들
def calculate_baseline_features(audio_data):
try:
if isinstance(audio_data, tuple):
sr, y = audio_data
y = y.astype(np.float32)
elif isinstance(audio_data, str):
y, sr = librosa.load(audio_data, sr=16000)
else:
print("Unsupported audio format")
return None
if len(y) == 0:
print("Empty audio data")
return None
features = {
"energy": float(np.mean(librosa.feature.rms(y=y))),
"tempo": float(librosa.feature.tempo(y=y, sr=sr)[0]),
"pitch": float(np.mean(librosa.feature.zero_crossing_rate(y=y))),
"volume": float(np.mean(np.abs(y))),
"mfcc": librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13).mean(axis=1).tolist()
}
return features
except Exception as e:
print(f"Error calculating baseline: {str(e)}")
return None
def map_acoustic_to_emotion(features, baseline_features=None):
if features is None:
return {
"primary": "알 수 없음",
"intensity": 0,
"confidence": 0.0,
"secondary": "",
"characteristics": ["음성 분석 실패"],
"details": {
"energy_level": "0%",
"speech_rate": "알 수 없음",
"pitch_variation": "알 수 없음",
"voice_volume": "알 수 없음"
}
}
energy_norm = min(features["energy"] * 100, 100)
tempo_norm = min(features["tempo"] / 200, 1)
pitch_norm = min(features["pitch"] * 2, 1)
if baseline_features:
if baseline_features["energy"] > 0 and baseline_features["tempo"] > 0 and baseline_features["pitch"] > 0:
energy_norm = (features["energy"] / baseline_features["energy"]) * 50
tempo_norm = (features["tempo"] / baseline_features["tempo"])
pitch_norm = (features["pitch"] / baseline_features["pitch"])
emotions = {
"primary": "",
"intensity": energy_norm,
"confidence": 0.0,
"secondary": "",
"characteristics": []
}
# 감정 매핑 로직
if energy_norm > 70:
if tempo_norm > 0.6:
emotions["primary"] = "기쁨/열정"
emotions["characteristics"].append("빠르고 활기찬 말하기 패턴")
else:
emotions["primary"] = "분노/강조"
emotions["characteristics"].append("강한 음성 강도")
emotions["confidence"] = energy_norm / 100
elif pitch_norm > 0.6:
if energy_norm > 50:
emotions["primary"] = "놀람/흥분"
emotions["characteristics"].append("높은 음고와 강한 강세")
else:
emotions["primary"] = "관심/호기심"
emotions["characteristics"].append("음고 변화가 큼")
emotions["confidence"] = pitch_norm
elif energy_norm < 30:
if tempo_norm < 0.4:
emotions["primary"] = "슬픔/우울"
emotions["characteristics"].append("느리고 약한 음성")
else:
emotions["primary"] = "피로/무기력"
emotions["characteristics"].append("낮은 에너지 레벨")
emotions["confidence"] = (30 - energy_norm) / 30
else:
if tempo_norm > 0.5:
emotions["primary"] = "평온/안정"
emotions["characteristics"].append("균형잡힌 말하기 패턴")
else:
emotions["primary"] = "차분/진지"
emotions["characteristics"].append("안정적인 음성 특성")
emotions["confidence"] = 0.5
emotions["details"] = {
"energy_level": f"{energy_norm:.1f}%",
"speech_rate": f"{'빠름' if tempo_norm > 0.6 else '보통' if tempo_norm > 0.4 else '느림'}",
"pitch_variation": f"{'높음' if pitch_norm > 0.6 else '보통' if pitch_norm > 0.3 else '낮음'}",
"voice_volume": f"{'큼' if features['volume'] > 0.7 else '보통' if features['volume'] > 0.3 else '작음'}"
}
return emotions
def analyze_voice(audio_data, state):
if audio_data is None:
return state, "음성을 먼저 녹음해주세요.", "", "", ""
try:
sr, y = audio_data
y = y.astype(np.float32)
if len(y) == 0:
return state, "음성이 감지되지 않았습니다.", "", "", ""
acoustic_features = calculate_baseline_features((sr, y))
if acoustic_features is None:
return state, "음성 분석에 실패했습니다.", "", "", ""
# 음성 인식
if speech_recognizer:
try:
transcription = speech_recognizer({"sampling_rate": sr, "raw": y.astype(np.float32)})
text = transcription["text"]
except Exception as e:
print(f"Speech recognition error: {e}")
text = "음성 인식 실패"
else:
text = "음성 인식 모델을 불러올 수 없습니다."
# 음성 감정 분석
voice_emotion = map_acoustic_to_emotion(acoustic_features, state.get("baseline_features"))
# 텍스트 감정 분석
if text_analyzer and text:
try:
text_sentiment = text_analyzer(text)[0]
text_result = f"텍스트 감정 분석: {text_sentiment['label']} (점수: {text_sentiment['score']:.2f})"
except Exception as e:
print(f"Text analysis error: {e}")
text_sentiment = {"label": "unknown", "score": 0.0}
text_result = "텍스트 감정 분석 실패"
else:
text_sentiment = {"label": "unknown", "score": 0.0}
text_result = "텍스트 감정 분석을 수행할 수 없습니다."
voice_result = (
f"음성 감정: {voice_emotion['primary']} "
f"(강도: {voice_emotion['intensity']:.1f}%, 신뢰도: {voice_emotion['confidence']:.2f})\n"
f"특징: {', '.join(voice_emotion['characteristics'])}\n"
f"상세 분석:\n"
f"- 에너지 레벨: {voice_emotion['details']['energy_level']}\n"
f"- 말하기 속도: {voice_emotion['details']['speech_rate']}\n"
f"- 음높이 변화: {voice_emotion['details']['pitch_variation']}\n"
f"- 음성 크기: {voice_emotion['details']['voice_volume']}"
)
# 프롬프트 생성
prompt = generate_detailed_prompt(text, voice_emotion, text_sentiment)
state = {**state, "final_prompt": prompt}
return state, text, voice_result, text_result, prompt
except Exception as e:
print(f"Error in analyze_voice: {str(e)}")
return state, f"오류 발생: {str(e)}", "", "", ""
def generate_detailed_prompt(text, emotions, text_sentiment):
emotion_colors = {
"기쁨/열정": "밝은 노랑과 따뜻한 주황색",
"분노/강조": "강렬한 빨강과 짙은 검정",
"놀람/흥분": "선명한 파랑과 밝은 보라",
"관심/호기심": "연한 하늘색과 민트색",
"슬픔/우울": "어두운 파랑과 회색",
"피로/무기력": "탁한 갈색과 짙은 회색",
"평온/안정": "부드러운 초록과 베이지",
"차분/진지": "차분한 남색과 깊은 보라"
}
if emotions["intensity"] > 70:
visual_style = "역동적인 붓질과 강한 대비"
elif emotions["intensity"] > 40:
visual_style = "균형잡힌 구도와 중간 톤의 조화"
else:
visual_style = "부드러운 그라데이션과 차분한 톤"
prompt = f"한국 전통 민화 스타일의 추상화, {emotion_colors.get(emotions['primary'], '자연스러운 색상')} 기반. "
prompt += f"{visual_style}로 표현된 {emotions['primary']}의 감정. "
prompt += f"음성의 특징({', '.join(emotions['characteristics'])})을 화면의 동적 요소로 표현. "
prompt += f"발화 내용 '{text}'에서 느껴지는 감정({text_sentiment['label']} - 점수: {text_sentiment['score']:.2f})을 은유적 이미지로 담아내기."
return prompt
def generate_image_from_prompt(prompt):
if not prompt:
print("No prompt provided")
return None
try:
response = requests.post(
API_URL,
headers=headers,
json={
"inputs": prompt,
"parameters": {
"negative_prompt": "ugly, blurry, poor quality, distorted",
"num_inference_steps": 30,
"guidance_scale": 7.5
}
}
)
if response.status_code == 200:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
image_path = f"generated_images/image_{timestamp}.png"
os.makedirs("generated_images", exist_ok=True)
with open(image_path, "wb") as f:
f.write(response.content)
return image_path
else:
print(f"Error: {response.status_code}")
print(f"Response: {response.text}")
return None
except Exception as e:
print(f"Error generating image: {str(e)}")
return None
def safe_state_update(state, updates):
try:
new_state = {**state, **updates}
# 중요 상태값 검증
if "user_name" in updates:
new_state["user_name"] = str(updates["user_name"]).strip() or "익명"
if "baseline_features" in updates:
if updates["baseline_features"] is None:
return state # baseline이 None이면 상태 업데이트 하지 않음
return new_state
except Exception as e:
print(f"State update error: {e}")
return state
def create_interface():
db = SimpleDB()
initial_state = {
"user_name": "",
"baseline_features": None,
"reflections": [],
"wish": None,
"final_prompt": "",
"image_path": None,
"current_tab": 0
}
css = """
@media (max-width: 600px) {
.container { padding: 10px !important; }
.gradio-row {
flex-direction: column !important;
gap: 10px !important;
}
.gradio-button {
width: 100% !important;
margin: 5px 0 !important;
min-height: 44px !important; /* 모바일 터치 영역 개선 */
}
.gradio-textbox { width: 100% !important; }
.gradio-audio { width: 100% !important; }
.gradio-image { width: 100% !important; }
#audio-recorder { width: 100% !important; }
#result-image { width: 100% !important; }
.gradio-dataframe {
overflow-x: auto !important;
max-width: 100% !important;
}
}
/* 전반적인 UI 개선 */
.gradio-button {
transition: all 0.3s ease;
}
.gradio-button:active {
transform: scale(0.98);
}
"""
with gr.Blocks(theme=gr.themes.Soft(), css=css) as app:
state = gr.State(value=initial_state)
gr.Markdown("# 디지털 굿판")
gr.Markdown("""
1. 입장 → 2. 청신 → 3. 기원 → 4. 송신
순서대로 진행해주세요.
""")
with gr.Tabs(selected=0) as tabs:
# 입장 탭 (축원 포함)
with gr.TabItem("입장") as tab_entrance:
# 1단계: 첫 화면
welcome_section = gr.Column(visible=True)
with welcome_section:
gr.Markdown(WELCOME_MESSAGE)
name_input = gr.Textbox(
label="이름을 알려주세요",
placeholder="이름을 입력해주세요",
interactive=True
)
name_submit_btn = gr.Button("굿판 시작하기", variant="primary")
# 2단계: 세계관 설명
story_section = gr.Column(visible=False)
with story_section:
gr.Markdown(ONCHEON_STORY)
continue_btn = gr.Button("준비하기", variant="primary")
# 3단계: 축원 의식
blessing_section = gr.Column(visible=False)
with blessing_section:
gr.Markdown("### 축원의식을 시작하겠습니다")
gr.Markdown("'명짐 복짐 짊어지고 안가태평하시기를 비도발원 축원 드립니다'")
baseline_audio = gr.Audio(
label="축원 문장 녹음하기",
sources=["microphone"],
type="numpy",
streaming=False
)
set_baseline_btn = gr.Button("축원 마치기", variant="primary")
baseline_status = gr.Markdown("")
# 4단계: 굿판 입장 안내
entry_guide_section = gr.Column(visible=False)
with entry_guide_section:
gr.Markdown("## 굿판으로 입장하기")
gr.Markdown("""
* 청신 탭으로 이동해 주세요.
* 부산광역시 동래구 온천장역에서 시작하면 더욱 깊은 경험을 시작할 수 있습니다.
* (본격적인 경험을 시작하기에 앞서 이동을 권장드립니다)
""")
enter_btn = gr.Button("청신 의식 시작하기", variant="primary")
with gr.TabItem("청신") as tab_listen:
gr.Markdown("## 청신 - 소리로 정화하기")
gr.Markdown("""
온천천의 소리를 들으며 마음을 정화해보세요.
💫 이 앱은 온천천의 사운드스케이프를 녹음하여 제작되었으며,
온천천 온천장역에서 장전역까지 걸으며 더 깊은 체험이 가능합니다.
""")
play_music_btn = gr.Button("온천천의 소리 듣기", variant="secondary")
with gr.Row():
audio = gr.Audio(
value="assets/main_music.mp3",
type="filepath",
label="온천천의 소리",
interactive=False,
show_download_button=True,
visible=True
)
with gr.Column():
reflection_input = gr.Textbox(
label="지금 이 순간의 감상을 자유롭게 적어보세요",
lines=3,
max_lines=5
)
save_btn = gr.Button("감상 저장하기", variant="secondary")
reflections_display = gr.Dataframe(
headers=["시간", "감상", "감정 분석"],
label="기록된 감상들",
value=[], # 초기값은 빈 리스트
interactive=False,
wrap=True,
row_count=(5, "dynamic") # 동적으로 행 수 조정
)
# 기원 탭
with gr.TabItem("기원") as tab_wish:
gr.Markdown("## 기원 - 소원을 전해보세요")
with gr.Row():
with gr.Column():
voice_input = gr.Audio(
label="소원을 나누고 싶은 마음을 말해주세요",
sources=["microphone"],
type="numpy",
streaming=False
)
with gr.Row():
clear_btn = gr.Button("녹음 지우기", variant="secondary")
analyze_btn = gr.Button("소원 분석하기", variant="primary")
with gr.Column():
transcribed_text = gr.Textbox(
label="인식된 텍스트",
interactive=False
)
voice_emotion = gr.Textbox(
label="음성 감정 분석",
interactive=False
)
text_emotion = gr.Textbox(
label="텍스트 감정 분석",
interactive=False
)
# 송신 탭
with gr.TabItem("송신") as tab_send:
gr.Markdown("## 송신 - 소지(소원지)를 그려 날려 태워봅시다")
final_prompt = gr.Textbox(
label="생성된 프롬프트",
interactive=False,
lines=3
)
generate_btn = gr.Button("마음의 그림 그리기", variant="primary")
result_image = gr.Image(
label="생성된 이미지",
show_download_button=True
)
gr.Markdown("## 온천천에 전하고 싶은 소원을 남겨주세요")
final_reflection = gr.Textbox(
label="소원",
placeholder="당신의 소원을 한 줄로 남겨주세요...",
max_lines=3
)
save_final_btn = gr.Button("소원 전하기", variant="primary")
gr.Markdown("""
💫 여러분의 소원은 11월 25일 온천천 벽면에 설치될 소원나무에 전시될 예정입니다.
따뜻한 마음을 담아 작성해주세요.
""")
wishes_display = gr.Dataframe(
headers=["시간", "소원", "이름"],
label="기록된 소원들",
value=[],
interactive=False,
wrap=True
)
# 이벤트 핸들러들
def handle_name_submit(name, state):
if not name.strip():
return (
gr.update(visible=True),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
state
)
state = safe_state_update(state, {"user_name": name})
return (
gr.update(visible=False),
gr.update(visible=True),
gr.update(visible=False),
gr.update(visible=False),
state
)
def handle_continue():
return (
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=True),
gr.update(visible=False)
)
def handle_blessing_complete(audio, state):
if audio is None:
return state, "음성을 먼저 녹음해주세요.", gr.update(visible=True), gr.update(visible=False)
try:
# ... (기존 음성 처리 코드)
return (
state,
"축원이 완료되었습니다.",
gr.update(visible=False),
gr.update(visible=True)
)
except Exception as e:
return state, f"오류가 발생했습니다: {str(e)}", gr.update(visible=True), gr.update(visible=False)
def handle_enter():
return gr.update(selected=1) # 청신 탭으로 이동
def handle_start():
return gr.update(visible=False), gr.update(visible=True)
def handle_baseline(audio, current_state):
if audio is None:
return current_state, "음성을 먼저 녹음해주세요.", gr.update(selected=0)
try:
sr, y = audio
y = y.astype(np.float32)
features = calculate_baseline_features((sr, y))
if features:
current_state = safe_state_update(current_state, {
"baseline_features": features,
"current_tab": 1
})
return current_state, "기준점이 설정되었습니다. 청신 탭으로 이동합니다.", gr.update(selected=1)
return current_state, "기준점 설정에 실패했습니다. 다시 시도해주세요.", gr.update(selected=0)
except Exception as e:
print(f"Baseline error: {str(e)}")
return current_state, "오류가 발생했습니다. 다시 시도해주세요.", gr.update(selected=0)
def handle_save_reflection(text, state):
if not text.strip():
return state, []
try:
current_time = datetime.now().strftime("%H:%M:%S")
if text_analyzer:
sentiment = text_analyzer(text)[0]
sentiment_text = f"{sentiment['label']} ({sentiment['score']:.2f})"
db.save_reflection(state.get("user_name", "익명"), text, sentiment)
else:
sentiment_text = "분석 불가"
db.save_reflection(state.get("user_name", "익명"), text, {"label": "unknown", "score": 0.0})
new_reflection = [current_time, text, sentiment_text]
reflections = state.get("reflections", [])
reflections.append(new_reflection)
state = safe_state_update(state, {"reflections": reflections})
return state, db.get_all_reflections()
except Exception as e:
print(f"Error saving reflection: {e}")
return state, state.get("reflections", [])
def save_reflection_fixed(text, state):
if not text.strip():
return state, []
try:
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
name = state.get("user_name", "익명")
if text_analyzer:
sentiment = text_analyzer(text)[0]
sentiment_text = f"{sentiment['label']} ({sentiment['score']:.2f})"
else:
sentiment_text = "분석 불가"
# DB에 저장
db.save_reflection(name, text, sentiment_text)
# 화면에 표시할 데이터 형식으로 변환
display_data = []
all_reflections = db.get_all_reflections()
for ref in all_reflections:
display_data.append([
ref["timestamp"],
ref["reflection"],
ref["sentiment"]
])
# 상태 업데이트
state = safe_state_update(state, {"reflections": display_data})
return state, display_data
except Exception as e:
print(f"Error saving reflection: {e}")
return state, []
def handle_save_wish(text, state):
if not text.strip():
return "소원을 입력해주세요.", []
try:
name = state.get("user_name", "익명")
db.save_wish(name, text)
wishes = db.get_all_wishes()
wish_display_data = [
[wish["timestamp"], wish["wish"], wish["name"]]
for wish in wishes
]
return "소원이 저장되었습니다.", wish_display_data
except Exception as e:
print(f"Error saving wish: {e}")
return "오류가 발생했습니다.", []
# 이벤트 연결
name_submit_btn.click(
fn=handle_name_submit,
inputs=[name_input, state],
outputs=[welcome_section, story_section, blessing_section, entry_guide_section, state]
)
continue_btn.click(
fn=handle_continue,
outputs=[story_section, welcome_section, blessing_section, entry_guide_section]
)
set_baseline_btn.click(
fn=handle_blessing_complete,
inputs=[baseline_audio, state],
outputs=[state, baseline_status, blessing_section, entry_guide_section]
)
enter_btn.click(
fn=handle_enter,
outputs=[tabs]
)
play_music_btn.click(
fn=lambda: "assets/main_music.mp3",
outputs=[audio]
)
save_btn.click(
fn=save_reflection_fixed, # handle_save_reflection -> save_reflection_fixed
inputs=[reflection_input, state],
outputs=[state, reflections_display]
)
clear_btn.click(
fn=lambda: None,
outputs=[voice_input]
)
analyze_btn.click(
fn=analyze_voice,
inputs=[voice_input, state],
outputs=[state, transcribed_text, voice_emotion, text_emotion, final_prompt]
)
generate_btn.click(
fn=generate_image_from_prompt,
inputs=[final_prompt],
outputs=[result_image]
)
save_final_btn.click(
fn=handle_save_wish,
inputs=[final_reflection, state],
outputs=[baseline_status, wishes_display]
)
return app
if __name__ == "__main__":
demo = create_interface()
demo.launch(
debug=True,
share=True,
server_name="0.0.0.0",
server_port=7860,
show_error=True,
height=None,
width="100%"
)