ayaka68 commited on
Commit
7cc64b6
·
verified ·
1 Parent(s): e204bd0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +384 -159
app.py CHANGED
@@ -1,26 +1,54 @@
1
  # =========================
2
- # app.py (AIモデル搭載版)
3
  # =========================
4
  import os
5
- import io
6
- import uuid
7
- import datetime as dt
8
- import csv
9
- import base64
10
- import random
11
  import warnings
 
12
 
13
- # --- 警告の抑制 ---
 
 
 
 
14
  warnings.filterwarnings('ignore')
15
 
16
- # --- ライブラリのインポート ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  import numpy as np
18
  import soundfile as sf
19
  import streamlit as st
20
  from audiorecorder import audiorecorder
21
  from pydub import AudioSegment
 
 
 
 
 
22
  import torch
23
- from transformers import AutoModelForAudioClassification, AutoFeatureExtractor
 
 
 
 
24
 
25
  # =========================
26
  # 架空の場所データ
@@ -46,12 +74,11 @@ REASON_TAGS = ["静けさ","緑","水辺","発散","創作","交流","体験","
46
  # =========================
47
  # AIモデル関連の関数
48
  # =========================
49
-
50
  @st.cache_resource
51
  def load_model():
52
- """AIモデルをロードしてStreamlitのキャッシュに保存"""
53
  try:
54
- model_name = "Mizuiro-inc/emotion2vec-base-japanese"
55
 
56
  with st.spinner('AIモデルを初回ロード中... (数分かかる場合があります)'):
57
  feature_extractor = AutoFeatureExtractor.from_pretrained(model_name)
@@ -60,248 +87,446 @@ def load_model():
60
  return feature_extractor, model
61
  except Exception as e:
62
  st.error(f"モデルのロードに失敗しました: {e}")
63
- st.stop()
 
64
 
65
- def predict_emotion(audio_bytes):
66
- """音声データからAIが感情を予測する"""
 
 
 
 
 
 
67
  try:
68
- feature_extractor, model = load_model()
69
-
70
- # 音声データを16kHzのWAV形式に変換
71
  wav_bytes_16k = to_wav_bytes(audio_bytes, target_sr=16000)
72
  y, sr = sf.read(io.BytesIO(wav_bytes_16k), dtype="float32")
73
 
74
- # 音声が長すぎる場合は最初の30秒のみ使用
75
- max_duration = 30 # 秒
76
  max_samples = sr * max_duration
77
  if len(y) > max_samples:
78
  y = y[:max_samples]
79
  st.warning("音声が30秒を超えているため、最初の30秒のみを分析します")
80
 
81
- # 特徴量を抽出し、PyTorchテンソルに変換
82
  inputs = feature_extractor(y, sampling_rate=sr, return_tensors="pt", padding=True)
83
 
84
- # AIモデルで予測を実行
85
  with torch.no_grad():
86
  logits = model(**inputs).logits
87
 
88
- # 最も確率の高い感情ラベルを取得
89
  predicted_id = torch.argmax(logits, dim=-1).item()
90
  predicted_label = model.config.id2label[predicted_id]
91
 
92
- # 各感情の確率も計算 (表示用)
93
  probabilities = torch.softmax(logits, dim=-1)[0]
94
  all_scores = {model.config.id2label[i]: prob.item() for i, prob in enumerate(probabilities)}
95
 
96
- return predicted_label, all_scores
97
 
98
  except Exception as e:
99
- st.error(f"感情予測中にエラーが発生しました: {e}")
100
- return "neutral", {"neutral": 1.0}
101
 
102
  # =========================
103
- # 汎用関数
104
  # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
 
 
 
106
  def to_wav_bytes(any_bytes: bytes, target_sr=16000, mono=True) -> bytes:
107
- """様々な形式の音声をWAV形式のbytesに変換"""
108
- if not any_bytes:
109
- st.error("音声が空です。")
110
  st.stop()
111
  try:
112
  seg = AudioSegment.from_file(io.BytesIO(any_bytes))
113
- if mono:
114
- seg = seg.set_channels(1)
115
- if target_sr:
116
- seg = seg.set_frame_rate(target_sr)
117
- buf = io.BytesIO()
118
- seg.export(buf, format="wav")
119
- return buf.getvalue()
120
  except Exception as e:
121
- st.error(f"音声ファイルを処理できませんでした: {e}")
122
  st.stop()
 
 
 
 
 
123
 
124
  def audio_player_bytes(b: bytes, mime="audio/wav"):
125
- """音声データをUIに表示するためのHTMLを生成"""
126
- if not b:
127
  return
128
  b64 = base64.b64encode(b).decode("utf-8")
129
  st.markdown(
130
- f'<audio controls preload="metadata" style="width:100%">'
131
- f'<source src="data:{mime};base64,{b64}" type="{mime}">'
132
- f'</audio>',
133
- unsafe_allow_html=True
 
 
 
134
  )
135
 
136
- def score_places_by_ai(emo_label, top_k=4):
137
- """AIの感情ラベルに基づいて場所を推薦する"""
138
- # emotion2vec-base-japaneseの実際のラベルに対応
139
- label_to_emo_key = {
140
- 'happy': ['joy', 'surprise'],
141
- 'sad': ['calm', 'joy'],
142
- 'angry': ['release', 'calm'],
143
- 'neutral': ['calm', 'surprise', 'joy'],
144
- 'surprise': ['surprise', 'joy'],
145
- 'disgust': ['release', 'calm'],
146
- 'fear': ['calm', 'release']
 
 
147
  }
148
- priors = label_to_emo_key.get(emo_label, ['calm', 'joy'])
149
-
150
  scored = []
151
  for p in PLACES:
152
  base = 0.5
153
- if p["emo_key"] == priors[0]:
154
- base += 0.5
155
- if len(priors) > 1 and p["emo_key"] == priors[1]:
156
- base += 0.25
157
- scored.append((base + random.uniform(-0.02, 0.02), p))
158
-
159
  scored.sort(key=lambda x: x[0], reverse=True)
160
-
161
- # 多様性を確保するロジック
162
- candidates = [p for _, p in scored]
 
 
 
163
  picked, seen = [], set()
164
  for p in candidates:
165
- if p["emo_key"] not in seen:
166
- picked.append(p)
167
- seen.add(p["emo_key"])
168
- if len(picked) >= top_k:
169
- break
170
  if len(picked) < top_k:
171
  for p in candidates:
172
- if p not in picked:
173
  picked.append(p)
174
- if len(picked) >= top_k:
175
- break
176
  return picked
177
 
178
  # =========================
179
- # メイン処理(ここから実行開始)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  # =========================
181
  def main():
182
  st.set_page_config(page_title="Voice→Place Recommender", page_icon="🎙️", layout="centered")
183
- st.title("🎙️ 声の感情で『架空の場所』をレコメンド (AI版)")
184
  st.caption("録音→AI感情推定→上位スポット→評価→CSV保存(匿名)")
185
 
186
- # ---- Session state 初期化 ----
187
- for key, default in [("wav_bytes", None), ("recs", None), ("emo_label", None), ("scores", None), ("rec_key", 0)]:
188
- if key not in st.session_state:
189
- st.session_state[key] = default
 
 
 
190
 
191
- # ---- 1) 録音 / アップロード ----
192
  st.subheader("1) 録音またはアップロード")
193
- tab_rec, tab_upload = st.tabs(["🎤 録音する", "📁 ファイルを使う"])
194
-
 
 
 
 
 
 
 
 
 
 
 
195
  with tab_rec:
196
  audio = audiorecorder("録音開始 ▶", "録音停止 ■", key=f"rec_{st.session_state['rec_key']}")
197
  if len(audio) > 0:
198
- buf = io.BytesIO()
199
- audio.export(buf, format="wav")
200
  st.session_state["wav_bytes"] = buf.getvalue()
201
- audio_player_bytes(st.session_state["wav_bytes"])
202
-
 
203
  if st.button("🧹 クリアして新しく録音", use_container_width=True):
204
- for k in ["wav_bytes", "recs", "emo_label", "scores"]:
205
  st.session_state[k] = None
206
  st.session_state["rec_key"] += 1
207
  st.rerun()
208
 
209
  with tab_upload:
210
- up = st.file_uploader("WAV/MP3/M4A を選択", type=["wav", "mp3", "m4a"])
211
- if up:
212
- st.session_state["wav_bytes"] = up.read()
213
- audio_player_bytes(st.session_state["wav_bytes"])
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
- # ---- 2) 同意 ----
216
  st.subheader("2) 同意")
217
- consent = st.radio("研究利用の同意", ["保存しない(体験のみ)", "匿名で保存する"], horizontal=True)
 
 
 
 
 
 
 
218
 
219
- # ---- 推定 & レコメンド実行 ----
220
- if st.button("🔍 AIで推定 & レコメンド", type="primary", use_container_width=True,
221
  disabled=(st.session_state["wav_bytes"] is None)):
222
- with st.spinner('AIが感情を分析中...🤖'):
 
223
  raw_bytes = st.session_state["wav_bytes"]
224
- emo_label, all_scores = predict_emotion(raw_bytes)
225
-
226
- st.session_state.update({
227
- "emo_label": emo_label,
228
- "scores": all_scores,
229
- "recs": score_places_by_ai(emo_label)
230
- })
 
 
 
 
 
231
  st.success("分析が完了しました!")
232
 
233
- # ---- 結果表示 ----
234
- if st.session_state.get("recs"):
235
- emo_label = st.session_state["emo_label"]
236
  scores = st.session_state["scores"]
 
237
  recs = st.session_state["recs"]
238
-
239
- st.subheader("分析結果")
240
-
241
  # 感情の日本語表示
242
  emotion_japanese = {
243
- 'happy': '😊 喜び',
244
- 'sad': '😢 悲しみ',
245
- 'angry': '😠 怒り',
246
- 'neutral': '😐 中立',
247
- 'surprise': '😲 驚き',
248
- 'disgust': '😤 嫌悪',
249
- 'fear': '😨 恐怖'
 
250
  }
251
 
252
- col1, col2 = st.columns([0.6, 0.4])
253
- with col1:
254
- display_emotion = emotion_japanese.get(emo_label, emo_label)
255
- st.success(f"**AIの推定感情: {display_emotion}**")
256
-
257
- # スコアを日本語で表示
258
- japanese_scores = {}
259
- for label, score in scores.items():
260
- jp_label = emotion_japanese.get(label, label)
261
- japanese_scores[jp_label] = score
262
-
263
- st.write("感情スコアの詳細:")
264
- st.bar_chart(japanese_scores)
265
-
266
- with col2:
267
- st.write("この感情におすすめの場所:")
268
- if recs:
269
- st.image(recs[0]["image"], use_container_width=True)
270
- st.markdown(f"**{recs[0]['name']}**")
271
- st.caption(f"タグ: {', '.join(recs[0]['tags'])}")
272
 
 
 
 
 
 
 
273
  st.subheader("3) おすすめ(上位4件)")
274
  cols = st.columns(4)
275
  for i, p in enumerate(recs[:4]):
276
- with cols[i]:
277
- if "image" in p:
278
  st.image(p["image"], use_container_width=True)
279
  st.markdown(f"**{p['name']}**")
280
  st.caption(f"タグ: {', '.join(p['tags'])}")
281
 
282
- # ---- 4) 評価入力 & 5) 保存 ----
283
- with st.form("feedback_form"):
284
- st.subheader("4) 評価")
285
- choice_name = st.selectbox("第一候補を選んでください", [p["name"] for p in recs[:4]])
286
- rating_like = st.slider("行ってみたい度(★)", 1, 5, 4)
287
- rating_vibe = st.slider("気分に合う度(🎯)", 1, 5, 4)
288
- reasons = st.multiselect("理由タグ(1–3個)", REASON_TAGS, max_selections=3)
289
- comment = st.text_input("ひとことコメント(任意・20字)", max_chars=20)
290
-
291
- if st.form_submit_button("💾 ログ保存", use_container_width=True):
292
- st.info("ログ保存機能は現在開発中です。")
 
 
 
 
293
 
294
- # ---- フッター ----
295
  st.divider()
296
- if st.button("▶ 次の人を試す(状態をクリア)", use_container_width=True):
297
- for k in ["wav_bytes", "recs", "emo_label", "scores"]:
298
- if st.session_state.get(k):
299
- st.session_state[k] = None
300
  st.session_state["rec_key"] += 1
301
  st.rerun()
302
 
303
- # =========================
304
  # エントリーポイント
305
- # =========================
306
  if __name__ == "__main__":
307
  main()
 
1
  # =========================
2
+ # streamlit_app.py 日本語AIモデル版
3
  # =========================
4
  import os
5
+ import tempfile
 
 
 
 
 
6
  import warnings
7
+ import logging
8
 
9
+ # ロギングレベルを設定してFontconfigの警告を抑制
10
+ logging.getLogger('matplotlib.font_manager').setLevel(logging.ERROR)
11
+ logging.getLogger('matplotlib').setLevel(logging.ERROR)
12
+
13
+ # すべての警告を抑制
14
  warnings.filterwarnings('ignore')
15
 
16
+ # 権限/キャッシュ対策
17
+ os.environ["STREAMLIT_BROWSER_GATHERUSAGESTATS"] = "false"
18
+ os.environ["NUMBA_DISABLE_JIT"] = "1"
19
+
20
+ # Matplotlibの設定ファイルを作成
21
+ mpl_config_dir = tempfile.mkdtemp()
22
+ os.environ["MPLCONFIGDIR"] = mpl_config_dir
23
+
24
+ # matplotlibrcファイルを作成
25
+ matplotlibrc_path = os.path.join(mpl_config_dir, 'matplotlibrc')
26
+ with open(matplotlibrc_path, 'w') as f:
27
+ f.write("""
28
+ backend: Agg
29
+ font.family: sans-serif
30
+ font.sans-serif: DejaVu Sans
31
+ axes.unicode_minus: False
32
+ """)
33
+
34
+ # その他のインポート
35
+ import io, uuid, datetime as dt, csv, base64, json, random
36
  import numpy as np
37
  import soundfile as sf
38
  import streamlit as st
39
  from audiorecorder import audiorecorder
40
  from pydub import AudioSegment
41
+ import matplotlib
42
+ matplotlib.use('Agg')
43
+ import matplotlib.pyplot as plt
44
+ import matplotlib.patches as mpatches
45
+ from matplotlib import rcParams
46
  import torch
47
+ from transformers import AutoFeatureExtractor, AutoModelForAudioClassification
48
+
49
+ # フォント設定
50
+ rcParams["font.family"] = "DejaVu Sans"
51
+ rcParams["axes.unicode_minus"] = False
52
 
53
  # =========================
54
  # 架空の場所データ
 
74
  # =========================
75
  # AIモデル関連の関数
76
  # =========================
 
77
  @st.cache_resource
78
  def load_model():
79
+ """日本語音声感情認識モデルをロード"""
80
  try:
81
+ model_name = "imprt/kushinada-hubert-base-jtes-er"
82
 
83
  with st.spinner('AIモデルを初回ロード中... (数分かかる場合があります)'):
84
  feature_extractor = AutoFeatureExtractor.from_pretrained(model_name)
 
87
  return feature_extractor, model
88
  except Exception as e:
89
  st.error(f"モデルのロードに失敗しました: {e}")
90
+ st.info("音声特徴量ベースの分析に切り替えます")
91
+ return None, None
92
 
93
+ def predict_emotion_ai(audio_bytes):
94
+ """AIモデルで音声から感情を予測"""
95
+ feature_extractor, model = load_model()
96
+
97
+ if feature_extractor is None or model is None:
98
+ # AIモデルが使えない場合は特徴量ベースにフォールバック
99
+ return predict_emotion_features(audio_bytes)
100
+
101
  try:
102
+ # 音声データを16kHzに変換
 
 
103
  wav_bytes_16k = to_wav_bytes(audio_bytes, target_sr=16000)
104
  y, sr = sf.read(io.BytesIO(wav_bytes_16k), dtype="float32")
105
 
106
+ # 30秒以上の場合は最初の30秒のみ使用
107
+ max_duration = 30
108
  max_samples = sr * max_duration
109
  if len(y) > max_samples:
110
  y = y[:max_samples]
111
  st.warning("音声が30秒を超えているため、最初の30秒のみを分析します")
112
 
113
+ # 特徴量抽出と予測
114
  inputs = feature_extractor(y, sampling_rate=sr, return_tensors="pt", padding=True)
115
 
 
116
  with torch.no_grad():
117
  logits = model(**inputs).logits
118
 
119
+ # 予測結果
120
  predicted_id = torch.argmax(logits, dim=-1).item()
121
  predicted_label = model.config.id2label[predicted_id]
122
 
123
+ # 確率スコア
124
  probabilities = torch.softmax(logits, dim=-1)[0]
125
  all_scores = {model.config.id2label[i]: prob.item() for i, prob in enumerate(probabilities)}
126
 
127
+ return predicted_label, all_scores, "AI"
128
 
129
  except Exception as e:
130
+ st.warning(f"AI予測��にエラーが発生しました: {e}")
131
+ return predict_emotion_features(audio_bytes)
132
 
133
  # =========================
134
+ # 音声特徴量ベースの関数(フォールバック用)
135
  # =========================
136
+ def extract_features(y, sr):
137
+ """音声から特徴量を抽出"""
138
+ # 簡易トリム
139
+ abs_y = np.abs(y)
140
+ thr = 0.01 * (abs_y.max() + 1e-9)
141
+ idx = np.where(abs_y > thr)[0]
142
+ if idx.size >= 2:
143
+ y = y[idx[0]:idx[-1]+1]
144
+
145
+ # RMS(エネルギー)
146
+ energy_mean = float(np.sqrt(np.mean(y**2) + 1e-12))
147
+
148
+ # スペクトル重心
149
+ n = len(y)
150
+ win = np.hanning(n) if n >= 512 else np.ones_like(y)
151
+ y_win = y * win
152
+ spec = np.fft.rfft(y_win)
153
+ mag = np.abs(spec) + 1e-12
154
+ freqs = np.fft.rfftfreq(len(y_win), d=1.0/sr)
155
+ sc_mean = float((freqs * mag).sum() / mag.sum())
156
+
157
+ # ZCR(符号反転率)
158
+ zc = (y[:-1] * y[1:] < 0).astype(np.float32)
159
+ zcr_mean = float(zc.mean()) if zc.size else 0.0
160
+
161
+ # F0(基本周波数)
162
+ fmin, fmax = 80.0, 600.0
163
+ if len(y) < int(sr / fmin) + 2:
164
+ f0_est = 0.0
165
+ else:
166
+ corr = np.correlate(y, y, mode='full')[len(y)-1:]
167
+ lmin = max(1, int(sr / fmax))
168
+ lmax = min(len(corr) - 1, int(sr / fmin))
169
+ seg = corr[lmin:lmax] if lmax > lmin else np.array([])
170
+ if seg.size > 0:
171
+ lag = lmin + int(np.argmax(seg))
172
+ f0_est = float(sr / lag) if lag > 0 else 0.0
173
+ else:
174
+ f0_est = 0.0
175
+
176
+ return {
177
+ "f0_mean": float(f0_est),
178
+ "energy_mean": energy_mean,
179
+ "spec_centroid": sc_mean,
180
+ "zcr_mean": zcr_mean,
181
+ "duration": len(y)/sr
182
+ }
183
+
184
+ def predict_emotion_features(audio_bytes):
185
+ """音声特徴量から感情を推定(フォールバック)"""
186
+ wav_bytes_16k = to_wav_bytes(audio_bytes, target_sr=16000)
187
+ y, sr = sf.read(io.BytesIO(wav_bytes_16k), dtype="float32")
188
+
189
+ feat = extract_features(y, sr)
190
+
191
+ # 特徴量から感情を推定
192
+ f0 = feat["f0_mean"]
193
+ en = feat["energy_mean"]
194
+ z = feat["zcr_mean"]
195
+
196
+ # Arousal/Valenceを計算
197
+ arousal = float(np.tanh(160*en + 4*z))
198
+ valence = float(np.tanh(((f0-170)/120) + 15*en))
199
+
200
+ # 感情ラベルを決定
201
+ if valence >= 0.22 and arousal >= 0.22:
202
+ label = "happiness"
203
+ elif valence >= 0.22 and arousal < 0.22:
204
+ label = "neutral" # calm
205
+ elif valence < 0.10 and arousal >= 0.30:
206
+ label = "anger"
207
+ elif valence < 0.10 and arousal < 0.18:
208
+ label = "sadness"
209
+ else:
210
+ label = "neutral"
211
+
212
+ # 擬似的なスコア
213
+ scores = {
214
+ "happiness": 0.0,
215
+ "anger": 0.0,
216
+ "sadness": 0.0,
217
+ "neutral": 0.0
218
+ }
219
+ scores[label] = 0.7
220
+ scores["neutral"] += 0.3
221
+
222
+ return label, scores, "Features"
223
 
224
+ # =========================
225
+ # 共通関数
226
+ # =========================
227
  def to_wav_bytes(any_bytes: bytes, target_sr=16000, mono=True) -> bytes:
228
+ """音声をWAV形式に変換"""
229
+ if not any_bytes or len(any_bytes) == 0:
230
+ st.error("音声が空です。録音やアップロードを確認してください。")
231
  st.stop()
232
  try:
233
  seg = AudioSegment.from_file(io.BytesIO(any_bytes))
 
 
 
 
 
 
 
234
  except Exception as e:
235
+ st.error(f"音声を読み込めませんでした: {e}")
236
  st.stop()
237
+ if mono: seg = seg.set_channels(1)
238
+ if target_sr: seg = seg.set_frame_rate(target_sr)
239
+ buf = io.BytesIO()
240
+ seg.export(buf, format="wav")
241
+ return buf.getvalue()
242
 
243
  def audio_player_bytes(b: bytes, mime="audio/wav"):
244
+ """音声プレイヤーを表示"""
245
+ if not b:
246
  return
247
  b64 = base64.b64encode(b).decode("utf-8")
248
  st.markdown(
249
+ f"""
250
+ <audio controls preload="metadata" style="width:100%">
251
+ <source src="data:{mime};base64,{b64}" type="{mime}">
252
+ Your browser does not support the audio element.
253
+ </audio>
254
+ """,
255
+ unsafe_allow_html=True,
256
  )
257
 
258
+ def score_places(emo_label, top_k=4, diversity=True):
259
+ """感情に基づいて場所を推薦"""
260
+ # JTESの感情ラベルと場所のマッピング
261
+ EMO_MAP_PRIORS = {
262
+ "happiness": ["joy", "surprise"],
263
+ "anger": ["release", "calm"],
264
+ "sadness": ["calm", "joy"],
265
+ "neutral": ["calm", "surprise", "joy"],
266
+ # 特徴量ベースのラベル用
267
+ "joy": ["joy","surprise"],
268
+ "calm": ["calm","joy"],
269
+ "surprise": ["surprise","joy"],
270
+ "release": ["release","calm"],
271
  }
272
+
273
+ priors = EMO_MAP_PRIORS.get(emo_label, ["calm","joy","surprise"])
274
  scored = []
275
  for p in PLACES:
276
  base = 0.5
277
+ if p["emo_key"] == priors[0]: base += 0.5
278
+ if len(priors) > 1 and p["emo_key"] == priors[1]: base += 0.25
279
+ jitter = random.uniform(-0.02, 0.02)
280
+ scored.append((base + jitter, p))
 
 
281
  scored.sort(key=lambda x: x[0], reverse=True)
282
+ candidates = [p for _, p in scored[:max(top_k, 4)]]
283
+
284
+ if not diversity:
285
+ return candidates[:top_k]
286
+
287
+ # 多様化
288
  picked, seen = [], set()
289
  for p in candidates:
290
+ k = p["emo_key"]
291
+ if k not in seen:
292
+ picked.append(p); seen.add(k)
293
+ if len(picked) >= top_k: break
 
294
  if len(picked) < top_k:
295
  for p in candidates:
296
+ if p not in picked:
297
  picked.append(p)
298
+ if len(picked) >= top_k: break
 
299
  return picked
300
 
301
  # =========================
302
+ # 感情マップ描画
303
+ # =========================
304
+ def plot_emotion_map(emotion_label, scores, method="AI"):
305
+ """感情分析結果をビジュアル化"""
306
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5), dpi=150)
307
+
308
+ # 左: 感情スコアの棒グラフ
309
+ emotion_japanese = {
310
+ 'happiness': '😊 喜び',
311
+ 'anger': '😠 怒り',
312
+ 'sadness': '😢 悲しみ',
313
+ 'neutral': '😐 中立'
314
+ }
315
+
316
+ labels = []
317
+ values = []
318
+ colors = []
319
+ color_map = {
320
+ 'happiness': '#FF6B6B',
321
+ 'anger': '#FFA94D',
322
+ 'sadness': '#868E96',
323
+ 'neutral': '#51CF66'
324
+ }
325
+
326
+ for label, score in scores.items():
327
+ jp_label = emotion_japanese.get(label, label)
328
+ labels.append(jp_label)
329
+ values.append(score)
330
+ colors.append(color_map.get(label, '#74C0FC'))
331
+
332
+ bars = ax1.bar(labels, values, color=colors, alpha=0.8)
333
+ ax1.set_ylim(0, 1)
334
+ ax1.set_ylabel('Score', fontsize=12)
335
+ ax1.set_title(f'Emotion Scores ({method})', fontsize=14, fontweight='bold')
336
+ ax1.grid(axis='y', alpha=0.3)
337
+
338
+ # 数値を棒の上に表示
339
+ for bar, value in zip(bars, values):
340
+ height = bar.get_height()
341
+ ax1.text(bar.get_x() + bar.get_width()/2., height + 0.01,
342
+ f'{value:.2f}', ha='center', va='bottom', fontsize=10)
343
+
344
+ # 右: 感情の円グラフ
345
+ sizes = [score for score in scores.values() if score > 0.05]
346
+ labels_pie = [emotion_japanese.get(label, label) for label, score in scores.items() if score > 0.05]
347
+ colors_pie = [color_map.get(label, '#74C0FC') for label, score in scores.items() if score > 0.05]
348
+
349
+ wedges, texts, autotexts = ax2.pie(sizes, labels=labels_pie, colors=colors_pie,
350
+ autopct='%1.0f%%', startangle=90,
351
+ textprops={'fontsize': 11})
352
+
353
+ # 推定された感情を強調
354
+ current_jp = emotion_japanese.get(emotion_label, emotion_label)
355
+ ax2.set_title(f'Result: {current_jp}', fontsize=14, fontweight='bold')
356
+
357
+ plt.tight_layout()
358
+ return fig
359
+
360
+ # =========================
361
+ # メイン処理
362
  # =========================
