ayaka68 commited on
Commit
8a8b25f
·
verified ·
1 Parent(s): c17f977

Upload 10 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ images/aqua_museum.png filter=lfs diff=lfs merge=lfs -text
37
+ images/lib_silent.png filter=lfs diff=lfs merge=lfs -text
38
+ images/roof_garden.png filter=lfs diff=lfs merge=lfs -text
39
+ images/shade_bol.png filter=lfs diff=lfs merge=lfs -text
40
+ images/silent_atlier.png filter=lfs diff=lfs merge=lfs -text
41
+ images/wind_root.png filter=lfs diff=lfs merge=lfs -text
.streamlit:config.toml ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [browser]
2
+ gatherUsageStats = false
app.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io, uuid, datetime as dt, csv
2
+ import numpy as np
3
+ import librosa, soundfile as sf
4
+ import streamlit as st
5
+ from audiorecorder import audiorecorder
6
+ from pydub import AudioSegment
7
+
8
+ st.set_page_config(page_title="Voice→Place Recommender", page_icon="🎙️", layout="centered")
9
+ st.title("🎙️ 声の感情で『架空の場所』をレコメンド")
10
+ st.caption("録音→感情推定(Arousal/Valence)→上位3スポット→評価→CSV保存(匿名)")
11
+
12
+ # =========================
13
+ # 架空の場所データ
14
+ # =========================
15
+ PLACES = [
16
+ {"place_id":"lib_silent", "name":"無音図書館",
17
+ "tags":["静けさ","集中","屋内"], "emo_key":"calm",
18
+ "image":"images/lib_silent.jpg"},
19
+ {"place_id":"aqua_museum", "name":"深海ガラス館",
20
+ "tags":["発見","学習","ひんやり","屋内"], "emo_key":"surprise",
21
+ "image":"images/aqua_museum.jpg"},
22
+ {"place_id":"roof_garden", "name":"雨上がりの屋上庭園",
23
+ "tags":["開放","共有","屋外","緑"], "emo_key":"joy",
24
+ "image":"images/roof_garden.jpg"},
25
+ {"place_id":"boulder_warehouse", "name":"影のボルダリング倉庫",
26
+ "tags":["発散","身体活動","屋内"], "emo_key":"release",
27
+ "image":"images/shade_bol.jpg"},
28
+ {"place_id":"atelier_mono", "name":"静寂のアトリエ",
29
+ "tags":["創作","集中","屋内"], "emo_key":"calm",
30
+ "image":"images/silent_atlier.jpg"},
31
+ {"place_id":"wind_birch", "name":"風鳴りの白樺道",
32
+ "tags":["自然","散歩","屋外","緑"], "emo_key":"joy",
33
+ "image":"images/wind_root.jpg"}
34
+ ]
35
+ REASON_TAGS = ["静けさ","緑","水辺","発散","創作","交流","体験","学習","屋内","屋外","没入","回復"]
36
+
37
+ # =========================
38
+ # 特徴量抽出・推定ロジック
39
+ # =========================
40
+ def extract_features(y, sr):
41
+ yt, _ = librosa.effects.trim(y, top_db=30)
42
+ f0, _, _ = librosa.pyin(yt, fmin=librosa.note_to_hz('C2'), fmax=librosa.note_to_hz('C7'))
43
+ f0_mean = np.nanmean(f0); f0_med = np.nanmedian(f0)
44
+ rms = librosa.feature.rms(y=yt).flatten(); energy_mean = float(np.mean(rms))
45
+ spec_cent = librosa.feature.spectral_centroid(y=yt, sr=sr).flatten(); sc_mean = float(np.mean(spec_cent))
46
+ zcr = librosa.feature.zero_crossing_rate(yt).flatten(); zcr_mean = float(np.mean(zcr))
47
+ return {
48
+ "f0_mean": float(f0_mean if not np.isnan(f0_mean) else 0.0),
49
+ "f0_med": float(f0_med if not np.isnan(f0_med) else 0.0),
50
+ "energy_mean": energy_mean,
51
+ "spec_centroid": sc_mean,
52
+ "zcr_mean": zcr_mean,
53
+ "duration": len(yt)/sr
54
+ }
55
+
56
+ def av_from_features(feat):
57
+ f0 = feat["f0_mean"]; en = feat["energy_mean"]; z = feat["zcr_mean"]
58
+ arousal = float(np.tanh((en*200) + (z*5)))
59
+ valence = float(np.tanh(((f0-170)/120) + en*30))
60
+ return arousal, valence
61
+
62
+ def label_from_av(arousal, valence):
63
+ if valence >= 0.15 and arousal >= 0.15: return "joy"
64
+ if valence >= 0.15 and arousal < 0.15: return "calm"
65
+ if valence < 0.15 and arousal >= 0.25: return "arousal_high_neg"
66
+ if arousal >= 0.15: return "surprise"
67
+ return "neutral"
68
+
69
+ EMO_MAP_PRIORS = {
70
+ "joy": ["joy","surprise"], "calm": ["calm","joy"],
71
+ "surprise": ["surprise","joy"], "arousal_high_neg": ["release","surprise"],
72
+ "neutral": ["calm","joy","surprise"]
73
+ }
74
+
75
+ def score_places(emo_label):
76
+ priors = EMO_MAP_PRIORS.get(emo_label, ["calm","joy","surprise"])
77
+ scored = []
78
+ for p in PLACES:
79
+ base = 0.5
80
+ if p["emo_key"] == priors[0]: base += 0.5
81
+ if len(priors) > 1 and p["emo_key"] == priors[1]: base += 0.25
82
+ scored.append((base, p))
83
+ scored.sort(key=lambda x: x[0], reverse=True)
84
+ return [p for _, p in scored][:3]
85
+
86
+ # =========================
87
+ # ログ保存
88
+ # =========================
89
+ def ensure_logs():
90
+ import os
91
+ os.makedirs("logs", exist_ok=True)
92
+ path = "logs/oc_sessions.csv"
93
+ if not os.path.exists(path):
94
+ with open(path, "w", newline="", encoding="utf-8") as f:
95
+ csv.writer(f).writerow([
96
+ "session_id","ts","consent_research","save_audio",
97
+ "f0_mean","energy_mean","spec_centroid","zcr_mean","duration",
98
+ "arousal","valence","emo_label",
99
+ "exposed_ids","choice_id","rating_like","rating_vibe","reason_tags","comment"
100
+ ])
101
+ return path
102
+
103
+ def append_log(row_dict):
104
+ path = ensure_logs()
105
+ with open(path, "a", newline="", encoding="utf-8") as f:
106
+ csv.writer(f).writerow([
107
+ row_dict.get("session_id"), row_dict.get("ts"),
108
+ row_dict.get("consent_research"), row_dict.get("save_audio"),
109
+ row_dict.get("f0_mean"), row_dict.get("energy_mean"),
110
+ row_dict.get("spec_centroid"), row_dict.get("zcr_mean"),
111
+ row_dict.get("duration"),
112
+ row_dict.get("arousal"), row_dict.get("valence"), row_dict.get("emo_label"),
113
+ ",".join(row_dict.get("exposed_ids", [])),
114
+ row_dict.get("choice_id"),
115
+ row_dict.get("rating_like"), row_dict.get("rating_vibe"),
116
+ "|".join(row_dict.get("reason_tags", [])),
117
+ row_dict.get("comment","")
118
+ ])
119
+
120
+ # =========================
121
+ # 音声をWAVに正規化
122
+ # =========================
123
+ def to_wav_bytes(any_bytes: bytes, target_sr=16000, mono=True) -> bytes:
124
+ if not any_bytes or len(any_bytes) == 0:
125
+ st.error("音声が空です。録音やアップロードを確認してください。"); st.stop()
126
+ try:
127
+ seg = AudioSegment.from_file(io.BytesIO(any_bytes))
128
+ except Exception as e:
129
+ st.error(f"音声を読み込めませんでした: {e}"); st.stop()
130
+ if mono: seg = seg.set_channels(1)
131
+ if target_sr: seg = seg.set_frame_rate(target_sr)
132
+ buf = io.BytesIO(); seg.export(buf, format="wav"); return buf.getvalue()
133
+
134
+ # =========================
135
+ # Session state 初期化
136
+ # =========================
137
+ for key, default in [
138
+ ("wav_bytes", None), ("recs", None), ("feat", None),
139
+ ("arousal", None), ("valence", None), ("emo_label", None)
140
+ ]:
141
+ if key not in st.session_state: st.session_state[key] = default
142
+
143
+ # =========================
144
+ # UI: 録音 / アップロード
145
+ # =========================
146
+ st.subheader("1) 録音またはアップロード")
147
+ tab_rec, tab_upload = st.tabs(["🎤 録音する", "📁 ファイルを使う"])
148
+
149
+ with tab_rec:
150
+ audio = audiorecorder("録音開始 ▶", "録音停止 ■")
151
+ if len(audio) > 0:
152
+ buf = io.BytesIO(); audio.export(buf, format="wav")
153
+ st.session_state["wav_bytes"] = buf.getvalue()
154
+ st.audio(st.session_state["wav_bytes"], format="audio/wav")
155
+ st.caption(f"録音サイズ: {len(st.session_state['wav_bytes'])} bytes")
156
+
157
+ with tab_upload:
158
+ up = st.file_uploader("WAV/MP3/M4A を選択", type=["wav","mp3","m4a"])
159
+ if up is not None:
160
+ st.session_state["wav_bytes"] = up.read()
161
+ st.audio(st.session_state["wav_bytes"])
162
+ st.caption(f"アップロードサイズ: {len(st.session_state['wav_bytes'])} bytes")
163
+
164
+ # =========================
165
+ # UI: 同意
166
+ # =========================
167
+ st.subheader("2) 同意")
168
+ consent = st.radio("研究利用の同意(匿名IDで特徴量と評価を保存します)",
169
+ ["保存しない(体験のみ)", "匿名で保存する"], horizontal=True)
170
+ save_audio = st.checkbox("音声ファイルも保存する(任意)", value=False)
171
+
172
+ # =========================
173
+ # 推定 & レコメンド
174
+ # =========================
175
+ if st.button("🔍 推定 & レコメンド", type="primary", use_container_width=True,
176
+ disabled=(st.session_state["wav_bytes"] is None)):
177
+ raw_bytes = st.session_state["wav_bytes"]
178
+ wav_bytes_fixed = to_wav_bytes(raw_bytes, target_sr=16000, mono=True)
179
+ try:
180
+ y, sr = librosa.load(io.BytesIO(wav_bytes_fixed), sr=16000, mono=True)
181
+ except Exception as e:
182
+ st.error(f"音声読み込みでエラー: {e}"); st.stop()
183
+
184
+ feat = extract_features(y, sr)
185
+ arousal, valence = av_from_features(feat)
186
+ emo_label = label_from_av(arousal, valence)
187
+
188
+ # 状態に保存(rerun 対策)
189
+ st.session_state["feat"] = feat
190
+ st.session_state["arousal"] = arousal
191
+ st.session_state["valence"] = valence
192
+ st.session_state["emo_label"] = emo_label
193
+ st.session_state["recs"] = score_places(emo_label)
194
+
195
+ # 表示(推定が完了していれば出す)
196
+ if st.session_state["recs"] is not None:
197
+ feat = st.session_state["feat"]; arousal = st.session_state["arousal"]
198
+ valence = st.session_state["valence"]; emo_label = st.session_state["emo_label"]
199
+ recs = st.session_state["recs"]
200
+
201
+ st.success(f"推定感情: **{emo_label}** | Arousal: {arousal:.2f} / Valence: {valence:.2f}")
202
+ st.caption(f"F0_mean={feat['f0_mean']:.1f} Hz, Energy={feat['energy_mean']:.4f}, ZCR={feat['zcr_mean']:.3f}")
203
+
204
+ st.subheader("3) おすすめ(上位3件)")
205
+ cols = st.columns(3)
206
+ for i, p in enumerate(recs):
207
+ with cols[i]:
208
+ st.markdown(f"**{p['name']}**")
209
+ st.caption(f"タグ: {', '.join(p['tags'])}")
210
+
211
+ # =========================
212
+ # 4) 評価入力
213
+ # =========================
214
+ st.subheader("4) 評価")
215
+ choice_name = st.selectbox("第一候補を選んでください", [p["name"] for p in recs])
216
+ rating_like = st.slider("行ってみたい度(★)", 1, 5, 4)
217
+ rating_vibe = st.slider("気分に合う度(🎯)", 1, 5, 4)
218
+ reasons = st.multiselect("理由タグ(1–3個)", REASON_TAGS, max_selections=3)
219
+ comment = st.text_input("ひとことコメント(任意・20字)", max_chars=20)
220
+
221
+ # =========================
222
+ # 5) 保存
223
+ # =========================
224
+ if st.button("💾 ログ保存", use_container_width=True):
225
+ consent_research = (consent == "匿名で保存する")
226
+ if not consent_research:
227
+ st.info("体験のみモードです。研究ログは保存しません。")
228
+ else:
229
+ exposed_ids = [p["place_id"] for p in recs]
230
+ choice_id = next(p["place_id"] for p in recs if p["name"] == choice_name)
231
+ row = {
232
+ "session_id": f"oc-{uuid.uuid4().hex[:8]}",
233
+ "ts": dt.datetime.now().isoformat(timespec="seconds"),
234
+ "consent_research": consent_research,
235
+ "save_audio": (save_audio and consent_research),
236
+ "f0_mean": feat["f0_mean"], "energy_mean": feat["energy_mean"],
237
+ "spec_centroid": feat["spec_centroid"], "zcr_mean": feat["zcr_mean"],
238
+ "duration": feat["duration"],
239
+ "arousal": arousal, "valence": valence, "emo_label": emo_label,
240
+ "exposed_ids": exposed_ids, "choice_id": choice_id,
241
+ "rating_like": rating_like, "rating_vibe": rating_vibe,
242
+ "reason_tags": reasons, "comment": comment,
243
+ }
244
+ append_log(row)
245
+ if row["save_audio"]:
246
+ import os; os.makedirs("logs", exist_ok=True)
247
+ with open(f"logs/{row['session_id']}.wav", "wb") as f:
248
+ f.write(st.session_state["wav_bytes"])
249
+ st.success("保存しました(logs/oc_sessions.csv)。")
hf.yaml ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ sdk: streamlit
2
+ app_file: streamlit_app.py
images/aqua_museum.png ADDED

