haepada commited on
Commit
1914fcd
·
verified ·
1 Parent(s): fdaafe5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +826 -555
app.py CHANGED
@@ -3,94 +3,55 @@ import json
3
  import numpy as np
4
  import librosa
5
  from datetime import datetime
 
6
  import gradio as gr
7
  from transformers import pipeline
8
  import requests
9
- import base64
10
  from dotenv import load_dotenv
11
 
12
  # 환경변수 로드
13
  load_dotenv()
14
 
15
- # 필요한 디렉토리 생성
16
- REQUIRED_DIRS = ['static', 'assets', 'data', 'generated_images', 'templates']
17
- for directory in REQUIRED_DIRS:
18
- os.makedirs(directory, exist_ok=True)
19
-
20
- # 필요한 파일 경로 정의
21
- REQUIRED_FILES = {
22
- 'static/main-image.png': '메인 이미지',
23
- 'static/DIGITAL_GUTPAN_LOGO_m.png': '모바일 로고',
24
- 'static/DIGITAL_GUTPAN_LOGO_w.png': '데스크톱 로고',
25
- 'assets/main_music.mp3': '배경 음악'
26
- }
27
-
28
- # 파일 체크 함수
29
- def check_required_files():
30
- missing_files = []
31
- for file_path, description in REQUIRED_FILES.items():
32
- if not os.path.exists(file_path):
33
- missing_files.append(f"{description} ({file_path})")
34
- return missing_files
35
-
36
- # AI 모델 초기화
37
- try:
38
- speech_recognizer = pipeline(
39
- "automatic-speech-recognition",
40
- model="kresnik/wav2vec2-large-xlsr-korean"
41
- )
42
- text_analyzer = pipeline(
43
- "sentiment-analysis",
44
- model="nlptown/bert-base-multilingual-uncased-sentiment"
45
- )
46
- print("✅ AI 모델 초기화 성공")
47
- except Exception as e:
48
- print(f"❌ AI 모델 초기화 실패: {e}")
49
- speech_recognizer = None
50
- text_analyzer = None
51
-
52
- # API 설정
53
- HF_API_TOKEN = os.getenv("roots", "")
54
- if not HF_API_TOKEN:
55
- print("Warning: HuggingFace API token not found. Some features may be limited.")
56
-
57
- API_URL = "https://api-inference.huggingface.co/models/stabilityai/stable-diffusion-xl-base-1.0"
58
- headers = {"Authorization": f"Bearer {HF_API_TOKEN}"} if HF_API_TOKEN else {}
59
-
60
  # 상수 정의
61
  WELCOME_MESSAGE = """
62
  # 디지털 굿판에 오신 것을 환영합니다
63
 
64
  이곳은 현대 도시 속에서 잊혀진 전통 굿의 정수를 담아낸 디지털 의례의 공간입니다.
65
  이곳에서는 사람들의 목소리와 감정을 통해 영적 교감을 나누고, 자연과 도시의 에너지가 연결됩니다.
 
66
  """
67
 
68
  ONCHEON_STORY = """
69
- ## 생명의 공간 '온천천' 🌌
70
-
71
- 온천천의 물줄기는 신성한 금샘에서 시작됩니다. 금샘은 생명과 창조의 원천이며,
72
- 천상의 생명이 지상에서 숨을 틔우는 자리입니다.
73
 
74
- 도시의 소음 속에서도 신성한 생명력을 느껴보세요. 이곳에서 영적인 교감을 경험하며,
75
- 자연과 하나 되는 순간을 맞이해 보시기 바랍니다.
76
 
77
- 프로젝트는 부산광역시 동래구 '온천장역'에서 금정구 '장전역'을 잇는 구간의
78
- 온천천 산책로의 사운드스케이프를 기반으로 제작되었습니다.
 
79
 
80
- 산책로를 따라 걸으며 본 프로젝트 체험 시 보다 몰입된 경험이 가능합니다.
81
  """
82
 
83
- # 초기 상태 정의
84
- INITIAL_STATE = {
85
- "user_name": "",
86
- "baseline_features": None,
87
- "reflections": [],
88
- "wish": None,
89
- "final_prompt": "",
90
- "image_path": None,
91
- "current_tab": 0,
92
- "audio_playing": False
93
- }
 
 
 
 
 
 
 
 
94
 
95
  class SimpleDB:
96
  def __init__(self, reflections_path="data/reflections.json", wishes_path="data/wishes.json"):
@@ -114,12 +75,14 @@ class SimpleDB:
114
  def save_reflection(self, name, reflection, sentiment, timestamp=None):
115
  if timestamp is None:
116
  timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
 
117
  reflection_data = {
118
  "timestamp": timestamp,
119
  "name": name,
120
  "reflection": reflection,
121
  "sentiment": sentiment
122
  }
 
123
  self.reflections.append(reflection_data)
124
  self._save_json(self.reflections_path, self.reflections)
125
  return True
@@ -150,8 +113,35 @@ class SimpleDB:
150
  return sorted(self.reflections, key=lambda x: x["timestamp"], reverse=True)
151
 
152
  def get_all_wishes(self):
153
- return sorted(self.wishes, key=lambda x: x["timestamp"], reverse=True)
 
 
 
 
 
 
 
 
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  def calculate_baseline_features(audio_data):
156
  try:
157
  if isinstance(audio_data, tuple):
@@ -213,6 +203,7 @@ def map_acoustic_to_emotion(features, baseline_features=None):
213
  "characteristics": []
214
  }
215
 
 
216
  if energy_norm > 70:
217
  if tempo_norm > 0.6:
218
  emotions["primary"] = "기쁨/열정"
@@ -271,7 +262,6 @@ def analyze_voice(audio_data, state):
271
  return state, "음성 분석에 실패했습니다.", "", "", ""
272
 
273
  # 음성 인식
274
- text = "음성 인식 모델을 불러올 수 없습니다."
275
  if speech_recognizer:
276
  try:
277
  transcription = speech_recognizer({"sampling_rate": sr, "raw": y.astype(np.float32)})
@@ -279,20 +269,24 @@ def analyze_voice(audio_data, state):
279
  except Exception as e:
280
  print(f"Speech recognition error: {e}")
281
  text = "음성 인식 실패"
 
 
282
 
283
  # 음성 감정 분석
284
  voice_emotion = map_acoustic_to_emotion(acoustic_features, state.get("baseline_features"))
285
 
286
  # 텍스트 감정 분석
287
- text_sentiment = {"label": "unknown", "score": 0.0}
288
- text_result = "텍스트 감정 분석을 수행할 수 없습니다."
289
  if text_analyzer and text:
290
  try:
291
  text_sentiment = text_analyzer(text)[0]
292
  text_result = f"텍스트 감정 분석: {text_sentiment['label']} (점수: {text_sentiment['score']:.2f})"
293
  except Exception as e:
294
  print(f"Text analysis error: {e}")
 
295
  text_result = "텍스트 감정 분석 실패"
 
 
 
296
 