363
  def main():
364
  st.set_page_config(page_title="Voice→Place Recommender", page_icon="🎙️", layout="centered")
365
+ st.title("🎙️ 声の感情で『架空の場所』をレコメンド")
366
  st.caption("録音→AI感情推定→上位スポット→評価→CSV保存(匿名)")
367
 
368
+ # Session state 初期化
369
+ for key, default in [
370
+ ("wav_bytes", None), ("recs", None), ("feat", None),
371
+ ("emotion_label", None), ("scores", None), ("method", None),
372
+ ("rec_key", 0),
373
+ ]:
374
+ if key not in st.session_state: st.session_state[key] = default
375
 
376
+ # UI: 録音 / アップロード
377
  st.subheader("1) 録音またはアップロード")
378
+
379
+ # 403エラーの対処法
380
+ with st.warning("⚠️ ファイルアップロードで403エラーが出る場合"):
381
+ st.markdown("""
382
+ **推奨方法:録音機能を使用してください**
383
+
384
+ 1. 🎤 **録音する**タブを使用
385
+ 2. PCやスマホで音声を再生しながら録音
386
+ 3. または直接マイクに向かって話す
387
+ """)
388
+
389
+ tab_rec, tab_upload = st.tabs(["🎤 録音する(推奨)", "📁 ファイルを使う"])
390
+
391
  with tab_rec:
