mcuo commited on
Commit
121e254
·
verified ·
1 Parent(s): c36b039

Upload 8 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt ./
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY app ./app
9
+ COPY data ./data
10
+
11
+ ENV PORT=7860
12
+ ENV ANIME_RIG_DIR=/app/data/Anime2.5DRig
13
+
14
+ EXPOSE 7860
15
+
16
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,65 @@
1
  ---
2
- title: GPT Voice
3
- emoji: 💻
4
- colorFrom: gray
5
- colorTo: gray
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: GPT Voice Avatar Test
3
+ emoji: 🎙️
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # ChatGPT Live Avatar Test
12
+
13
+ ChatGPT音声モードのタブ映像・タブ音声をブラウザの画面共有で取得し、Anime2.5DRigをオーバーレイして音量ベースの口パクを検証するHugging Face Docker Space用MVPです。
14
+
15
+ ## Spaceへの配置
16
+
17
+ 対象Space:
18
+
19
+ - リポジトリ: `https://huggingface.co/spaces/mcuo/GPT-Voice`
20
+ - 実行アプリ: `https://mcuo-gpt-voice.hf.space`
21
+
22
+ このリポジトリの内容をDocker Spaceへ配置します。Anime2.5DRig一式はSpaceリポジトリ内の次の場所へ配置してください。
23
+
24
+ ```text
25
+ data/Anime2.5DRig/index.html
26
+ data/Anime2.5DRig/lib/ag-psd.min.js
27
+ data/Anime2.5DRig/lib/rigger.js
28
+ data/Anime2.5DRig/lib/genericparts.js
29
+ ```
30
+
31
+ Dockerビルド時にこのディレクトリは `/app/data/Anime2.5DRig` へコピーされ、アプリから `/anime2.5drig/` として配信されます。つまり実行中アプリでのURLは次です。
32
+
33
+ ```text
34
+ https://mcuo-gpt-voice.hf.space/anime2.5drig/
35
+ ```
36
+
37
+ `https://huggingface.co/spaces/mcuo/GPT-Voice/data/Anime2.5DRig` はSpaceリポジトリを閲覧するための場所であり、実行アプリの静的ファイルURLではありません。
38
+
39
+ Persistent Storageの `/data/Anime2.5DRig` を使う場合は、Spaceの環境変数を `ANIME_RIG_DIR=/data/Anime2.5DRig` に設定します。
40
+
41
+ ## 操作
42
+
43
+ 1. Spaceをデスクトップ版Chromium系ブラウザで開く。
44
+ 2. 別タブでChatGPT音声モードを開始する。
45
+ 3. Spaceの「ChatGPTタブを共有」を押す。
46
+ 4. ChatGPTタブを選び、「タブの音声を共有」を有効にする。
47
+ 5. ChatGPTに応答させ、「音声トラック」と「推定口開度」を確認する。
48
+ 6. Anime2.5DRigでPSDを読み込み、AI応答中に口が動くか確認する。
49
+
50
+ 画面共有の許可はブラウザの仕様上、ユーザーが毎回選択する必要があります。共有映像と音声はサーバーへアップロードせず、ブラウザ内だけで処理します。
51
+
52
+ ## ローカル確認
53
+
54
+ ```powershell
55
+ $env:ANIME_RIG_DIR = "F:\path\to\Anime2.5DRig"
56
+ uvicorn app.main:app --host 127.0.0.1 --port 7860
57
+ ```
58
+
59
+ `http://127.0.0.1:7860/healthz` が `{"ok":true}` を返せばサーバーは起動しています。
60
+
61
+ ## 現段階の制約
62
+
63
+ - 口開度入力の検出はAnime2.5DRigの現在の日本語UI(「口」「開き」)を手掛かりにしているため、Anime2.5DRig側に明示的な外部制御APIを追加するのが次の改善点です。
64
+ - 初期検証は音量ベースで、母音別のvisemeには対応しません。
65
+ - モバイルブラウザとウィンドウ全体のシステム音声共有は対象外です。
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
app/main.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import os
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.responses import FileResponse
6
+ from fastapi.staticfiles import StaticFiles
7
+
8
+
9
+ BASE_DIR = Path(__file__).resolve().parent
10
+ STATIC_DIR = BASE_DIR / "static"
11
+ APP_DIR = BASE_DIR.parent
12
+ ANIME_RIG_DIR = Path(
13
+ os.getenv("ANIME_RIG_DIR", str(APP_DIR / "data" / "Anime2.5DRig"))
14
+ ).resolve()
15
+
16
+ app = FastAPI(title="ChatGPT Live Avatar Test")
17
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
18
+
19
+ if ANIME_RIG_DIR.is_dir():
20
+ app.mount(
21
+ "/anime2.5drig",
22
+ StaticFiles(directory=ANIME_RIG_DIR, html=True),
23
+ name="anime2.5drig",
24
+ )
25
+
26
+
27
+ @app.get("/api/status")
28
+ def status() -> dict[str, object]:
29
+ index_path = ANIME_RIG_DIR / "index.html"
30
+ return {
31
+ "ok": True,
32
+ "animeRigAvailable": index_path.is_file(),
33
+ "animeRigUrl": "/anime2.5drig/" if index_path.is_file() else None,
34
+ "expectedPath": str(ANIME_RIG_DIR),
35
+ "repositoryPath": "data/Anime2.5DRig",
36
+ }
37
+
38
+
39
+ @app.get("/healthz")
40
+ def healthz() -> dict[str, bool]:
41
+ return {"ok": True}
42
+
43
+
44
+ @app.get("/")
45
+ def index() -> FileResponse:
46
+ return FileResponse(STATIC_DIR / "index.html")
app/static/app.js ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const elements = {
2
+ start: document.querySelector("#startShare"),
3
+ stop: document.querySelector("#stopShare"),
4
+ toggleRig: document.querySelector("#toggleRig"),
5
+ video: document.querySelector("#sharedScreen"),
6
+ empty: document.querySelector("#emptyState"),
7
+ frame: document.querySelector("#rigFrame"),
8
+ shareStatus: document.querySelector("#shareStatus"),
9
+ audioStatus: document.querySelector("#audioStatus"),
10
+ rigStatus: document.querySelector("#rigStatus"),
11
+ meter: document.querySelector("#mouthMeter"),
12
+ value: document.querySelector("#mouthValue"),
13
+ sensitivity: document.querySelector("#sensitivity"),
14
+ message: document.querySelector("#message"),
15
+ };
16
+
17
+ let displayStream = null;
18
+ let audioContext = null;
19
+ let analyser = null;
20
+ let samples = null;
21
+ let animationId = null;
22
+ let smoothedLevel = 0;
23
+ let rigMouthInput = null;
24
+
25
+ function setMessage(text, error = false) {
26
+ elements.message.textContent = text;
27
+ elements.message.classList.toggle("error", error);
28
+ }
29
+
30
+ async function loadRigStatus() {
31
+ try {
32
+ const response = await fetch("/api/status");
33
+ const status = await response.json();
34
+ if (!status.animeRigAvailable) {
35
+ elements.rigStatus.textContent = "未配置";
36
+ elements.frame.hidden = true;
37
+ elements.toggleRig.disabled = true;
38
+ setMessage(`${status.expectedPath} にAnime2.5DRigのindex.htmlを配置してください。音声検証は実行できます。`);
39
+ return;
40
+ }
41
+ elements.rigStatus.textContent = "利用可能";
42
+ elements.frame.src = status.animeRigUrl;
43
+ elements.frame.hidden = false;
44
+ elements.toggleRig.disabled = false;
45
+ } catch (error) {
46
+ elements.rigStatus.textContent = "確認失敗";
47
+ setMessage(`Anime2.5DRigの状態確認に失敗しました: ${error.message}`, true);
48
+ }
49
+ }
50
+
51
+ function findRigMouthInput() {
52
+ try {
53
+ const doc = elements.frame.contentDocument;
54
+ if (!doc) return null;
55
+ const sliders = [...doc.querySelectorAll('input[type="range"]')];
56
+ return sliders.find((input) => {
57
+ const area = input.parentElement?.parentElement?.textContent || input.parentElement?.textContent || "";
58
+ return area.includes("口") && area.includes("開き");
59
+ }) || null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function updateRigMouth(level) {
66
+ if (!rigMouthInput || !rigMouthInput.isConnected) {
67
+ rigMouthInput = findRigMouthInput();
68
+ }
69
+ if (!rigMouthInput) return;
70
+ const min = Number(rigMouthInput.min || 0);
71
+ const max = Number(rigMouthInput.max || 1);
72
+ rigMouthInput.value = String(min + (max - min) * level);
73
+ rigMouthInput.dispatchEvent(new Event("input", { bubbles: true }));
74
+ }
75
+
76
+ function renderAudioLevel() {
77
+ if (!analyser || !samples) return;
78
+ analyser.getFloatTimeDomainData(samples);
79
+ let energy = 0;
80
+ for (const sample of samples) energy += sample * sample;
81
+ const rms = Math.sqrt(energy / samples.length);
82
+ const sensitivity = Number(elements.sensitivity.value);
83
+ const gated = Math.max(0, rms - 0.008);
84
+ const target = Math.min(1, gated * 18 * sensitivity);
85
+ const smoothing = target > smoothedLevel ? 0.42 : 0.14;
86
+ smoothedLevel += (target - smoothedLevel) * smoothing;
87
+
88
+ elements.meter.value = smoothedLevel;
89
+ elements.value.value = smoothedLevel.toFixed(2);
90
+ updateRigMouth(smoothedLevel);
91
+ animationId = requestAnimationFrame(renderAudioLevel);
92
+ }
93
+
94
+ async function startShare() {
95
+ if (!navigator.mediaDevices?.getDisplayMedia) {
96
+ setMessage("このブラウザは画面共有APIに対応していません。デスクトップ版Chromium系ブラウザで試してください。", true);
97
+ return;
98
+ }
99
+
100
+ try {
101
+ displayStream = await navigator.mediaDevices.getDisplayMedia({
102
+ video: { frameRate: { ideal: 30, max: 30 } },
103
+ audio: true,
104
+ preferCurrentTab: false,
105
+ selfBrowserSurface: "exclude",
106
+ surfaceSwitching: "include",
107
+ });
108
+
109
+ const videoTrack = displayStream.getVideoTracks()[0];
110
+ const audioTrack = displayStream.getAudioTracks()[0];
111
+ elements.video.srcObject = displayStream;
112
+ elements.video.style.display = "block";
113
+ elements.empty.style.display = "none";
114
+ elements.shareStatus.textContent = videoTrack?.label || "共有中";
115
+ elements.start.disabled = true;
116
+ elements.stop.disabled = false;
117
+
118
+ videoTrack?.addEventListener("ended", stopShare, { once: true });
119
+
120
+ if (!audioTrack) {
121
+ elements.audioStatus.textContent = "取得できませんでした";
122
+ setMessage("音声トラックがありません。共有選択画面でChatGPTのタブを選び、「タブの音声を共有」を有効にしてください。", true);
123
+ return;
124
+ }
125
+
126
+ elements.audioStatus.textContent = audioTrack.label || "取得済み";
127
+ audioContext = new AudioContext();
128
+ await audioContext.resume();
129
+ const audioOnlyStream = new MediaStream([audioTrack]);
130
+ const source = audioContext.createMediaStreamSource(audioOnlyStream);
131
+ analyser = audioContext.createAnalyser();
132
+ analyser.fftSize = 1024;
133
+ analyser.smoothingTimeConstant = 0;
134
+ samples = new Float32Array(analyser.fftSize);
135
+ source.connect(analyser);
136
+ setMessage("音声トラックを取得しました。ChatGPTに応答させ、推定口開度が動くことを確認してください。");
137
+ renderAudioLevel();
138
+ } catch (error) {
139
+ if (error.name === "NotAllowedError") {
140
+ setMessage("画面共有がキャンセルされました。", false);
141
+ } else {
142
+ setMessage(`画面共有を開始できませんでした: ${error.message}`, true);
143
+ }
144
+ }
145
+ }
146
+
147
+ function stopShare() {
148
+ if (animationId) cancelAnimationFrame(animationId);
149
+ animationId = null;
150
+ analyser = null;
151
+ samples = null;
152
+ smoothedLevel = 0;
153
+ updateRigMouth(0);
154
+ elements.meter.value = 0;
155
+ elements.value.value = "0.00";
156
+
157
+ if (displayStream) {
158
+ for (const track of displayStream.getTracks()) track.stop();
159
+ }
160
+ displayStream = null;
161
+ elements.video.srcObject = null;
162
+ elements.video.style.display = "none";
163
+ elements.empty.style.display = "grid";
164
+
165
+ if (audioContext) audioContext.close().catch(() => {});
166
+ audioContext = null;
167
+ elements.shareStatus.textContent = "停止済み";
168
+ elements.audioStatus.textContent = "停止済み";
169
+ elements.start.disabled = false;
170
+ elements.stop.disabled = true;
171
+ }
172
+
173
+ elements.start.addEventListener("click", startShare);
174
+ elements.stop.addEventListener("click", stopShare);
175
+ elements.toggleRig.addEventListener("click", () => {
176
+ const hidden = elements.frame.classList.toggle("hidden");
177
+ elements.toggleRig.textContent = hidden ? "アバターUIを表示" : "アバターUIを隠す";
178
+ });
179
+ elements.frame.addEventListener("load", () => {
180
+ rigMouthInput = findRigMouthInput();
181
+ elements.rigStatus.textContent = rigMouthInput ? "利用可能・口制御検出" : "利用可能・口制御未検出";
182
+ });
183
+ window.addEventListener("beforeunload", stopShare);
184
+
185
+ loadRigStatus();
186
+
app/static/index.html ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>ChatGPT Live Avatar Test</title>
7
+ <link rel="stylesheet" href="/static/styles.css">
8
+ </head>
9
+ <body>
10
+ <header class="toolbar">
11
+ <div>
12
+ <h1>ChatGPT Live Avatar Test</h1>
13
+ <p>ChatGPTのタブと「タブの音声を共有」を選択してください。</p>
14
+ </div>
15
+ <div class="actions">
16
+ <button id="startShare" class="primary">ChatGPTタブを共有</button>
17
+ <button id="stopShare" disabled>共有を停止</button>
18
+ <button id="toggleRig" disabled>アバターUIを隠す</button>
19
+ </div>
20
+ </header>
21
+
22
+ <main>
23
+ <section id="stage" class="stage">
24
+ <video id="sharedScreen" autoplay playsinline muted></video>
25
+ <div id="emptyState" class="empty-state">
26
+ <strong>共有待ち</strong>
27
+ <span>開始後、ChatGPT Liveを開いているタブを選択します。</span>
28
+ </div>
29
+ <iframe
30
+ id="rigFrame"
31
+ class="rig-frame"
32
+ title="Anime2.5DRig"
33
+ allow="microphone"
34
+ ></iframe>
35
+ </section>
36
+
37
+ <aside class="diagnostics">
38
+ <h2>検証ステータス</h2>
39
+ <dl>
40
+ <div><dt>画面共有</dt><dd id="shareStatus">未開始</dd></div>
41
+ <div><dt>音声トラック</dt><dd id="audioStatus">未確認</dd></div>
42
+ <div><dt>Anime2.5DRig</dt><dd id="rigStatus">確認中</dd></div>
43
+ </dl>
44
+
45
+ <label for="mouthMeter">推定口開度</label>
46
+ <meter id="mouthMeter" min="0" max="1" value="0"></meter>
47
+ <output id="mouthValue">0.00</output>
48
+
49
+ <label for="sensitivity">感度</label>
50
+ <input id="sensitivity" type="range" min="0.5" max="4" value="1.8" step="0.1">
51
+
52
+ <p id="message" class="message" role="status">
53
+ Anime2.5DRigが未配置でも、タブ音声の取得とメーターは検証できます。
54
+ </p>
55
+ </aside>
56
+ </main>
57
+
58
+ <script src="/static/app.js" defer></script>
59
+ </body>
60
+ </html>
app/static/styles.css ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: dark;
3
+ font-family: Inter, "Noto Sans JP", system-ui, sans-serif;
4
+ background: #090b12;
5
+ color: #f4f6ff;
6
+ }
7
+
8
+ * { box-sizing: border-box; }
9
+
10
+ body { margin: 0; min-height: 100vh; }
11
+
12
+ .toolbar {
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: space-between;
16
+ gap: 24px;
17
+ padding: 18px 24px;
18
+ border-bottom: 1px solid #2b3040;
19
+ background: #111522;
20
+ }
21
+
22
+ h1, h2, p { margin: 0; }
23
+ h1 { font-size: 20px; }
24
+ .toolbar p { margin-top: 5px; color: #aab1c5; font-size: 13px; }
25
+ .actions { display: flex; gap: 10px; flex-wrap: wrap; }
26
+
27
+ button {
28
+ border: 1px solid #424a62;
29
+ border-radius: 9px;
30
+ padding: 10px 14px;
31
+ background: #202638;
32
+ color: inherit;
33
+ cursor: pointer;
34
+ }
35
+ button.primary { background: #645cff; border-color: #7e78ff; }
36
+ button:disabled { opacity: .45; cursor: not-allowed; }
37
+
38
+ main {
39
+ display: grid;
40
+ grid-template-columns: minmax(0, 1fr) 280px;
41
+ min-height: calc(100vh - 86px);
42
+ }
43
+
44
+ .stage {
45
+ position: relative;
46
+ overflow: hidden;
47
+ min-height: 620px;
48
+ background: radial-gradient(circle at center, #20263a, #080a10 70%);
49
+ }
50
+
51
+ #sharedScreen {
52
+ width: 100%;
53
+ height: 100%;
54
+ object-fit: contain;
55
+ display: none;
56
+ }
57
+
58
+ .empty-state {
59
+ position: absolute;
60
+ inset: 0;
61
+ display: grid;
62
+ place-content: center;
63
+ gap: 8px;
64
+ text-align: center;
65
+ color: #aab1c5;
66
+ }
67
+ .empty-state strong { color: #f4f6ff; font-size: 24px; }
68
+
69
+ .rig-frame {
70
+ position: absolute;
71
+ right: 16px;
72
+ bottom: 16px;
73
+ width: min(430px, 42vw);
74
+ height: min(610px, calc(100% - 32px));
75
+ border: 1px solid rgba(255,255,255,.25);
76
+ border-radius: 14px;
77
+ background: rgba(9, 11, 18, .92);
78
+ box-shadow: 0 18px 60px rgba(0,0,0,.5);
79
+ }
80
+ .rig-frame.hidden { display: none; }
81
+
82
+ .diagnostics {
83
+ border-left: 1px solid #2b3040;
84
+ background: #111522;
85
+ padding: 22px;
86
+ }
87
+ .diagnostics h2 { font-size: 16px; margin-bottom: 18px; }
88
+ dl { margin: 0 0 24px; }
89
+ dl div { display: flex; justify-content: space-between; gap: 10px; margin: 10px 0; }
90
+ dt { color: #aab1c5; }
91
+ dd { margin: 0; text-align: right; }
92
+ .diagnostics label { display: block; margin: 18px 0 8px; color: #c9cee0; }
93
+ meter, input[type="range"] { width: 100%; }
94
+ output { display: block; margin-top: 5px; font-variant-numeric: tabular-nums; }
95
+ .message { margin-top: 24px; color: #aab1c5; line-height: 1.55; font-size: 13px; }
96
+ .message.error { color: #ff9d9d; }
97
+
98
+ @media (max-width: 820px) {
99
+ .toolbar { align-items: flex-start; flex-direction: column; }
100
+ main { grid-template-columns: 1fr; }
101
+ .diagnostics { border-left: 0; border-top: 1px solid #2b3040; }
102
+ .rig-frame { width: min(390px, calc(100% - 32px)); height: 60%; }
103
+ }
104
+
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fastapi==0.116.1
2
+ uvicorn[standard]==0.35.0
3
+