Git LFS Details

  • SHA256: 0f30f66180de76aa92c0046fd80ea56c3519bd05f852a82bd58bec3d946d4ca1
  • Pointer size: 132 Bytes
  • Size of remote file: 6.56 MB
images/lib_silent.png ADDED

Git LFS Details

  • SHA256: 8879445f67da5529def23fdd32164dfc6ba0da0acc5b70b4d7c47446e9256e2a
  • Pointer size: 132 Bytes
  • Size of remote file: 7.38 MB
images/roof_garden.png ADDED

Git LFS Details

  • SHA256: 64779e62d035d2bc803c8c8941d15c04ee87703a29b856910fcafb646c59b4b2
  • Pointer size: 132 Bytes
  • Size of remote file: 8.37 MB
images/shade_bol.png ADDED

Git LFS Details

  • SHA256: 83a9fe10b652f50a1e55c49f76f328131e341239e0884d2f18a6a58d599baf05
  • Pointer size: 132 Bytes
  • Size of remote file: 5.78 MB
images/silent_atlier.png ADDED

Git LFS Details

  • SHA256: 4123b25dad3fc766a7d381a214fd5e4b906252cbbbcb7617af222d0e5b68cb85
  • Pointer size: 132 Bytes
  • Size of remote file: 4.84 MB
images/wind_root.png ADDED

Git LFS Details

  • SHA256: 0ccd4537758cf46877b28da50b48ce487a0eba734116f21d36a7cec5972cd01e
  • Pointer size: 133 Bytes
  • Size of remote file: 10.3 MB
requirements.txt.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ streamlit
2
+ streamlit-audiorecorder
3
+ pydub
4
+ librosa
5
+ numpy
6
+ pandas
7
+ soundfile
8
+ imageio-ffmpeg