392
  audio = audiorecorder("録音開始 ▶", "録音停止 ■", key=f"rec_{st.session_state['rec_key']}")
393
  if len(audio) > 0:
394
+ buf = io.BytesIO(); audio.export(buf, format="wav")
 
395
  st.session_state["wav_bytes"] = buf.getvalue()
396
+ audio_player_bytes(st.session_state["wav_bytes"], mime="audio/wav")
397
+ st.caption(f"録音サイズ: {len(st.session_state['wav_bytes']) / 1024:.1f} KB")
398
+
399
  if st.button("🧹 クリアして新しく録音", use_container_width=True):
400
+ for k in ["wav_bytes","recs","feat","emotion_label","scores","method"]:
401
  st.session_state[k] = None
402
  st.session_state["rec_key"] += 1
403
  st.rerun()
404
 
405
  with tab_upload:
406
+ uploaded_file = st.file_uploader(
407
+ "音声ファイルを選択(WAV推奨)",
408
+ type=["wav", "mp3", "m4a"],
409
+ accept_multiple_files=False
410
+ )
411
+
412
+ if uploaded_file is not None:
413
+ try:
414
+ bytes_data = uploaded_file.getvalue()
415
+ st.session_state["wav_bytes"] = bytes_data
416
+ st.success(f"✅ ファイル読み込み成功: {uploaded_file.name}")
417
+ st.caption(f"ファイルサイズ: {len(bytes_data) / 1024:.1f} KB")
418
+ audio_player_bytes(bytes_data, mime="audio/wav")
419
+ except Exception as e:
420
+ st.error(f"❌ ファイル読み込みエラー")
421
+ st.exception(e)
422
+ st.info("💡 代わりに録音機能をお試しください")
423
 