297
  voice_result = (
298
  f"음성 감정: {voice_emotion['primary']} "
@@ -315,6 +309,7 @@ def analyze_voice(audio_data, state):
315
  print(f"Error in analyze_voice: {str(e)}")
316
  return state, f"오류 발생: {str(e)}", "", "", ""
317
 
 
318
  def generate_detailed_prompt(text, emotions, text_sentiment):
319
  emotion_colors = {
320
  "기쁨/열정": "밝은 노랑과 따뜻한 주황색",
@@ -338,15 +333,13 @@ def generate_detailed_prompt(text, emotions, text_sentiment):
338
  "차분/진지": "균형잡힌 수직선과 안정적인 구조"
339
  }
340
 
341
- prompt = (
342
- f"minimalistic abstract art, {emotion_colors.get(emotions['primary'], '자연스러운 색상')} color scheme, "
343
- f"{abstract_elements.get(emotions['primary'], '유기적 형태')}, "
344
- "korean traditional patterns, ethereal atmosphere, sacred geometry, "
345
- "flowing energy, mystical aura, no human figures, no faces, "
346
- "digital art, high detail, luminescent effects. "
347
- "negative prompt: photorealistic, human, face, figurative, text, letters, "
348
- "--ar 2:3 --s 750 --q 2"
349
- )
350
 
351
  return prompt
352
 
@@ -385,464 +378,501 @@ def generate_image_from_prompt(prompt):
385
  except Exception as e:
386
  print(f"Error generating image: {str(e)}")
387
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
- def encode_image_to_base64(image_path):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  try:
391
- if not os.path.exists(image_path):
392
- print(f"Warning: Image file not found at {image_path}")
393
- return ""
394
- with open(image_path, "rb") as img_file:
395
- return base64.b64encode(img_file.read()).decode()
 
 
 
396
  except Exception as e:
397
- print(f"Error encoding image {image_path}: {e}")
398
- return ""
399
-
400
- def create_html_templates(mobile_logo, desktop_logo, main_image):
401
- logo_html = f"""
402
- <div class="logo-container">
403
- <img class="mobile-logo" src="data:image/png;base64,{mobile_logo}" alt="디지털 굿판 로고 모바일">
404
- <img class="desktop-logo" src="data:image/png;base64,{desktop_logo}" alt="디지털 굿판 로고 데스크톱">
405
- </div>
406
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
 
408
  main_image_html = f"""
409
  <div class="main-image-container">
410
  <img src="data:image/png;base64,{main_image}" alt="디지털 굿판 메인 이미지" class="main-image">
411
  </div>
412
  """
413
-
414
- audio_player_html = """
 
415
  <div class="audio-player-container">
416
- <audio controls class="audio-player">
417
- <source src="/assets/main_music.mp3" type="audio/mpeg">
418
- 브라우저가 오디오 태그를 지원하지 않습니다.
419
  </audio>
 
 
 
420
  </div>
421
- """
422
-
423
- return logo_html, main_image_html, audio_player_html
424
-
425
- def create_pwa_files():
426
- """필요한 PWA 파일들을 생성하는 함수"""
427
- # manifest.json 생성
428
- manifest_path = 'static/manifest.json'
429
- manifest_data = {
430
- "name": "디지털 굿판",
431
- "short_name": "디지털 굿판",
432
- "description": "현대 도시 디지털 의례 공간",
433
- "start_url": "/",
434
- "display": "standalone",
435
- "background_color": "#ffffff",
436
- "theme_color": "#000000",
437
- "orientation": "portrait",
438
- "icons": [
439
- {
440
- "src": "/static/icons/icon-72x72.png",
441
- "sizes": "72x72",
442
- "type": "image/png",
443
- "purpose": "any maskable"
444
- },
445
- {
446
- "src": "/static/icons/icon-96x96.png",
447
- "sizes": "96x96",
448
- "type": "image/png",
449
- "purpose": "any maskable"
450
- },
451
- {
452
- "src": "/static/icons/icon-128x128.png",
453
- "sizes": "128x128",
454
- "type": "image/png",
455
- "purpose": "any maskable"
456
- },
457
- {
458
- "src": "/static/icons/icon-144x144.png",
459
- "sizes": "144x144",
460
- "type": "image/png",
461
- "purpose": "any maskable"
462
- },
463
- {
464
- "src": "/static/icons/icon-192x192.png",
465
- "sizes": "192x192",
466
- "type": "image/png",
467
- "purpose": "any maskable"
468
- },
469
- {
470
- "src": "/static/icons/icon-512x512.png",
471
- "sizes": "512x512",
472
- "type": "image/png",
473
- "purpose": "any maskable"
474
  }
475
- ]
476
- }
477
- os.makedirs('static', exist_ok=True)
478
- with open(manifest_path, 'w', encoding='utf-8') as f:
479
- json.dump(manifest_data, f, ensure_ascii=False, indent=2)
480
-
481
- # service-worker.js 생성
482
- sw_path = 'static/service-worker.js'
483
- with open(sw_path, 'w', encoding='utf-8') as f:
484
- f.write('''
485
- const CACHE_NAME = 'digital-gutpan-v2';
486
- const urlsToCache = [
487
- '/',
488
- '/assets/main_music.mp3',
489
- '/static/icons/icon-72x72.png',
490
- '/static/icons/icon-96x96.png',
491
- '/static/icons/icon-128x128.png',
492
- '/static/icons/icon-144x144.png',
493
- '/static/icons/icon-192x192.png',
494
- '/static/icons/icon-512x512.png'
495
- ];
496
-
497
- self.addEventListener('install', event => {
498
- event.waitUntil(
499
- caches.open(CACHE_NAME)
500
- .then(cache => cache.addAll(urlsToCache))
501
- .then(() => self.skipWaiting())
502
- );
503
- });
504
-
505
- self.addEventListener('activate', event => {
506
- event.waitUntil(
507
- caches.keys().then(cacheNames => {
508
- return Promise.all(
509
- cacheNames.map(cacheName => {
510
- if (cacheName !== CACHE_NAME) {
511
- return caches.delete(cacheName);
512
- }
513
- })
514
- );
515
- }).then(() => self.clients.claim())
516
- );
517
  });
518
-
519
- self.addEventListener('fetch', event => {
520
- event.respondWith(
521
- caches.match(event.request)
522
- .then(response => response || fetch(event.request))
523
- );
524
  });
525
- '''.strip())
526
-
527
-
528
- # CSS 스타일 정의
529
- STYLE_CSS = """
530
- /* 기본 컨테이너 스타일링 */
531
- .gradio-container {
532
- margin: 0 auto !important;
533
- max-width: 800px !important;
534
- padding: 1rem !important;
535
- }
536
-
537
- /* 이미지 컨테이너 스타일링 */
538
- .main-image-container {
539
- width: 100%;
540
- margin: 0 auto 2rem auto;
541
- text-align: center;
542
- }
543
-
544
- .main-image {
545
- width: 100%;
546
- height: auto;
547
- max-width: 100%;
548
- display: block;
549
- margin: 0 auto;
550
- }
551
-
552
- /* 로고 스타일링 */
553
- .logo-container {
554
- padding: 20px 0;
555
- width: 100%;
556
- max-width: 800px;
557
- margin: 0 auto;
558
- text-align: center;
559
- }
560
-
561
- /* 오디오 플레이어 스타일링 */
562
- .audio-player-container {
563
- width: 100%;
564
- max-width: 800px;
565
- margin: 20px auto;
566
- text-align: center;
567
- }
568
-
569
- .audio-player {
570
- width: 100%;
571
- max-width: 300px;
572
- margin: 0 auto;
573
- border-radius: 8px;
574
- }
575
-
576
- /* 상태 메시지 박스 스타일링 */
577
- .blessing-status-box {
578
- min-height: 200px !important;
579
- max-height: 300px !important;
580
- overflow-y: auto !important;
581
- padding: 15px !important;
582
- border: 1px solid #ddd !important;
583
- border-radius: 8px !important;
584
- background-color: #f8f9fa !important;
585
- margin: 10px 0 !important;
586
- }
587
-
588
- .blessing-status-box p {
589
- margin: 0 0 10px 0 !important;
590
- line-height: 1.5 !important;
591
- }
592
-
593
- /* 버튼 스타일링 */
594
- .gradio-button {
595
- transition: all 0.3s ease;
596
- border-radius: 8px !important;
597
- width: 100% !important;
598
- margin: 5px 0 !important;
599
- min-height: 44px !important;
600
- }
601
-
602
- .gradio-button:active {
603
- transform: scale(0.98);
604
- }
605
-
606
- /* 데이터프레임 스타일링 */
607
- .gradio-dataframe {
608
- border: 1px solid #e0e0e0;
609
- border-radius: 8px;
610
- overflow: hidden;
611
- }
612
-
613
- /* 모바일 뷰 (600px 이하) */
614
- @media (max-width: 600px) {
615
- .mobile-logo {
616
- display: none !important;
617
- }
618
- .desktop-logo {
619
- display: block !important;
620
- width: 100%;
621
- height: auto;
622
- max-width: 800px;
623
- margin: 0 auto;
624
- }
625
- .main-image {
626
- width: 100%;
627
- max-width: 100%;
628
- }
629
- .container {
630
- padding: 10px !important;
631
- }
632
- .gradio-row {
633
- flex-direction: column !important;
634
- gap: 10px !important;
635
- }
636
- .audio-player {
637
- width: 100%;
638
- }
639
- }
640
-
641
- /* 데스크톱 뷰 (601px 이상) */
642
- @media (min-width: 601px) {
643
- .desktop-logo {
644
- display: none !important;
645
- }
646
- .mobile-logo {
647
- display: block !important;
648
- width: 100% !important;
649
- height: auto !important;
650
- max-width: 300px !important;
651
- margin: 0 auto !important;
652
- }
653
- .main-image {
654
- width: 600px;
655
- max-width: 600px;
656
- }
657
- .gradio-row {
658
- gap: 20px !important;
659
- }
660
- .gradio-row > .gradio-column {
661
- flex: 1 !important;
662
- min-width: 0 !important;
663
- }
664
- }
665
 
666
- /* 컴포넌트 간격 조정 */
667
- .gradio-column > *:not(:last-child) {
668
- margin-bottom: 1rem !important;
669
- }
670
- """
 
 
671
 
672
- def create_interface():
673
- # DB 초기화
674
- db = SimpleDB()
675
-
676
- # 이미지 인코딩
677
- mobile_logo = encode_image_to_base64("static/DIGITAL_GUTPAN_LOGO_m.png")
678
- desktop_logo = encode_image_to_base64("static/DIGITAL_GUTPAN_LOGO_w.png")
679
- main_image = encode_image_to_base64("static/main-image.png")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
 
681
- # HTML 템플릿 생성
682
- logo_html, main_image_html, audio_player_html = create_html_templates(
683
- mobile_logo, desktop_logo, main_image
684
- )
 
 
 
 
 
 
685
 
686
- # 이벤트 핸들러들
687
- def handle_name_submit(name, state):
688
- if not name.strip():
689
- return (
690
- gr.update(visible=True),
691
- gr.update(visible=False),
692
- gr.update(visible=False),
693
- gr.update(visible=False),
694
- state
695
- )
696
- state = safe_state_update(state, {"user_name": name})
697
- return (
698
- gr.update(visible=False),
699
- gr.update(visible=True),
700
- gr.update(visible=False),
701
- gr.update(visible=False),
702
- state
703
- )
704
 
705
- def handle_continue():
706
- return (
707
- gr.update(visible=False),
708
- gr.update(visible=False),
709
- gr.update(visible=True),
710
- gr.update(visible=False)
711
- )
712
-
713
- def handle_blessing_complete(audio, state):
714
- if audio is None:
715
- return state, "음성을 먼저 녹음해주세요.", gr.update(visible=True), gr.update(visible=False)
716
 
717
- try:
718
- sr, y = audio
719
- features = calculate_baseline_features((sr, y))
720
-
721
- if features:
722
- state = safe_state_update(state, {"baseline_features": features})
723
- return (
724
- state,
725
- "분석이 완료되었습니다. 다음 단계로 진행해주세요.",
726
- gr.update(visible=False),
727
- gr.update(visible=True)
728
- )
729
-
730
- return (
731
- state,
732
- "분석에 실패했습니다. 다시 시도해주세요.",
733
- gr.update(visible=True),
734
- gr.update(visible=False)
735
- )
736
-
737
- except Exception as e:
738
- return state, f"오류 발생: {str(e)}", gr.update(visible=True), gr.update(visible=False)
739
-
740
- def handle_enter():
741
- return gr.update(selected=1)
742
-
743
- def handle_save_reflection(text, state):
744
- if not text.strip():
745
- return state, []
746
 
747
- try:
748
- current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
749
- name = state.get("user_name", "익명")
750
-
751
- if text_analyzer:
752
- sentiment = text_analyzer(text)[0]
753
- sentiment_text = f"{sentiment['label']} ({sentiment['score']:.2f})"
754
- else:
755
- sentiment_text = "분석 불가"
756
-
757
- db.save_reflection(name, text, sentiment_text)
758
- return state, [[current_time, text, sentiment_text]]
759
-
760
- except Exception as e:
761
- print(f"Error saving reflection: {e}")
762
- return state, []
763
-
764
- def handle_save_wish(text, state):
765
- if not text.strip():
766
- return "소원을 입력해주세요.", []
767
 
768
- try:
769
- name = state.get("user_name", "익명")
770
- db.save_wish(name, text)
771
- wishes = db.get_all_wishes()
772
- wish_display_data = [
773
- [wish["timestamp"], wish["wish"], wish["name"]]
774
- for wish in wishes
775
- ]
776
- return "소원이 저장되었습니다.", wish_display_data
777
- except Exception as e:
778
- print(f"Error saving wish: {e}")
779
- return "오류가 발생했습니다.", []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
780
 
781
- def safe_analyze_voice(audio_data, state):
782
- if audio_data is None:
783
- return (
784
- state,
785
- "음성을 먼저 녹음해주세요.",
786
- "",
787
- "",
788
- "",
789
- "분석 준비 중..."
790
- )
791
-
792
- try:
793
- status_msg = "음성 분석 중..."
794
- sr, y = audio_data
795
-
796
- if len(y) == 0:
797
- return (
798
- state,
799
- "음성이 감지되지 않았습니다.",
800
- "",
801
- "",
802
- "",
803
- "분석 실패"
804
- )
805
-
806
- acoustic_features = calculate_baseline_features((sr, y))
807
- new_state, text, voice_result, text_result, prompt = analyze_voice(audio_data, state)
808
-
809
- return (
810
- new_state,
811
- text,
812
- voice_result,
813
- text_result,
814
- prompt,
815
- "분석 완료"
816
- )
817
-
818
- except Exception as e:
819
- print(f"Voice analysis error: {str(e)}")
820
- return (
821
- state,
822
- "음성 분석 중 오류가 발생했습니다. 다시 시도해주세요.",
823
- "",
824
- "",
825
- "",
826
- "분석 실패"
827
- )
828
 
829
- # Gradio 인터페이스 구성
830
- with gr.Blocks(theme=gr.themes.Soft(), css=STYLE_CSS) as app:
831
- # 상태 관리
832
- state = gr.State(value=INITIAL_STATE)
833
  processing_status = gr.State("")
834
-
835
- # 로고 영역
836
  with gr.Column(elem_classes="logo-container"):
837
- gr.HTML(logo_html)
 
 
 
838
 
839
- # 탭 구성
840
  with gr.Tabs(selected=0) as tabs:
841
- # 입장 탭
842
  with gr.TabItem("입장") as tab_entrance:
843
- # 메인 이미지 표시
844
  gr.HTML(main_image_html)
845
-
846
  # 1단계: 첫 화면
847
  welcome_section = gr.Column(visible=True)
848
  with welcome_section:
@@ -866,6 +896,7 @@ def create_interface():
866
  gr.Markdown("### 축원의식을 시작하겠습니다")
867
  gr.Markdown("'명짐 복짐 짊어지고 안가태평하시기를 비도발원 축원 드립니다'")
868
 
 
869
  baseline_audio = gr.Audio(
870
  label="축원 문장 녹음하기",
871
  sources=["microphone"],
@@ -873,15 +904,18 @@ def create_interface():
873
  streaming=False
874
  )
875
 
 
876
  blessing_status = gr.Markdown(
877
  "분석 결과가 여기에 표시됩니다.",
878
  elem_id="blessing-status",
879
  elem_classes="blessing-status-box"
880
  )
881
 
 
882
  set_baseline_btn = gr.Button("축원문 분석하기", variant="primary")
883
  send_blessing_btn = gr.Button("축원 보내기", variant="secondary", visible=False)
884
 
 
885
  # 4단계: 굿판 입장 안내
886
  entry_guide_section = gr.Column(visible=False)
887
  with entry_guide_section:
@@ -893,7 +927,6 @@ def create_interface():
893
  """)
