roots / app.py
haepada's picture
Update app.py
0dfe358 verified
raw
history blame
29.5 kB
"""
디지털 굿판 (Digital Gut-pan) - 도시 속 디지털 의례 공간
- 음성 기반 감정 분석
- 소원 아카이브 시스템
- PWA 지원
"""
import os
import json
import numpy as np
import librosa
from datetime import datetime
from flask import Flask, send_from_directory, render_template
import gradio as gr
from transformers import pipeline
import requests
from dotenv import load_dotenv
# Flask 앱 초기화
app = Flask(__name__)
# 환경변수 로드
load_dotenv()
# 상수 정의
WELCOME_MESSAGE = """
# 디지털 굿판에 오신 것을 환영합니다
디지털 굿판은 현대 도시 속에서 잊혀진 전통 굿의 정수를 담아낸 **디지털 의례의 공간**입니다.
이곳에서는 사람들의 목소리와 감정을 통해 **영적 교감**을 나누고, **자연과 도시의 에너지가 연결**됩니다.
이제, 평온함과 치유의 여정을 시작해보세요.
"""
ONCHEON_STORY = """
## 온천천 이야기 🌌
온천천의 물줄기는 신성한 금샘에서 시작됩니다. 금샘은 생명과 창조의 원천이며,
천상의 생명이 지상에서 숨을 틔우는 자리입니다. 도시의 소음 속에서도 신성한 생명력을 느껴보세요.
이곳에서 영적인 교감을 경험하며, 자연과 하나 되는 순간을 맞이해 보시기 바랍니다.
이 앱은 온천천의 사운드스케이프를 녹음하여 제작되었으며,
온천천 온천장역에서 장전역까지 걸으며 더 깊은 체험이 가능합니다.
"""
# 경로 설정
PATHS = {
'data': 'data',
'assets': 'assets',
'static': 'static',
'templates': 'templates',
'generated_images': 'generated_images',
'wishes': 'data/wishes'
}
# 디렉토리 생성
for path in PATHS.values():
os.makedirs(path, exist_ok=True)
# 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
# Flask 라우트
@app.route('/static/<path:path>')
def serve_static(path):
return send_from_directory('static', path)
@app.route('/assets/<path:path>')
def serve_assets(path):
return send_from_directory('assets', path)
@app.route('/wishes/<path:path>')
def serve_wishes(path):
return send_from_directory('data/wishes', path)
# CSS 스타일
GRADIO_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;
touch-action: manipulation !important;
user-select: none !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);
}
/* 모바일 최적화 */
* {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
.record-button {
position: relative !important;
touch-action: manipulation !important;
}
"""
# 초기 상태 정의
INITIAL_STATE = {
"user_name": "",
"baseline_features": None,
"current_tab": 0,
"last_emotion": None
}
# 음성 분석 관련 함수들
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
class WishArchive:
"""소원 아카이브 관리 클래스"""
def __init__(self):
"""아카이브 초기화"""
self.archive_path = os.path.join(PATHS['wishes'], 'wish_archive.json')
os.makedirs(os.path.dirname(self.archive_path), exist_ok=True)
self.wishes = self._load_archive()
def _load_archive(self) -> list:
"""아카이브 파일 로드"""
if not os.path.exists(self.archive_path):
return []
try:
with open(self.archive_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"Error loading wish archive: {e}")
return []
def add_wish(self, wish_data: dict) -> bool:
"""새로운 소원 추가"""
try:
self.wishes.append(wish_data)
with open(self.archive_path, 'w', encoding='utf-8') as f:
json.dump(self.wishes, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
print(f"Error saving wish: {e}")
return False
def get_display_data(self) -> list:
"""화면 표시용 데이터"""
return [
[wish["timestamp"], wish["wish"], wish["name"]]
for wish in sorted(self.wishes, key=lambda x: x["timestamp"], reverse=True)
]
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 create_interface():
"""Gradio 인터페이스 생성"""
wish_archive = WishArchive()
initial_state = {
"user_name": "",
"baseline_features": None,
"current_tab": 0,
"last_emotion": None,
"analysis_complete": False
}
with gr.Blocks(theme=gr.themes.Soft(), css=GRADIO_CSS, analytics_enabled=False, title="디지털 굿판") 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("'명짐 복짐 짊어지고 안가태평하시기를 비도발원 축원 드립니다'")
with gr.Column() as recording_section:
baseline_audio = gr.Audio(
label="축원 문장 녹음하기",
sources=["microphone"],
type="numpy",
streaming=False
)
analysis_status = gr.Markdown(
"",
visible=False
)
with gr.Column(visible=False) as baseline_results:
baseline_status = gr.Markdown("")
set_baseline_btn = gr.Button(
"축원 마치기",
variant="primary",
visible=False
)
# 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.TabItem("기원") as tab_wish:
gr.Markdown("## 기원 - 소원을 전해보세요")
with gr.Row():
with gr.Column():
voice_input = gr.Audio(
label="소원을 나누고 싶은 마음을 말해주세요",
sources=["microphone"],
type="numpy",
streaming=False
)
voice_analysis_status = gr.Markdown(
"",
visible=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("## 송신 - 소지(소원지)를 남겨주세요")
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=wish_archive.get_display_data(),
interactive=False,
wrap=True
)
# 이벤트 핸들러들
def handle_baseline_recording(audio, state):
"""기준 음성 분석"""
if audio is None:
return {
"state": state,
"analysis": gr.update(visible=True, value="음성을 먼저 녹음해주세요."),
"results": gr.update(visible=False),
"status": "",
"button": gr.update(visible=False)
}
try:
# 분석 중임을 표시
analysis_update = gr.update(visible=True, value="음성을 분석하고 있습니다...")
yield {
"state": state,
"analysis": analysis_update,
"results": gr.update(visible=False),
"status": "",
"button": gr.update(visible=False)
}
features = calculate_baseline_features(audio)
if features is None:
return {
"state": state,
"analysis": gr.update(visible=True, value="음성 분석에 실패했습니다. 다시 시도해주세요."),
"results": gr.update(visible=False),
"status": "",
"button": gr.update(visible=False)
}
# 분석 성공 시
new_state = {**state, "baseline_features": features, "analysis_complete": True}
return {
"state": new_state,
"analysis": gr.update(visible=False),
"results": gr.update(visible=True),
"status": "축원 문장이 정상적으로 인식되었습니다.",
"button": gr.update(visible=True)
}
except Exception as e:
return {
"state": state,
"analysis": gr.update(visible=True, value=f"오류가 발생했습니다: {str(e)}"),
"results": gr.update(visible=False),
"status": "",
"button": gr.update(visible=False)
}
def handle_blessing_complete(state):
"""축원 완료 처리"""
if not state.get("analysis_complete"):
return state, "음성 분석이 완료되지 않았습니다.", gr.update(visible=True), gr.update(visible=False)
return state, "축원이 완료되었습니다.", gr.update(visible=False), gr.update(visible=True)
# 이벤트 연결
baseline_audio.change(
fn=handle_baseline_recording,
inputs=[baseline_audio, state],
outputs=[
state,
analysis_status,
baseline_results,
baseline_status,
set_baseline_btn
]
)
set_baseline_btn.click(
fn=handle_blessing_complete,
inputs=[state],
outputs=[state, baseline_status, blessing_section, entry_guide_section]
)
return app
def create_pwa_files():
"""PWA 필요 파일들 생성"""
# manifest.json 생성
manifest_path = 'static/manifest.json'
if not os.path.exists(manifest_path):
manifest_data = {
"name": "디지털 굿판",
"short_name": "디지털 굿판",
"description": "현대 도시 속 디지털 의례 공간",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"orientation": "portrait",
"icons": [
{
"src": "/static/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(manifest_data, f, ensure_ascii=False, indent=2)
# service-worker.js 생성
sw_path = 'static/service-worker.js'
if not os.path.exists(sw_path):
with open(sw_path, 'w', encoding='utf-8') as f:
f.write('''
// 캐시 이름 설정
const CACHE_NAME = 'digital-gutpan-v1';
// 캐시할 파일 목록
const urlsToCache = [
'/',
'/static/icons/icon-72x72.png',
'/static/icons/icon-96x96.png',
'/static/icons/icon-128x128.png',
'/static/icons/icon-144x144.png',
'/static/icons/icon-152x152.png',
'/static/icons/icon-192x192.png',
'/static/icons/icon-384x384.png',
'/static/icons/icon-512x512.png',
'/assets/main_music.mp3'
];
// 서비스 워커 설치 시
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
.then(() => self.skipWaiting())
);
});
// 서비스 워커 활성화 시
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim())
);
});
// 네트워크 요청 처리
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
'''.strip())
# index.html 생성
index_path = 'templates/index.html'
if not os.path.exists(index_path):
with open(index_path, 'w', encoding='utf-8') as f:
f.write('''<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>디지털 굿판</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#000000">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="디지털 굿판">
<link rel="apple-touch-icon" href="/static/icons/icon-152x152.png">
<script>
// 화면 꺼짐 방지
async function preventSleep() {
try {
if ('wakeLock' in navigator) {
const wakeLock = await navigator.wakeLock.request('screen');
console.log('화면 켜짐 유지 활성화');
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
await preventSleep();
}
});
}
} catch (err) {
console.log('화면 켜짐 유지 실패:', err);
}
}
// 서비스 워커 등록
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js');
console.log('ServiceWorker 등록 성공:', registration.scope);
await preventSleep();
} catch (err) {
console.log('ServiceWorker 등록 실패:', err);
}
});
}
</script>
</head>
<body>
<div id="gradio-app"></div>
</body>
</html>''')
if __name__ == "__main__":
# 필요한 디렉토리 생성
for directory in ['static/icons', 'assets', 'templates', 'data', 'generated_images']:
os.makedirs(directory, exist_ok=True)
# PWA 파일 생성
create_pwa_files()
# Gradio 앱 생성
demo = create_interface()
# Flask 앱에 Gradio 마운트
app = gr.mount_gradio_app(app, demo, path="/")
# Flask 서버 실행
app.run(
host="0.0.0.0",
port=7860,
debug=True
)