424
+ # UI: 同意
425
  st.subheader("2) 同意")
426
+ consent = st.radio("研究利用の同意(匿名IDで特徴量と評価を保存します)",
427
+ ["保存しない(体験のみ)", "匿名で保存する"], horizontal=True)
428
+ save_audio = st.checkbox("音声ファイルも保存する(任意)", value=False)
429
+
430
+ # 分析方法の選択
431
+ analysis_method = st.radio("分析方法",
432
+ ["AIモデル(推奨)", "音声特徴量ベース"],
433
+ horizontal=True)
434
 
435
+ # 推定 & レコメンド
436
+ if st.button("🔍 推定 & レコメンド", type="primary", use_container_width=True,
437
  disabled=(st.session_state["wav_bytes"] is None)):
438
+
439
+ with st.spinner('感情を分析中...'):
440
  raw_bytes = st.session_state["wav_bytes"]
441
+
442
+ if analysis_method == "AIモデル(推奨)":
443
+ emotion_label, scores, method = predict_emotion_ai(raw_bytes)
444
+ else:
445
+ emotion_label, scores, method = predict_emotion_features(raw_bytes)
446
+
447
+ # 状態に保存
448
+ st.session_state["emotion_label"] = emotion_label
449
+ st.session_state["scores"] = scores
450
+ st.session_state["method"] = method
451
+ st.session_state["recs"] = score_places(emotion_label, top_k=4, diversity=True)
452
+
453
  st.success("分析が完了しました!")