894
  enter_btn = gr.Button("청신 의식 시작하기", variant="primary")
895
 
896
- # 청신 탭
897
  with gr.TabItem("청신") as tab_listen:
898
  gr.Markdown("## 청신 - 소리로 정화하기")
899
  gr.Markdown("""
@@ -903,8 +936,16 @@ def create_interface():
903
  온천천 온천장역에서 장전역까지 걸으며 더 깊은 체험이 가능합니다.
904
  """)
905
 
906
- gr.Markdown("## 온천천의 소리를 들어보세요")
907
- gr.HTML(audio_player_html)
 
 
 
 
 
 
 
 
908
 
909
  with gr.Column():
910
  reflection_input = gr.Textbox(
@@ -925,7 +966,7 @@ def create_interface():
925
  # 기원 탭
926
  with gr.TabItem("기원") as tab_wish:
927
  gr.Markdown("## 기원 - 소원을 전해보세요")
928
- status_display = gr.Markdown("", visible=False)
929
 
930
  with gr.Row():
931
  with gr.Column():
@@ -934,7 +975,7 @@ def create_interface():
934
  sources=["microphone"],
935
  type="numpy",
936
  streaming=False,
937
- elem_id="voice-input"
938
  )
939
  with gr.Row():
940
  clear_btn = gr.Button("녹음 지우기", variant="secondary")
@@ -967,7 +1008,7 @@ def create_interface():
967
  label="생성된 이미지",
968
  show_download_button=True
969
  )
970
-
971
  gr.Markdown("## 온천천에 전하고 싶은 소원을 남겨주세요")
972
  final_reflection = gr.Textbox(
973
  label="소원",
@@ -976,9 +1017,9 @@ def create_interface():
976
  )
977
  save_final_btn = gr.Button("소원 전하기", variant="primary")
978
  gr.Markdown("""
979
- 💫 여러분의 소원은 11월 25일 온천천 벽면에 설치될 소원나무에 전시될 예정입니다.
980
- 따뜻한 마음을 담아 작성해주세요.
981
- """)
982
  wishes_display = gr.Dataframe(
983
  headers=["시간", "소원", "이름"],
984
  label="기록된 소원들",
@@ -986,13 +1027,11 @@ def create_interface():
986
  interactive=False,
987
  wrap=True
988
  )
989
-
990
- # 프로젝트 소개 탭
991
  with gr.TabItem("프로젝트 소개") as tab_intro:
992
  gr.HTML(main_image_html)