454
 
455
+ # 表示(推定が完了していれば出す)
456
+ if st.session_state["recs"] is not None:
457
+ emotion_label = st.session_state["emotion_label"]
458
  scores = st.session_state["scores"]
459
+ method = st.session_state["method"]
460
  recs = st.session_state["recs"]
461
+
 
 
462
  # 感情の日本語表示
463
  emotion_japanese = {
464
+ 'happiness': '喜び',
465
+ 'anger': '怒り',
466
+ 'sadness': '悲しみ',
467
+ 'neutral': '中立',
468
+ 'joy': '喜び',
469
+ 'calm': '落ち着き',
470
+ 'surprise': '驚き',
471
+ 'release': '発散'
472
  }
473
 
474
+ display_emotion = emotion_japanese.get(emotion_label, emotion_label)
475
+ st.success(f"推定感情: **{display_emotion}**")
476
+
477
+ # 感情の説明
478
+ emotion_explanations = {
479
+ "happiness": "喜びや楽しさを感じています",
480
+ "joy": "喜びや楽しさを感じています",
481
+ "calm": "落ち着いて穏やかな状態です",
482
+ "surprise": "驚きや興奮を感じています",
483
+ "anger": "怒りやイライラを感じています",
484
+ "sadness": "悲しみや元気のない状態です",
485
+ "neutral": "特に強い感情はない中立状態です",
486
+ "release": "発散や解放を求めています"
487
+ }
488
+
489
+ if emotion_label in emotion_explanations:
490
+ st.info(f"💡 {emotion_explanations[emotion_label]}")
 
 
 