993
  gr.Markdown("""
994
  # 디지털 굿판 프로젝트
995
- '디지털 굿판'은 부산문화재단의 지원으로 루츠리딤이 제작한 다원예술 프로젝트입니다.
996
 
997
  본 사업은 전통 굿의 요소와 현대 기술을 결합해 참여자들이 자연과 깊이 연결되며 내면을 탐색하고 치유하는 경험을 제공합니다.
998
 
@@ -1002,15 +1041,204 @@ def create_interface():
1002
 
1003
  이 프로젝트는 현대 사회의 삶에 대한 근본적인 질문을 던지며 새로운 문화적 지향점을 모색합니다.
1004
 
1005
- 본 프로젝트에서 기록된 정보 중 '송신' 단계의 '소원전하기'를 제외한 모든 과정의 정보는(목소리,감상 등) 별도로 저장되지 않으며 AI 학습이나 외부 공유에 사용되지 않습니다.
1006
 
1007
  ## 크레딧
1008
  - **기획**: 루츠리딤, 전승아
1009
  - **음악**: 루츠리딤 (이광혁)
1010
  - **미디어아트**: 송지훈
1011
- """)
1012
 
1013
- # 이벤트 연결
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1014
  name_submit_btn.click(
1015
  fn=handle_name_submit,
1016
  inputs=[name_input, state],
@@ -1025,7 +1253,7 @@ def create_interface():
1025
  set_baseline_btn.click(
1026
  fn=handle_blessing_complete,
1027
  inputs=[baseline_audio, state],
1028
- outputs=[state, blessing_status, blessing_section, entry_guide_section]
1029
  )
1030
 
1031
  enter_btn.click(
@@ -1034,7 +1262,7 @@ def create_interface():
1034
  )
1035
 
1036
  save_btn.click(
1037
- fn=handle_save_reflection,
1038
  inputs=[reflection_input, state],
1039
  outputs=[state, reflections_display]
1040
  )
@@ -1059,42 +1287,86 @@ def create_interface():
1059
  save_final_btn.click(
1060
  fn=handle_save_wish,
1061
  inputs=[final_reflection, state],
1062
- outputs=[blessing_status, wishes_display]
1063
  )
1064
-
1065
  return app
1066
 
1067
  if __name__ == "__main__":
1068
- # 필요한 디렉토리 생성
1069
- for directory in REQUIRED_DIRS:
1070
- os.makedirs(directory, exist_ok=True)
1071
-
1072
- # 파일 경로 체크
1073
- print("\n🔍 파일 경로 확인 중...")
1074
- missing_files = []
1075
- for file_path, description in REQUIRED_FILES.items(): # REQUIRED_PATHS 대신 REQUIRED_FILES 사용
1076
- if not os.path.exists(file_path):
1077
- missing_files.append(f"{description} ({file_path})")
1078
-
1079
- if missing_files:
1080
- print("\n❌ 누락된 파일들:")
1081
- for file in missing_files:
1082
- print(f"- {file}")
1083
- else:
1084
- print("\n✅ 모든 필요 파일이 존재합니다.")
1085
-
1086
- # PWA 설정 파일 생성
1087
- create_pwa_files()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1088
 
1089
- print("\n🎵 음악 파일 접근성 확인...")
1090
- if os.path.exists("assets/main_music.mp3"):
1091
- print("✅ 음악 파일 확인됨")
1092
- print(f"📂 절대 경로: {os.path.abspath('assets/main_music.mp3')}")
1093
- else:
1094
- print("❌ 음악 파일이 없습니다")
1095
 
1096
- print("\n🚀 서버 시작...")
1097
-
1098
  # Gradio 앱 실행
1099
  demo = create_interface()
1100
  demo.queue().launch(
@@ -1105,5 +1377,4 @@ if __name__ == "__main__":
1105
  show_error=True,
1106
  height=None,
1107
  width="100%"
1108
- )
1109
-
 
3
  import numpy as np
4
  import librosa
5
  from datetime import datetime
6
+ from flask import Flask, send_from_directory, render_template
7
  import gradio as gr
8
  from transformers import pipeline
9
  import requests
 
10
  from dotenv import load_dotenv
11
 
12
  # 환경변수 로드
13
  load_dotenv()
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  # 상수 정의
16
  WELCOME_MESSAGE = """
17
  # 디지털 굿판에 오신 것을 환영합니다
18
 
19
  이곳은 현대 도시 속에서 잊혀진 전통 굿의 정수를 담아낸 디지털 의례의 공간입니다.
20
  이곳에서는 사람들의 목소리와 감정을 통해 영적 교감을 나누고, 자연과 도시의 에너지가 연결됩니다.
21
+
22
  """
23
 
24
  ONCHEON_STORY = """
25
+ ## 생명의 공간 ‘온천천’ 🌌
 
 
 
26
 
27
+ 온천천의 물줄기는 신성한 금샘에서 시작됩니다. 금샘은 생명과 창조의 원천이며, 천상의 생명이 지상에서 숨을 틔우는 자리입니다.
 
28
 
29
+ 도시의 소음 속에서도 신성한 생명력을 느껴보세요. 이곳에서 영적인 교감을 경험하며, 자연과 하나 되는 순간을 맞이해 보시기 바랍니다
30
+
31
+ 이 프로젝트는 부산광역시 동래구 ‘온천장역’ 에서 금정구 ‘장전역’을 잇는 구간의 온천천 산책로의 사운드 스케이프를 기반으로 제작 되었습니다.
32
 
33
+ 산책로를 따라 걸으며 본 프로젝트 체험 시 보다 몰입된 경험이 가능합니다.
34
  """
35
 
36
+ # Flask 초기화
37
+ app = Flask(__name__)
38
+
39
+ # 환경변수 로드
40
+ load_dotenv()
41
+
42
+ # Flask 라우트
43
+ @app.route('/static/<path:path>')
44
+ def serve_static(path):
45
+ return send_from_directory('static', path)
46
+
47
+ @app.route('/assets/<path:path>')
48
+ def serve_assets(path):
49
+ return send_from_directory('assets', path)
50
+
51
+ @app.route('/wishes/<path:path>')
52
+ def serve_wishes(path):
53
+ return send_from_directory('data/wishes', path)
54
+
55
 
56
  class SimpleDB:
57
  def __init__(self, reflections_path="data/reflections.json", wishes_path="data/wishes.json"):
 
75
  def save_reflection(self, name, reflection, sentiment, timestamp=None):
76
  if timestamp is None:
77
  timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
78
+
79
  reflection_data = {
80
  "timestamp": timestamp,
81
  "name": name,
82
  "reflection": reflection,
83
  "sentiment": sentiment
84
  }
85
+
86
  self.reflections.append(reflection_data)
87
  self._save_json(self.reflections_path, self.reflections)
88
  return True
 
113
  return sorted(self.reflections, key=lambda x: x["timestamp"], reverse=True)
114
 
115
  def get_all_wishes(self):
116
+ return self.wishes
117
+
118
+ # API 설정
119
+ HF_API_TOKEN = os.getenv("roots", "")
120
+ if not HF_API_TOKEN:
121
+ print("Warning: HuggingFace API token not found. Some features may be limited.")
122
+
123
+ API_URL = "https://api-inference.huggingface.co/models/stabilityai/stable-diffusion-xl-base-1.0"
124
+ headers = {"Authorization": f"Bearer {HF_API_TOKEN}"} if HF_API_TOKEN else {}
125
 
126
+ # AI 모델 초기화
127
+ try:
128
+ speech_recognizer = pipeline(
129
+ "automatic-speech-recognition",
130
+ model="kresnik/wav2vec2-large-xlsr-korean"
131
+ )
132
+ text_analyzer = pipeline(
133
+ "sentiment-analysis",
134
+ model="nlptown/bert-base-multilingual-uncased-sentiment"
135
+ )
136
+ except Exception as e:
137
+ print(f"Error initializing AI models: {e}")
138
+ speech_recognizer = None
139
+ text_analyzer = None
140
+
141
+ # 필요한 디렉토리 생성
142
+ os.makedirs("generated_images", exist_ok=True)
143
+
144
+ # 음성 분석 관련 함수들
145
  def calculate_baseline_features(audio_data):
146
  try:
147
  if isinstance(audio_data, tuple):
 
203
  "characteristics": []
204
  }
205
 
206
+ # 감정 매핑 로직
207
  if energy_norm > 70:
208
  if tempo_norm > 0.6:
209
  emotions["primary"] = "기쁨/열정"
 
262
  return state, "음성 분석에 실패했습니다.", "", "", ""
263
 
264
  # 음성 인식
 
265
  if speech_recognizer:
266
  try:
267
  transcription = speech_recognizer({"sampling_rate": sr, "raw": y.astype(np.float32)})
 
269
  except Exception as e:
270
  print(f"Speech recognition error: {e}")
271
  text = "음성 인식 실패"
272
+ else:
273
+ text = "음성 인식 모델을 불러올 수 없습니다."
274
 
275
  # 음성 감정 분석
276
  voice_emotion = map_acoustic_to_emotion(acoustic_features, state.get("baseline_features"))
277
 
278
  # 텍스트 감정 분석
 
 
279
  if text_analyzer and text:
280
  try:
281
  text_sentiment = text_analyzer(text)[0]
282
  text_result = f"텍스트 감정 분석: {text_sentiment['label']} (점수: {text_sentiment['score']:.2f})"
283
  except Exception as e:
284
  print(f"Text analysis error: {e}")
285
+ text_sentiment = {"label": "unknown", "score": 0.0}
286
  text_result = "텍스트 감정 분석 실패"
287
+ else:
288
+ text_sentiment = {"label": "unknown", "score": 0.0}
289
+ text_result = "텍스트 감정 분석을 수행할 수 없습니다."
290
 
291
  voice_result = (
292
  f"음성 감정: {voice_emotion['primary']} "
 
309
  print(f"Error in analyze_voice: {str(e)}")
310
  return state, f"오류 발생: {str(e)}", "", "", ""
311
 
312
+
313
  def generate_detailed_prompt(text, emotions, text_sentiment):
314
  emotion_colors = {
315
  "기쁨/열정": "밝은 노랑과 따뜻한 주황색",
 
333
  "차분/진지": "균형잡힌 수직선과 안정적인 구조"
334
  }
335
 
336
+ prompt = f"minimalistic abstract art, {emotion_colors.get(emotions['primary'], '자연스러운 색상')} color scheme, "
337
+ prompt += f"{abstract_elements.get(emotions['primary'], '유기적 형태')}, "
338
+ prompt += "korean traditional patterns, ethereal atmosphere, sacred geometry, "
339
+ prompt += "flowing energy, mystical aura, no human figures, no faces, "
340
+ prompt += "digital art, high detail, luminescent effects. "
341
+ prompt += "negative prompt: photorealistic, human, face, figurative, text, letters, "
342
+ prompt += "--ar 2:3 --s 750 --q 2"
 
 
343
 
344
  return prompt
345
 
 
378
  except Exception as e:
379
  print(f"Error generating image: {str(e)}")
380
  return None
381
+
382
+ def create_pwa_files():
383
+ """PWA 필요 파일들 생성"""
384
+ # manifest.json 생성
385
+ manifest_path = 'static/manifest.json'
386
+ if not os.path.exists(manifest_path):
387
+ manifest_data = {
388
+ "name": "디지털 굿판",
389
+ "short_name": "디지털 굿판",
390
+ "description": "현대 도시 속 디지털 의례 공간",
391
+ "start_url": "/",
392
+ "display": "standalone",
393
+ "background_color": "#ffffff",
394
+ "theme_color": "#000000",
395
+ "orientation": "portrait",
396
+ "icons": [
397
+ {
398
+ "src": "/static/icons/icon-72x72.png",
399
+ "sizes": "72x72",
400
+ "type": "image/png",
401
+ "purpose": "any maskable"
402
+ },
403
+ {
404
+ "src": "/static/icons/icon-96x96.png",
405
+ "sizes": "96x96",
406
+ "type": "image/png",
407
+ "purpose": "any maskable"
408
+ },
409
+ {
410
+ "src": "/static/icons/icon-128x128.png",
411
+ "sizes": "128x128",
412
+ "type": "image/png",
413
+ "purpose": "any maskable"
414
+ },
415
+ {
416
+ "src": "/static/icons/icon-144x144.png",
417
+ "sizes": "144x144",
418
+ "type": "image/png",
419
+ "purpose": "any maskable"
420
+ },
421
+ {
422
+ "src": "/static/icons/icon-152x152.png",
423
+ "sizes": "152x152",
424
+ "type": "image/png",
425
+ "purpose": "any maskable"
426
+ },
427
+ {
428
+ "src": "/static/icons/icon-192x192.png",
429
+ "sizes": "192x192",
430
+ "type": "image/png",
431
+ "purpose": "any maskable"
432
+ },
433
+ {
434
+ "src": "/static/icons/icon-384x384.png",
435
+ "sizes": "384x384",
436
+ "type": "image/png",
437
+ "purpose": "any maskable"
438
+ },
439
+ {
440
+ "src": "/static/icons/icon-512x512.png",
441
+ "sizes": "512x512",
442
+ "type": "image/png",
443
+ "purpose": "any maskable"
444
+ }
445
+ ]
446
+ }
447
+ with open(manifest_path, 'w', encoding='utf-8') as f:
448
+ json.dump(manifest_data, f, ensure_ascii=False, indent=2)
449
 
450
+ # service-worker.js 생성
451
+ sw_path = 'static/service-worker.js'
452
+ if not os.path.exists(sw_path):
453
+ with open(sw_path, 'w', encoding='utf-8') as f:
454
+ f.write('''
455
+ // 캐시 이름 설정
456
+ const CACHE_NAME = 'digital-gutpan-v1';
457
+
458
+ // 캐시할 파일 목록
459
+ const urlsToCache = [
460
+ '/',
461
+ '/static/icons/icon-72x72.png',
462
+ '/static/icons/icon-96x96.png',
463
+ '/static/icons/icon-128x128.png',
464
+ '/static/icons/icon-144x144.png',
465
+ '/static/icons/icon-152x152.png',
466
+ '/static/icons/icon-192x192.png',
467
+ '/static/icons/icon-384x384.png',
468
+ '/static/icons/icon-512x512.png',
469
+ '/assets/main_music.mp3'
470
+ ];
471
+
472
+ // 서비스 워커 설치 시
473
+ self.addEventListener('install', event => {
474
+ event.waitUntil(
475
+ caches.open(CACHE_NAME)
476
+ .then(cache => cache.addAll(urlsToCache))
477
+ .then(() => self.skipWaiting())
478
+ );
479
+ });
480
+
481
+ // 서비스 워커 활성화 시
482
+ self.addEventListener('activate', event => {
483
+ event.waitUntil(
484
+ caches.keys().then(cacheNames => {
485
+ return Promise.all(
486
+ cacheNames.map(cacheName => {
487
+ if (cacheName !== CACHE_NAME) {
488
+ return caches.delete(cacheName);
489
+ }
490
+ })
491
+ );
492
+ }).then(() => self.clients.claim())
493
+ );
494
+ });
495
+
496
+ // 네트워크 요청 처리
497
+ self.addEventListener('fetch', event => {
498
+ event.respondWith(
499
+ caches.match(event.request)
500
+ .then(response => {
501
+ if (response) {
502
+ return response;
503
+ }
504
+ return fetch(event.request);
505
+ })
506
+ );
507
+ });
508
+ '''.strip())
509
+
510
+ # index.html 파일에 화면 꺼짐 방지 스크립트 추가
511
+ index_path = 'templates/index.html'
512
+ if not os.path.exists(index_path):
513
+ with open(index_path, 'w', encoding='utf-8') as f:
514
+ f.write('''<!DOCTYPE html>
515
+ <html lang="ko">
516
+ <head>
517
+ <meta charset="UTF-8">
518
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
519
+ <title>디지털 굿판</title>
520
+ <link rel="manifest" href="/manifest.json">
521
+ <meta name="theme-color" content="#000000">
522
+ <meta name="apple-mobile-web-app-capable" content="yes">
523
+ <meta name="apple-mobile-web-app-status-bar-style" content="black">
524
+ <meta name="apple-mobile-web-app-title" content="디지털 굿판">
525
+ <link rel="apple-touch-icon" href="/static/icons/icon-152x152.png">
526
+ <script>
527
+ // 화면 꺼짐 방지
528
+ async function preventSleep() {
529
+ try {
530
+ if ('wakeLock' in navigator) {
531
+ const wakeLock = await navigator.wakeLock.request('screen');
532
+ console.log('화면 켜짐 유지 활성화');
533
+
534
+ document.addEventListener('visibilitychange', async () => {
535
+ if (document.visibilityState === 'visible') {
536
+ await preventSleep();
537
+ }
538
+ });
539
+ }
540
+ } catch (err) {
541
+ console.log('화면 켜짐 유지 실패:', err);
542
+ }
543
+ }
544
+
545
+ // 서비스 워커 등록
546
+ if ('serviceWorker' in navigator) {
547
+ window.addEventListener('load', async () => {
548
+ try {
549
+ const registration = await navigator.serviceWorker.register('/service-worker.js');
550
+ console.log('ServiceWorker 등록 성공:', registration.scope);
551
+ await preventSleep();
552
+ } catch (err) {
553
+ console.log('ServiceWorker 등록 실패:', err);
554
+ }
555
+ });
556
+ }
557
+ </script>
558
+ </head>
559
+ <body>
560
+ <div id="gradio-app"></div>
561
+ </body>
562
+ </html>''')
563
+
564
+ def safe_state_update(state, updates):
565
  try:
566
+ new_state = {**state, **updates}
567
+ # 중요 상태값 검증
568
+ if "user_name" in updates:
569
+ new_state["user_name"] = str(updates["user_name"]).strip() or "익명"
570
+ if "baseline_features" in updates:
571
+ if updates["baseline_features"] is None:
572
+ return state # baseline이 None이면 상태 업데이트 하지 않음
573
+ return new_state
574
  except Exception as e:
575
+ print(f"State update error: {e}")
576
+ return state
577
+
578
+ def create_interface():
579
+ db = SimpleDB() # DB 객체 초기화 추가
580
+ import base64
581
+
582
+ # initial_state 정의
583
+ initial_state = {
584
+ "user_name": "",
585
+ "baseline_features": None,
586
+ "reflections": [],
587
+ "wish": None,
588
+ "final_prompt": "",
589
+ "image_path": None,
590
+ "current_tab": 0,
591
+ "audio_playing": False
592
+ }
593
+
594
+ def encode_image_to_base64(image_path):
595
+ try:
596
+ with open(image_path, "rb") as img_file:
597
+ return base64.b64encode(img_file.read()).decode()
598
+ except Exception as e:
599
+ print(f"이미지 로딩 에러 ({image_path}): {e}")
600
+ return ""
601
+
602
+ # 로고 이미지 인코딩
603
+ mobile_logo = encode_image_to_base64("static/DIGITAL_GUTPAN_LOGO_m.png")
604
+ desktop_logo = encode_image_to_base64("static/DIGITAL_GUTPAN_LOGO_w.png")
605
+ main_image = encode_image_to_base64("static/main-image.png")
606
+
607
+ if not mobile_logo or not desktop_logo:
608
+ logo_html = """
609
+ <div style="text-align: center; padding: 20px;">
610
+ <h1 style="font-size: 24px;">디지털 굿판</h1>
611
+ </div>
612
+ """
613
+ else:
614
+ logo_html = f"""
615
+ <img class="mobile-logo" src="data:image/png;base64,{mobile_logo}" alt="디지털 굿판 로고 모바일">
616
+ <img class="desktop-logo" src="data:image/png;base64,{desktop_logo}" alt="디지털 굿판 로고 데스크톱">
617
+ """
618
 
619
  main_image_html = f"""
620
  <div class="main-image-container">
621
  <img src="data:image/png;base64,{main_image}" alt="디지털 굿판 메인 이미지" class="main-image">
622
  </div>
623
  """
624
+
625
+ # HTML5 Audio Player 템플릿
626
+ AUDIO_PLAYER_HTML = """
627
  <div class="audio-player-container">
628
+ <audio id="mainAudio" preload="auto" style="display:none;">
629
+ <source src="/assets/main_music.mp3" type="audio/mp3">
 
630
  </audio>
631
+ <button id="playButton" onclick="toggleAudio()" class="custom-audio-button">
632
+ <span id="playButtonText">재생</span>
633
+ </button>
634
  </div>
635
+ <script>
636
+ let audio = document.getElementById('mainAudio');
637
+ let playButton = document.getElementById('playButton');
638
+ let playButtonText = document.getElementById('playButtonText');
639
+
640
+ function toggleAudio() {
641
+ if (audio.paused) {
642
+ audio.play().then(() => {
643
+ playButtonText.textContent = '일시정지';
644
+ }).catch(error => {
645
+ console.error('Audio playback failed:', error);
646
+ alert('음악 재생에 실패했습니다. 다시 시도해주세요.');
647
+ });
648
+ } else {
649
+ audio.pause();
650
+ playButtonText.textContent = '재생';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  }
652
+ }
653
+
654
+ audio.addEventListener('ended', () => {
655
+ playButtonText.textContent = '재생';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  });
657
+
658
+ audio.addEventListener('error', (e) => {
659
+ console.error('Audio error:', e);
660
+ alert('음악 파일을 불러오는데 실패했습니다.');
 
 
661
  });
662
+ </script>
663
+ <style>
664
+ .audio-player-container {
665
+ margin: 20px 0;
666
+ width: 100%;
667
+ text-align: center;
668
+ }
669
+ .custom-audio-button {
670
+ width: 200px;
671
+ padding: 12px 24px;
672
+ margin: 10px 0;
673
+ background-color: #4a90e2;
674
+ color: white;
675
+ border: none;
676
+ border-radius: 8px;
677
+ cursor: pointer;
678
+ font-size: 16px;
679
+ font-weight: bold;
680
+ transition: background-color 0.3s ease;
681
+ }
682
+ .custom-audio-button:hover {
683
+ background-color: #357abd;
684
+ }
685
+ .custom-audio-button:active {
686
+ background-color: #2a5d8f;
687
+ transform: scale(0.98);
688
+ }
689
+ @media (max-width: 600px) {
690
+ .custom-audio-button {
691
+ width: 100%;
692
+ }
693
+ }
694
+ </style>
695
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
696
 
697
+ css = """
698
+ /* 전체 컨테이너 width 제한 */
699
+ .gradio-container {
700
+ margin: 0 auto !important;
701
+ max-width: 800px !important;
702
+ padding: 1rem !important;
703
+ }
704
 
705
+ /* 모바일 뷰 */
706
+ @media (max-width: 600px) {
707
+ .mobile-logo {
708
+ display: none !important;
709
+ }
710
+ .desktop-logo {
711
+ display: block !important;
712
+ width: 100%;
713
+ height: auto;
714
+ max-width: 800px;
715
+ margin: 0 auto;
716
+ }
717
+ .main-image {
718
+ width: 100%;
719
+ max-width: 100%;
720
+ }
721
+ }
722
+
723
+ /* 데스크톱 뷰 (601px 이상) */
724
+ @media (min-width: 601px) {
725
+ .main-image {
726
+ width: 600px;
727
+ max-width: 600px;
728
+ }
729
+ .container { padding: 10px !important; }
730
+ .gradio-row {
731
+ flex-direction: column !important;
732
+ gap: 10px !important;
733
+ }
734
+ .gradio-button {
735
+ width: 100% !important;
736
+ margin: 5px 0 !important;
737
+ min-height: 44px !important;
738
+ }
739
+ .gradio-textbox { width: 100% !important; }
740
+ .gradio-audio { width: 100% !important; }
741
+ .gradio-image { width: 100% !important; }
742
+ #audio-recorder { width: 100% !important; }
743
+ #result-image { width: 100% !important; }
744
+ .gradio-dataframe {
745
+ overflow-x: auto !important;
746
+ max-width: 100% !important;
747
+ }
748
+ }
749
+
750
+ /* 데스크톱 뷰 */
751
+ @media (min-width: 601px) {
752
+ .logo-container {
753
+ padding: 20px 0;
754
+ width: 100%;
755
+ max-width: 800px;
756
+ margin: 0 auto;
757
+ }
758
+ .desktop-logo {
759
+ display: none !important;
760
+ }
761
+ .mobile-logo {
762
+ display: block !important;
763
+ width: 100% !important;
764
+ height: auto !important;
765
+ max-width: 300px !important;
766
+ margin: 0 auto !important;
767
+ }
768
+ /* 데스크탑에서 2단 컬럼 레이아웃 보완 */
769
+ .gradio-row {
770
+ gap: 20px !important;
771
+ }
772
+ .gradio-row > .gradio-column {
773
+ flex: 1 !important;
774
+ min-width: 0 !important;
775
+ }
776
+ }
777
 
778
+ /* 전반적인 UI 개선 */
779
+ .gradio-button {
780
+ transition: all 0.3s ease;
781
+ border-radius: 8px !important;
782
+ }
783
+ .main-image-container {
784
+ width: 100%;
785
+ margin: 0 auto 2rem auto;
786
+ text-align: center;
787
+ }
788
 
789
+ .main-image {
790
+ width: 100%;
791
+ height: auto;
792
+ max-width: 100%;
793
+ display: block;
794
+ margin: 0 auto;
795
+ }
 
 
 
 
 
 
 
 
 
 
 
796
 
797
+ .gradio-button:active {
798
+ transform: scale(0.98);
799
+ }
 
 
 
 
 
 
 
 
800
 
801
+ /* 컴포넌트 간격 조정 */
802
+ .gradio-column > *:not(:last-child) {
803
+ margin-bottom: 1rem !important;
804
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
805
 
806
+ /* 데이터프레임 스타일링 */
807
+ .gradio-dataframe {
808
+ border: 1px solid #e0e0e0;
809
+ border-radius: 8px;
810
+ overflow: hidden;
811
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812
 
813
+ /* 오디오 플레이어 컨테이너 */
814
+ .audio-player-container {
815
+ max-width: 800px;
816
+ margin: 20px auto;
817
+ padding: 0 1rem;
818
+ }
819
+ .audio-player {
820
+ margin: 20px auto;
821
+ width: 100%;
822
+ max-width: 300px;
823
+
824
+ /* 스타일링 */
825
+ .tabs {
826
+ max-width: 800px;
827
+ margin: 0 auto;
828
+ }
829
+
830
+ /* 마크다운 컨텐츠 */
831
+ .markdown-content {
832
+ max-width: 800px;
833
+ margin: 0 auto;
834
+ padding: 0 1rem;
835
+ }
836
+
837
+ /* 이미지 컨테이너 */
838
+ .gradio-image {
839
+ border-radius: 8px;
840
+ overflow: hidden;
841
+ max-width: 800px;
842
+ margin: 0 auto;
843
+ }
844
 
845
+ .blessing-status-box {
846
+ min-height: 200px !important;
847
+ max-height: 300px !important;
848
+ overflow-y: auto !important;
849
+ padding: 15px !important;
850
+ border: 1px solid #ddd !important;
851
+ border-radius: 8px !important;
852
+ background-color: #f8f9fa !important;
853
+ margin: 10px 0 !important;
854
+ }
855
+
856
+ .blessing-status-box p {
857
+ margin: 0 0 10px 0 !important;
858
+ line-height: 1.5 !important;
859
+ }
860
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
861
 
862
+ with gr.Blocks(theme=gr.themes.Soft(), css=css) as app:
863
+ state = gr.State(value=initial_state)
 
 
864
  processing_status = gr.State("")
 
 
865
  with gr.Column(elem_classes="logo-container"):
866
+ gr.HTML(f"""
867
+ <img class="mobile-logo" src="data:image/png;base64,{mobile_logo}" alt="디지털 굿판 로고 모바일">
868
+ <img class="desktop-logo" src="data:image/png;base64,{desktop_logo}" alt="디지털 굿판 로고 데스크톱">
869
+ """)
870
 
 
871
  with gr.Tabs(selected=0) as tabs:
872
+ # 입장 탭 (축원 포함)
873
  with gr.TabItem("입장") as tab_entrance:
 
874
  gr.HTML(main_image_html)
875
+
876
  # 1단계: 첫 화면
877
  welcome_section = gr.Column(visible=True)
878
  with welcome_section:
 
896
  gr.Markdown("### 축원의식을 시작하겠습니다")
897
  gr.Markdown("'명짐 복짐 짊어지고 안가태평하시기를 비도발원 축원 드립니다'")
898
 
899
+ # 축원 문장 녹음하기
900
  baseline_audio = gr.Audio(
901
  label="축원 문장 녹음하기",
902
  sources=["microphone"],
 
904
  streaming=False
905
  )
906
 
907
+ # elem_classes를 사용하여 스타일 적용
908
  blessing_status = gr.Markdown(
909
  "분석 결과가 여기에 표시됩니다.",
910
  elem_id="blessing-status",
911
  elem_classes="blessing-status-box"
912
  )
913
 
914
+ # 상태 분석 및 결과창 구분
915
  set_baseline_btn = gr.Button("축원문 분석하기", variant="primary")
916
  send_blessing_btn = gr.Button("축원 보내기", variant="secondary", visible=False)
917
 
918
+
919
  # 4단계: 굿판 입장 안내
920
  entry_guide_section = gr.Column(visible=False)
921
  with entry_guide_section:
 
927
  """)
928
  enter_btn = gr.Button("청신 의식 시작하기", variant="primary")
929
 
 
930
  with gr.TabItem("청신") as tab_listen:
931
  gr.Markdown("## 청신 - 소리로 정화하기")
932
  gr.Markdown("""
 
936
  온천천 온천장역에서 장전역까지 걸으며 더 깊은 체험이 가능합니다.
937
  """)
938
 
939
+ # 커스텀 오디오 플레이어
940
+ with gr.Blocks(css=css) as demo:
941
+ gr.Markdown("## 온천천의 소리를 들어보세요")
942
+ # HTML로 오디오 플레이어를 직접 삽입
943
+ gr.HTML("""
944
+ <audio controls class="audio-player">
945
+ <source src="/assets/main_music.mp3" type="audio/mpeg">
946
+ 브라우저가 오디오 태그를 지원하지 않습니다.
947
+ </audio>
948
+ """)
949
 
950
  with gr.Column():
951
  reflection_input = gr.Textbox(
 
966
  # 기원 탭
967
  with gr.TabItem("기원") as tab_wish:
968
  gr.Markdown("## 기원 - 소원을 전해보세요")
969
+ status_display = gr.Markdown("", visible=False) # 상태 표시용 컴포넌트
970
 
971
  with gr.Row():
972
  with gr.Column():
 
975
  sources=["microphone"],
976
  type="numpy",
977
  streaming=False,
978
+ elem_id="voice-input" # elem_id 추가
979
  )
980
  with gr.Row():
981
  clear_btn = gr.Button("녹음 지우기", variant="secondary")
 
1008
  label="생성된 이미지",
1009
  show_download_button=True
1010
  )
1011
+
1012
  gr.Markdown("## 온천천에 전하고 싶은 소원을 남겨주세요")
1013
  final_reflection = gr.Textbox(
1014
  label="소원",
 
1017
  )
1018
  save_final_btn = gr.Button("소원 전하기", variant="primary")
1019
  gr.Markdown("""
1020
+ 💫 여러분의 소원은 11월 25일 온천천 벽면에 설치될 소원나무에 전시될 예정입니다.
1021
+ 따뜻한 마음을 담아 작성해주세요.
1022
+ """)
1023
  wishes_display = gr.Dataframe(
1024
  headers=["시간", "소원", "이름"],
1025
  label="기록된 소원들",
 
1027
  interactive=False,
1028
  wrap=True
1029
  )
 
 
1030
  with gr.TabItem("프로젝트 소개") as tab_intro:
1031
  gr.HTML(main_image_html)
1032
  gr.Markdown("""
1033
  # 디지털 굿판 프로젝트
1034
+ ‘디지털 굿판’은 부산문화재단의 지원으로 루츠리딤이 제작한 다원예술 프로젝트입니다.
1035
 
1036
  본 사업은 전통 굿의 요소와 현대 기술을 결합해 참여자들이 자연과 깊이 연결되며 내면을 탐색하고 치유하는 경험을 제공합니다.
1037
 
 
1041
 
1042
  이 프로젝트는 현대 사회의 삶에 대한 근본적인 질문을 던지며 새로운 문화적 지향점을 모색합니다.
1043
 
1044
+ 본 프로젝트에서 기록된 정보 중 ‘송신’ 단계의 ‘소원전하기’를 제외한 모든 과정의 정보는(목소리,감상 등) 별도로 저장되지 않으며 AI 학습이나 외부 공유에 사용되지 않습니다.
1045
 
1046
  ## 크레딧
1047
  - **기획**: 루츠리딤, 전승아
1048
  - **음악**: 루츠리딤 (이광혁)
1049
  - **미디어아트**: 송지훈
1050
+ """)
1051
 
1052
+ # 이벤트 핸들러들
1053
+ def handle_name_submit(name, state):
1054
+ if not name.strip():
1055
+ return (
1056
+ gr.update(visible=True),
1057
+ gr.update(visible=False),
1058
+ gr.update(visible=False),
1059
+ gr.update(visible=False),
1060
+ state
1061
+ )
1062
+ state = safe_state_update(state, {"user_name": name})
1063
+ return (
1064
+ gr.update(visible=False),
1065
+ gr.update(visible=True),
1066
+ gr.update(visible=False),
1067
+ gr.update(visible=False),
1068
+ state
1069
+ )
1070
+
1071
+ def handle_continue():
1072
+ return (
1073
+ gr.update(visible=False),
1074
+ gr.update(visible=False),
1075
+ gr.update(visible=True),
1076
+ gr.update(visible=False)
1077
+ )
1078
+
1079
+ def handle_blessing_complete(audio, state):
1080
+ if audio is None:
1081
+ return state, "음성을 먼저 녹음해주세요.", gr.update(visible=True), gr.update(visible=False)
1082
+
1083
+ try:
1084
+ status_msg = "축원문 분석 중..."
1085
+ sr, y = audio
1086
+ features = calculate_baseline_features((sr, y))
1087
+
1088
+ if features:
1089
+ state = safe_state_update(state, {"baseline_features": features})
1090
+ status_msg = "분석 완료되었습니다. 축원을 보내세요."
1091
+ return state, status_msg, gr.update(visible=False), gr.update(visible=True)
1092
+
1093
+ return state, "분석에 실패했습니다. 다시 시도해주세요.", gr.update(visible=True), gr.update(visible=False)
1094
+
1095
+ except Exception as e:
1096
+ return state, f"오류 발생: {str(e)}", gr.update(visible=True), gr.update(visible=False)
1097
+
1098
+ def handle_enter():
1099
+ return gr.update(selected=1) # 청신 탭으로 이동
1100
+
1101
+ def handle_start():
1102
+ return gr.update(visible=False), gr.update(visible=True)
1103
+
1104
+ def handle_baseline(audio, current_state):
1105
+ if audio is None:
1106
+ return current_state, "음성을 먼저 녹음해주세요.", gr.update(selected=0)
1107
+
1108
+ try:
1109
+ sr, y = audio
1110
+ y = y.astype(np.float32)
1111
+ features = calculate_baseline_features((sr, y))
1112
+ if features:
1113
+ current_state = safe_state_update(current_state, {
1114
+ "baseline_features": features,
1115
+ "current_tab": 1
1116
+ })
1117
+ return current_state, "기준점이 설정되었습니다. 청신 탭으로 이동합니다.", gr.update(selected=1)
1118
+ return current_state, "기준점 설정에 실패했습니다. 다시 시도해주세요.", gr.update(selected=0)
1119
+ except Exception as e:
1120
+ print(f"Baseline error: {str(e)}")
1121
+ return current_state, "오류가 발생했습니다. 다시 시도해주세요.", gr.update(selected=0)
1122
+
1123
+ def handle_save_reflection(text, state):
1124
+ if not text.strip():
1125
+ return state, []
1126
+
1127
+ try:
1128
+ current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1129
+ name = state.get("user_name", "익명")
1130
+
1131
+ if text_analyzer:
1132
+ sentiment = text_analyzer(text)[0]
1133
+ sentiment_text = f"{sentiment['label']} ({sentiment['score']:.2f})"
1134
+ else:
1135
+ sentiment_text = "분석 불가"
1136
+
1137
+ # DB에 저장
1138
+ db.save_reflection(name, text, sentiment_text)
1139
+
1140
+ # 화면에는 현재 사용자의 입력만 표시
1141
+ return state, [[current_time, text, sentiment_text]]
1142
+
1143
+ except Exception as e:
1144
+ print(f"Error saving reflection: {e}")
1145
+ return state, []
1146
+
1147
+ def save_reflection_fixed(text, state):
1148
+ if not text.strip():
1149
+ return state, []
1150
+
1151
+ try:
1152
+ current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1153
+ name = state.get("user_name", "익명")
1154
+
1155
+ if text_analyzer:
1156
+ sentiment = text_analyzer(text)[0]
1157
+ sentiment_text = f"{sentiment['label']} ({sentiment['score']:.2f})"
1158
+ else:
1159
+ sentiment_text = "분석 불가"
1160
+
1161
+ # DB에 저장
1162
+ db.save_reflection(name, text, sentiment_text)
1163
+
1164
+ # 현재 사용자의 감상만 표시
1165
+ return state, [[current_time, text, sentiment_text]]
1166
+
1167
+ except Exception as e:
1168
+ print(f"Error saving reflection: {e}")
1169
+ return state, []
1170
+
1171
+ def handle_save_wish(text, state):
1172
+ if not text.strip():
1173
+ return "소원을 입력해주세요.", []
1174
+
1175
+ try:
1176
+ name = state.get("user_name", "익명")
1177
+ db.save_wish(name, text)
1178
+ wishes = db.get_all_wishes()
1179
+ wish_display_data = [
1180
+ [wish["timestamp"], wish["wish"], wish["name"]]
1181
+ for wish in wishes
1182
+ ]
1183
+ return "소원이 저장되었습니다.", wish_display_data
1184
+ except Exception as e:
1185
+ print(f"Error saving wish: {e}")
1186
+ return "오류가 발생했습니다.", []
1187
+
1188
+ def safe_analyze_voice(audio_data, state):
1189
+ if audio_data is None:
1190
+ return (
1191
+ state,
1192
+ "음성을 먼저 녹음해주세요.",
1193
+ "",
1194
+ "",
1195
+ "",
1196
+ "분석 준비 중..."
1197
+ )
1198
+
1199
+ try:
1200
+ # 상태 업데이트
1201
+ status_msg = "음성 분석 중..."
1202
+
1203
+ # 음성 데이터 전처리
1204
+ sr, y = audio_data
1205
+ if len(y) == 0:
1206
+ return (
1207
+ state,
1208
+ "음성이 감지되지 않았습니다.",
1209
+ "",
1210
+ "",
1211
+ "",
1212
+ "분석 실패"
1213
+ )
1214
+
1215
+ status_msg = "음성 특성 분석 중..."
1216
+ acoustic_features = calculate_baseline_features((sr, y))
1217
+
1218
+ status_msg = "음성 인식 중..."
1219
+ new_state, text, voice_result, text_result, prompt = analyze_voice(audio_data, state)
1220
+
1221
+ status_msg = "분석 완료"
1222
+ return (
1223
+ new_state,
1224
+ text,
1225
+ voice_result,
1226
+ text_result,
1227
+ prompt,
1228
+ status_msg
1229
+ )
1230
+
1231
+ except Exception as e:
1232
+ print(f"Voice analysis error: {str(e)}")
1233
+ return (
1234
+ state,
1235
+ "음성 분석 중 오류가 발생했습니다. 다시 시도해주세요.",
1236
+ "",
1237
+ "",
1238
+ "",
1239
+ "분석 실패"
1240
+ )
1241
+ # 이벤트 연결
1242
  name_submit_btn.click(
1243
  fn=handle_name_submit,
1244
  inputs=[name_input, state],
 
1253
  set_baseline_btn.click(
1254
  fn=handle_blessing_complete,
1255
  inputs=[baseline_audio, state],
1256
+ outputs=[state, blessing_status, blessing_section, entry_guide_section] # baseline_status -> blessing_status
1257
  )
1258
 
1259
  enter_btn.click(
 
1262
  )
1263
 
1264
  save_btn.click(
1265
+ fn=save_reflection_fixed,
1266
  inputs=[reflection_input, state],
1267
  outputs=[state, reflections_display]
1268
  )
 
1287
  save_final_btn.click(
1288
  fn=handle_save_wish,
1289
  inputs=[final_reflection, state],
1290
+ outputs=[blessing_status, wishes_display] # baseline_status -> blessing_status
1291
  )
1292
+
1293
  return app
1294
 
1295
  if __name__ == "__main__":
1296
+ # 서비스 워커 캐시 설정 강화
1297
+ sw_content = """
1298
+ const CACHE_NAME = 'digital-gutpan-v2';
1299
+ const urlsToCache = [
1300
+ '/',
1301
+ '/assets/main_music.mp3',
1302
+ '/static/icons/icon-72x72.png',
1303
+ '/static/icons/icon-96x96.png',
1304
+ '/static/icons/icon-128x128.png',
1305
+ '/static/icons/icon-144x144.png',
1306
+ '/static/icons/icon-152x152.png',
1307
+ '/static/icons/icon-192x192.png',
1308
+ '/static/icons/icon-384x384.png',
1309
+ '/static/icons/icon-512x512.png'
1310
+ ];
1311
+
1312
+ self.addEventListener('install', event => {
1313
+ event.waitUntil(
1314
+ caches.open(CACHE_NAME)
1315
+ .then(cache => {
1316
+ console.log('Opened cache');
1317
+ return cache.addAll(urlsToCache);
1318
+ })
1319
+ .then(() => self.skipWaiting())
1320
+ );
1321
+ });
1322
+
1323
+ self.addEventListener('activate', event => {
1324
+ event.waitUntil(
1325
+ caches.keys().then(cacheNames => {
1326
+ return Promise.all(
1327
+ cacheNames.map(cacheName => {
1328
+ if (cacheName !== CACHE_NAME) {
1329
+ return caches.delete(cacheName);
1330
+ }
1331
+ })
1332
+ );
1333
+ }).then(() => self.clients.claim())
1334
+ );
1335
+ });
1336
+
1337
+ self.addEventListener('fetch', event => {
1338
+ event.respondWith(
1339
+ caches.match(event.request)
1340
+ .then(response => {
1341
+ if (response) {
1342
+ return response;
1343
+ }
1344
+
1345
+ return fetch(event.request).then(
1346
+ response => {
1347
+ if(!response || response.status !== 200 || response.type !== 'basic') {
1348
+ return response;
1349
+ }
1350
+
1351
+ const responseToCache = response.clone();
1352
+
1353
+ caches.open(CACHE_NAME)
1354
+ .then(cache => {
1355
+ cache.put(event.request, responseToCache);
1356
+ });
1357
+
1358
+ return response;
1359
+ }
1360
+ );
1361
+ })
1362
+ );
1363
+ });
1364
+ """
1365
 
1366
+ # 서비스 워커 파일 생성
1367
+ with open('static/service-worker.js', 'w') as f:
1368
+ f.write(sw_content)
 
 
 
1369
 
 
 
1370
  # Gradio 앱 실행
1371
  demo = create_interface()
1372
  demo.queue().launch(
 
1377
  show_error=True,
1378
  height=None,
1379
  width="100%"
1380
+ )