491
 
492
+ # 感情マップ表示
493
+ st.subheader("感情分析結果")
494
+ fig = plot_emotion_map(emotion_label, scores, method)
495
+ st.pyplot(fig, clear_figure=True)
496
+
497
+ # おすすめ表示(上位4件)
498
  st.subheader("3) おすすめ(上位4件)")
499
  cols = st.columns(4)
500
  for i, p in enumerate(recs[:4]):
501
+ with cols[i % 4]:
502
+ if "image" in p:
503
  st.image(p["image"], use_container_width=True)
504
  st.markdown(f"**{p['name']}**")
505
  st.caption(f"タグ: {', '.join(p['tags'])}")
506
 
507
+ # 評価入力
508
+ st.subheader("4) 評価")
509
+ choice_name = st.selectbox("第一候補を選んでください", [p["name"] for p in recs[:4]])
510
+ rating_like = st.slider("行ってみたい度(★)", 1, 5, 4)
511
+ rating_vibe = st.slider("気分に合う度(🎯)", 1, 5, 4)
512
+ reasons = st.multiselect("理由タグ(1–3個)", REASON_TAGS, max_selections=3)
513
+ comment = st.text_input("ひとことコメント(任意・20字)", max_chars=20)
514
+
515
+ # 保存
516
+ if st.button("💾 ログ保存", use_container_width=True):
517
+ consent_research = (consent == "匿名で保存する")
518
+ if not consent_research:
519
+ st.info("体験のみモードです。研究ログは保存しません。")
520
+ else:
521
+ st.success("保存機能は開発中です。")
522
 
 
523
  st.divider()
524
+ if st.button("▶ 次の人を録音する(状態をクリア)", use_container_width=True):
525
+ for k in ["wav_bytes","recs","emotion_label","scores","method"]:
526
+ st.session_state[k] = None
 
527
  st.session_state["rec_key"] += 1
528
  st.rerun()
529
 
 
530
  # エントリーポイント
 
531
  if __name__ == "__main__":
532
  main()