@usercase/mcp 0.2.11-next.0 → 0.2.11-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/hub.js +115 -2
- package/package.json +1 -1
- package/src/browser/open.js +3 -0
- package/src/browser/study.js +17 -2
- package/src/browser/voice.js +241 -0
package/mcp/hub.js
CHANGED
|
@@ -25,6 +25,7 @@ const ws = require("./workspace.js");
|
|
|
25
25
|
const signin = require("../src/browser/signin.js");
|
|
26
26
|
const { openRealChrome } = require("../src/browser/real-chrome.js");
|
|
27
27
|
const study = require("../src/browser/study.js");
|
|
28
|
+
const voice = require("../src/browser/voice.js");
|
|
28
29
|
const report = require("./report.js");
|
|
29
30
|
|
|
30
31
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -49,7 +50,9 @@ function trace(rec, o) {
|
|
|
49
50
|
fs.appendFileSync(TRACE, JSON.stringify({ at: Date.now(), person: rec && rec.person ? rec.person.id : null, ...o }) + "\n");
|
|
50
51
|
} catch {}
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
/** 음성 설문이 끝나고 위젯이 간 곳 — 여기면 마지막 말은 인사다 */
|
|
54
|
+
const VOICE_OVER = new Set(["testing", "next", "submit", "loading", "done", "complete", "idle"]);
|
|
55
|
+
const STUDY_ACTIVE = new Set(["consent", "mic", "loading", "testing", "timeout", "survey", "ai", "next", "submit"]);
|
|
53
56
|
|
|
54
57
|
/**
|
|
55
58
|
* 가상 사용자 표시 이름. 서버가 이 이름으로 가상 사용자 답을 진짜 답과 갈라 센다
|
|
@@ -459,7 +462,7 @@ class Hub {
|
|
|
459
462
|
const sdk = await rec.visit.begin();
|
|
460
463
|
// 리서치 참여 — 초대 수락·녹화 동의까지는 참여하기로 한 사람이 늘 하는 것이라 도구가 한다
|
|
461
464
|
if (research) {
|
|
462
|
-
rec.study = { research, answers: [], tasks: [], skipped: [], lastTask: null, over: false, surveyAt: null };
|
|
465
|
+
rec.study = { research, answers: [], tasks: [], skipped: [], lastTask: null, over: false, surveyAt: null, heardFrom: 0 };
|
|
463
466
|
const j = await study.join(page);
|
|
464
467
|
if (!j.ok) {
|
|
465
468
|
await this.finish(rec, { study: { research, failed: j.why } });
|
|
@@ -482,6 +485,11 @@ class Hub {
|
|
|
482
485
|
"You agreed to take part, so you stay with the study until its tasks are done — you do not leave the site in the middle of it over a small thing. The study may ask you a few questions along the way; answer them with answer_card.",
|
|
483
486
|
"If you get stuck on a task, do what a person does: keep looking for it — try other menus and pages, go back, scroll through lists, try what you have not tried yet. Studies usually offer a way to give up on a task after a while; when the panel offers it and you would give up, take it and go on to the next task. Leave in the middle only if you really would — something about you or your moment makes you stop.",
|
|
484
487
|
);
|
|
488
|
+
// AI 모더레이터 리서치 — 마이크를 받아 갔다(voice.js). 목소리가 말을 걸고, 방문자는 소리 내어 답한다
|
|
489
|
+
if (research && (await page.evaluate(() => !!(window.__ucVoice && window.__ucVoice.used())).catch(() => false)))
|
|
490
|
+
lines.push(
|
|
491
|
+
"This study has a voice: you allowed your microphone, and a voice may talk to you during the study (you see its words as a caption). When it asks you something, you answer out loud — with answer_card.",
|
|
492
|
+
);
|
|
485
493
|
if (!sdk.ready) lines.push("(note for whoever runs this: the Usercase SDK did not start on this page — this visit may not be recorded)");
|
|
486
494
|
lines.push("", await this.screen(rec));
|
|
487
495
|
return { id, text: lines.join("\n") };
|
|
@@ -558,6 +566,17 @@ class Hub {
|
|
|
558
566
|
await study.waitState(page, ["testing", "survey", "submit", "done"], 15000);
|
|
559
567
|
continue;
|
|
560
568
|
}
|
|
569
|
+
// AI 모더레이터가 소리로 묻는 중(음성 후속설문). 물음이 들리면 방문자가 소리 내어 답한다
|
|
570
|
+
if (s.state === "ai") {
|
|
571
|
+
const q = await this.voiceQuestion(rec);
|
|
572
|
+
if (q) {
|
|
573
|
+
rec.studyPending = { kind: "voice", q, task: st.lastTask };
|
|
574
|
+
return { card: renderVoiceQuestion(q, st.lastTask) };
|
|
575
|
+
}
|
|
576
|
+
s = study.isSurveyUrl(page.url()) ? null : await study.readStudy(page);
|
|
577
|
+
if (!s || s.state === "ai") break;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
561
580
|
if (s.state === "submit" || s.state === "loading") {
|
|
562
581
|
await study.waitState(page, ["done", "complete", "testing", "survey", "next", "idle", "invite"], 120000);
|
|
563
582
|
continue;
|
|
@@ -581,6 +600,14 @@ class Hub {
|
|
|
581
600
|
}
|
|
582
601
|
st.lastTaskKey = key;
|
|
583
602
|
st.lastTask = s.task.title;
|
|
603
|
+
// 과제 중에 모더레이터가 한 말(음성 리서치) — 사람도 듣는다
|
|
604
|
+
// 아직 말하는 중인 줄은 다음 화면에서 — 자막이 늘어나며 이어지는 동안 읽으면 말이 반쯤 잘린다
|
|
605
|
+
const h = await voice.heard(page, st.heardFrom);
|
|
606
|
+
if (h && h.closed) {
|
|
607
|
+
st.heardFrom += h.closed;
|
|
608
|
+
const said = `(The study's voice said: ${h.lines.slice(0, h.closed).map((l) => `"${l.text}"`).join(" ")})`;
|
|
609
|
+
note = note ? `${note}\n${said}` : said;
|
|
610
|
+
}
|
|
584
611
|
// 패널에는 지금 과제에 머문 시간이 보인다(SDK 알약의 타이머) — 방문자도 같은 걸 본다
|
|
585
612
|
const onTaskMs = Date.now() - rec.startedAt - st.tasks[st.tasks.length - 1].at;
|
|
586
613
|
st.canSkip = !!s.task.skip;
|
|
@@ -602,6 +629,55 @@ class Hub {
|
|
|
602
629
|
return null;
|
|
603
630
|
}
|
|
604
631
|
|
|
632
|
+
/**
|
|
633
|
+
* 모더레이터가 소리로 묻는 것(자막). 아직 답하지 않은 새 말이 들릴 때까지 기다린다 — 말이 이어지면 다 들은 뒤.
|
|
634
|
+
* 위젯이 묻는 상태(ai)를 벗어나거나 한참 조용하면 null
|
|
635
|
+
*/
|
|
636
|
+
async voiceQuestion(rec, ms = 25000) {
|
|
637
|
+
const st = rec.study;
|
|
638
|
+
const page = rec.page;
|
|
639
|
+
let h = null;
|
|
640
|
+
for (let t = 0; t < ms; t += 300) {
|
|
641
|
+
h = await voice.heard(page, st.heardFrom);
|
|
642
|
+
if (!h) return null;
|
|
643
|
+
// 설문 전에(과제 중에) 한 말은 묻는 말이 아니다 — 지나간다. 안 그러면 인사에 답하고 답이 한 칸씩 밀린다(11/04 실험실)
|
|
644
|
+
let skip = 0;
|
|
645
|
+
while (skip < h.lines.length && h.lines[skip].state !== "ai" && (skip < h.closed || !h.speaking)) skip++;
|
|
646
|
+
if (skip) {
|
|
647
|
+
if (TRACE) trace(rec, { ev: "voice_skip", lines: h.lines.slice(0, skip).map((l) => `${l.state}: ${l.text.slice(0, 80)}`) });
|
|
648
|
+
st.heardFrom += skip;
|
|
649
|
+
h = await voice.heard(page, st.heardFrom);
|
|
650
|
+
if (!h) return null;
|
|
651
|
+
}
|
|
652
|
+
if (h.lines.length && h.lines[0].state === "ai") break;
|
|
653
|
+
const s = await study.readStudy(page);
|
|
654
|
+
if (!s || s.state !== "ai") return null;
|
|
655
|
+
await sleep(300);
|
|
656
|
+
}
|
|
657
|
+
if (!h || !h.lines.length || h.lines[0].state !== "ai") return null;
|
|
658
|
+
// 말을 다 들을 때까지(자막이 내려갈 때까지). 서버는 질문을 말하기 시작할 때부터 답을 기다리니 너무 오래는 못 듣는다
|
|
659
|
+
for (let t = 0; t < 10000 && h.speaking; t += 300) {
|
|
660
|
+
await sleep(300);
|
|
661
|
+
h = (await voice.heard(page, st.heardFrom)) || h;
|
|
662
|
+
}
|
|
663
|
+
// 마지막 인사("고마워요")로 설문이 끝나면 위젯이 곧 묻는 상태를 벗어난다 — 그건 답할 말이 아니다
|
|
664
|
+
// 끝났다고 보는 건 위젯이 분명히 다음으로 간 때만 — 못 읽었거나 잠깐 바뀌는 중이면 묻는 말로 둔다.
|
|
665
|
+
// "아니면 끝" 으로 봤더니 진짜 질문을 넘겨 서버가 답 없이 다음으로 갔다(11/04 실험실 p2132)
|
|
666
|
+
let after = null;
|
|
667
|
+
for (let t = 0; t < 1500; t += 300) {
|
|
668
|
+
await sleep(300);
|
|
669
|
+
after = await study.readStudy(page);
|
|
670
|
+
if (after && VOICE_OVER.has(after.state)) break;
|
|
671
|
+
}
|
|
672
|
+
const text = h.lines.map((l) => l.text).join(" ");
|
|
673
|
+
if (TRACE) trace(rec, { ev: "voice_q", text: text.slice(0, 120), after: after ? after.state : null });
|
|
674
|
+
if (after && VOICE_OVER.has(after.state)) {
|
|
675
|
+
st.heardFrom = h.total;
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
return { text, upTo: h.total };
|
|
679
|
+
}
|
|
680
|
+
|
|
605
681
|
/** 과제 질문·시간 초과 창에 답한다. 참여하기로 한 사람이라 닫지 않는다 — 비워 두고 넘기는 건 된다(필수가 아니면) */
|
|
606
682
|
async studyAnswer(rec, decision) {
|
|
607
683
|
const p = rec.studyPending;
|
|
@@ -610,6 +686,31 @@ class Hub {
|
|
|
610
686
|
// 손이 고른 답으로 가는 시간. 읽고 정하는 건 AI 가 이미 했다(11/02: 1.2~3.7초를 또 기다렸다)
|
|
611
687
|
await sleep(300 + Math.random() * 500);
|
|
612
688
|
let said;
|
|
689
|
+
if (p.kind === "voice") {
|
|
690
|
+
// 소리 내어 답한다. 모더레이터가 말을 마칠 때까지 기다린다 — 끼어들지 않는다
|
|
691
|
+
st.heardFrom = p.q.upTo;
|
|
692
|
+
const words = decision.action === "other" ? String(decision.otherText || "").trim() : "";
|
|
693
|
+
for (let t = 0; t < 20000; t += 300) {
|
|
694
|
+
const h = await voice.heard(page, st.heardFrom);
|
|
695
|
+
if (!h || !h.speaking) break;
|
|
696
|
+
await sleep(300);
|
|
697
|
+
}
|
|
698
|
+
let answer = null;
|
|
699
|
+
if (words) {
|
|
700
|
+
try {
|
|
701
|
+
await voice.speak(page, words, { seed: rec.person.id });
|
|
702
|
+
answer = words;
|
|
703
|
+
said = `You said: "${words}"`;
|
|
704
|
+
} catch (e) {
|
|
705
|
+
said = `You tried to answer out loud, but no sound came out (${String(e.message).split("\n")[0]}).`;
|
|
706
|
+
}
|
|
707
|
+
} else said = "You stayed quiet and did not answer.";
|
|
708
|
+
st.answers.push({ task: p.task, question: p.q.text, answer, why: decision.why, spoken: true });
|
|
709
|
+
rec.studyPending = null;
|
|
710
|
+
rec.visit.history.push(`The study's voice asked "${p.q.text}" — ${said}`);
|
|
711
|
+
await sleep(600);
|
|
712
|
+
return [said, "", await this.screen(rec)].join("\n");
|
|
713
|
+
}
|
|
613
714
|
if (p.kind === "timeout") {
|
|
614
715
|
const skip = decision.action === "choose" && decision.choiceIndex === 1;
|
|
615
716
|
await study.press(page, skip ? "timeoutSkip" : "keepGoing").catch(() => {});
|
|
@@ -1062,6 +1163,18 @@ function renderStudyQuestion(q, task) {
|
|
|
1062
1163
|
return L.join("\n");
|
|
1063
1164
|
}
|
|
1064
1165
|
|
|
1166
|
+
/** AI 모더레이터가 소리로 묻는다(음성 리서치) */
|
|
1167
|
+
function renderVoiceQuestion(q, task) {
|
|
1168
|
+
return [
|
|
1169
|
+
"## The study's voice is asking you something, out loud",
|
|
1170
|
+
`This is part of the study you agreed to take part in${task ? ` (it comes after the task "${task}")` : ""}. A voice speaks to you, and its words show as a caption:`,
|
|
1171
|
+
"",
|
|
1172
|
+
`"${q.text}"`,
|
|
1173
|
+
"",
|
|
1174
|
+
"Answer out loud, as yourself, with answer_card: bothered true, action other, and in other_text exactly what you say — spoken words in the language of the voice, the way this person talks, in one go (a short pause ends your answer). It waits for you only a few seconds, so answer right away. Close only if you would stay quiet.",
|
|
1175
|
+
].join("\n");
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1065
1178
|
/** 과제에 시간이 오래 걸리면 패널이 묻는다 */
|
|
1066
1179
|
function renderStudyTimeout(t, task) {
|
|
1067
1180
|
return [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usercase/mcp",
|
|
3
|
-
"version": "0.2.11-next.
|
|
3
|
+
"version": "0.2.11-next.1",
|
|
4
4
|
"description": "Virtual users that browse your website and tell you what they thought — an MCP server for Claude Code",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://usercase.io",
|
package/src/browser/open.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const { markAgent, withAgentUA } = require("./card.js");
|
|
14
|
+
const { installVoice } = require("./voice.js");
|
|
14
15
|
|
|
15
16
|
/** HTTPS 사이트에서 localhost SDK·소켓을 부르려면 사설망 제한을 꺼야 한다. 로컬 SDK 로 돌릴 때만 필요하다 */
|
|
16
17
|
const LOCAL_NETWORK_ARGS = ["--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults"];
|
|
@@ -54,6 +55,8 @@ async function openVisit(browser, devices, { agent, site, guard, device = "deskt
|
|
|
54
55
|
await guard.apply(ctx, { onOffsite, onWrite, onExternal });
|
|
55
56
|
// **반드시 guard 뒤.** markAgent 의 비밀값 route 가 마지막에 걸려야 먼저 돈다
|
|
56
57
|
await markAgent(ctx, agent, { siteOrigin: site, persona, research, debugCards, hideDevOverlay: guard.local });
|
|
58
|
+
// 리서치 참여자는 목소리를 가진다 — AI 모더레이터 리서치가 마이크를 달라면 우리 목소리를 준다(voice.js)
|
|
59
|
+
if (research) await installVoice(ctx);
|
|
57
60
|
return ctx;
|
|
58
61
|
}
|
|
59
62
|
|
package/src/browser/study.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* 질문에 무엇을 답하는지, 포기할지는 방문자가 정한다.
|
|
9
9
|
*
|
|
10
10
|
* 화면 구성(earningbirds-core layouts/Widget.ts · components/frost/*):
|
|
11
|
-
* #eb-pill[data-state] — idle·invite·consent·loading·testing·timeout·survey·next·submit·done …
|
|
11
|
+
* #eb-pill[data-state] — idle·invite·consent·mic·loading·testing·timeout·survey·ai·next·submit·done …
|
|
12
|
+
* mic: #eb-mic-start(음성 리서치의 마이크 확인) · ai: AI 모더레이터가 소리로 묻는 중(자막은 voice.js 가 읽는다)
|
|
12
13
|
* #eb-frost(.eb-visible 이면 열림) > #eb-frost-body
|
|
13
14
|
* testing: .eb-frost-testing .eb-gf-section(진행) .eb-gf-flat-title(과제) .eb-gf-flat-sub(설명) #eb-task-lefttime-text #eb-test-skip
|
|
14
15
|
* timeout: .eb-frost-timeout #eb-timeout-continue #eb-timeout-skip
|
|
@@ -152,7 +153,21 @@ async function join(page, { inviteMs = 25000 } = {}) {
|
|
|
152
153
|
await sleep(1500 + Math.random() * 2500);
|
|
153
154
|
await page.locator("#eb-plugin #eb-consent-agree").click({ timeout: 5000 }).catch(() => {});
|
|
154
155
|
}
|
|
155
|
-
|
|
156
|
+
let s = await waitState(page, ["mic", "testing", "survey", "done"], 40000);
|
|
157
|
+
// AI 모더레이터(음성) 리서치는 동의 뒤 마이크 확인 화면이 있다. 마이크는 voice.js 가 준 우리 목소리다 —
|
|
158
|
+
// 연결됐다고 뜨면 사람처럼 잠깐 보고 시작을 누른다. Gemini 를 여는 데 시간이 걸려 시작까지 넉넉히 기다린다
|
|
159
|
+
if (s && s.state === "mic") {
|
|
160
|
+
const ready = await page
|
|
161
|
+
.locator("#eb-plugin #eb-mic-start:not([disabled])")
|
|
162
|
+
.first()
|
|
163
|
+
.waitFor({ timeout: 15000 })
|
|
164
|
+
.then(() => true)
|
|
165
|
+
.catch(() => false);
|
|
166
|
+
if (!ready) return { ok: false, why: "the study's microphone check did not accept the virtual user's microphone" };
|
|
167
|
+
await sleep(1200 + Math.random() * 1800);
|
|
168
|
+
await page.locator("#eb-plugin #eb-mic-start").first().click({ timeout: 5000 }).catch(() => {});
|
|
169
|
+
s = await waitState(page, ["testing", "survey", "done"], 60000);
|
|
170
|
+
}
|
|
156
171
|
if (!s || s.state !== "testing") return { ok: false, why: `the research did not start (the study panel is at "${s ? s.state || "?" : "nothing"}")` };
|
|
157
172
|
return { ok: true, study: s };
|
|
158
173
|
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 가상 사용자의 목소리 — AI 모더레이터(음성) 리서치에 참여할 때.
|
|
4
|
+
*
|
|
5
|
+
* 사람 참여자와 같은 길로 간다: SDK 는 마이크를 받고, 그 마이크로 들어온 소리를 Gemini·서버 음성 인식이 듣는다.
|
|
6
|
+
* - 귀: 모더레이터가 하는 말은 위젯 자막(.eb-ai-caption-text, core components/widget/Widget.ts)으로 뜬다. 그것을 읽는다
|
|
7
|
+
* - 입: 페이지의 마이크 요청(getUserMedia)이 진짜 마이크 대신 우리가 만든 오디오 줄(MediaStreamDestination)을 받는다.
|
|
8
|
+
* 할 말은 이 컴퓨터에 깔린 음성(Windows 음성 · macOS say · espeak)으로 wav 를 만들어 그 줄로 튼다 — 바깥 서비스·비용 없음
|
|
9
|
+
*
|
|
10
|
+
* 리서치 참여 방문에만 건다(open.js). 음성을 안 쓰는 리서치는 마이크를 부르지 않으니 아무 일도 없다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const os = require("os");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const { execFile } = require("child_process");
|
|
17
|
+
|
|
18
|
+
/** 페이지가 뜨기 전에 심는다. 마이크·권한·장치 목록을 우리 오디오 줄로 바꾸고, 모더레이터 자막을 적어 둔다 */
|
|
19
|
+
const PAGE_SCRIPT = `(() => {
|
|
20
|
+
if (window.__ucVoice) return;
|
|
21
|
+
const md = navigator.mediaDevices;
|
|
22
|
+
if (!md || !md.getUserMedia) return;
|
|
23
|
+
let ac = null;
|
|
24
|
+
let dest = null;
|
|
25
|
+
const line = () => {
|
|
26
|
+
if (!ac) {
|
|
27
|
+
ac = new (window.AudioContext || window.webkitAudioContext)();
|
|
28
|
+
dest = ac.createMediaStreamDestination();
|
|
29
|
+
}
|
|
30
|
+
if (ac.state === "suspended") ac.resume().catch(() => {});
|
|
31
|
+
return dest;
|
|
32
|
+
};
|
|
33
|
+
const MIC = { deviceId: "usercase-voice", groupId: "usercase-voice", kind: "audioinput", label: "Microphone" };
|
|
34
|
+
MIC.toJSON = () => ({ deviceId: MIC.deviceId, groupId: MIC.groupId, kind: MIC.kind, label: MIC.label });
|
|
35
|
+
const enumerate = md.enumerateDevices ? md.enumerateDevices.bind(md) : async () => [];
|
|
36
|
+
md.enumerateDevices = async () => {
|
|
37
|
+
const list = await enumerate().catch(() => []);
|
|
38
|
+
return [MIC, ...list.filter((d) => d.kind !== "audioinput")];
|
|
39
|
+
};
|
|
40
|
+
const gum = md.getUserMedia.bind(md);
|
|
41
|
+
md.getUserMedia = async (c) => {
|
|
42
|
+
if (c && c.audio && !c.video) {
|
|
43
|
+
const d = line();
|
|
44
|
+
// 받는 쪽이 트랙을 멈춰도(stop) 원본 줄은 살아 있게 복제해 준다
|
|
45
|
+
return new MediaStream(d.stream.getAudioTracks().map((t) => t.clone()));
|
|
46
|
+
}
|
|
47
|
+
return gum(c);
|
|
48
|
+
};
|
|
49
|
+
if (navigator.permissions && navigator.permissions.query) {
|
|
50
|
+
const query = navigator.permissions.query.bind(navigator.permissions);
|
|
51
|
+
navigator.permissions.query = (p) =>
|
|
52
|
+
p && p.name === "microphone"
|
|
53
|
+
? Promise.resolve({ name: "microphone", state: "granted", onchange: null, addEventListener() {}, removeEventListener() {} })
|
|
54
|
+
: query(p);
|
|
55
|
+
}
|
|
56
|
+
// 모더레이터 자막. 보이는 동안이 말하는 동안이다. 글이 늘어나며 이어지면(스트리밍 자막) 한 줄로 친다
|
|
57
|
+
const heard = [];
|
|
58
|
+
let shownText = "";
|
|
59
|
+
let speaking = false;
|
|
60
|
+
setInterval(() => {
|
|
61
|
+
const host = document.getElementById("eb-plugin");
|
|
62
|
+
const root = (host && host.shadowRoot) || document;
|
|
63
|
+
const box = root.querySelector(".eb-ai-caption.eb-ai-caption-visible .eb-ai-caption-text");
|
|
64
|
+
const t = box ? (box.innerText || box.textContent || "").replace(/\\s+/g, " ").trim() : "";
|
|
65
|
+
speaking = !!t;
|
|
66
|
+
if (!t || t === shownText) return;
|
|
67
|
+
// 그 말이 나올 때 위젯 상태 — ai 면 소리로 묻는 설문(답을 기다린다), 그 밖(과제 중)은 그냥 하는 말이다
|
|
68
|
+
const pill = root.querySelector("#eb-pill");
|
|
69
|
+
const state = (pill && pill.getAttribute("data-state")) || "";
|
|
70
|
+
const last = heard[heard.length - 1];
|
|
71
|
+
if (last && shownText && t.startsWith(shownText)) {
|
|
72
|
+
last.text = t;
|
|
73
|
+
last.at = Date.now();
|
|
74
|
+
} else heard.push({ n: heard.length, text: t, at: Date.now(), state });
|
|
75
|
+
shownText = t;
|
|
76
|
+
}, 200);
|
|
77
|
+
window.__ucVoice = {
|
|
78
|
+
used: () => !!ac,
|
|
79
|
+
// closed: 다 말한 줄 수(from 부터). 말하는 중인 마지막 줄은 아직 늘어날 수 있다
|
|
80
|
+
heard: (from) => {
|
|
81
|
+
const lines = heard.slice(from || 0);
|
|
82
|
+
return { speaking, lines, total: heard.length, closed: speaking && lines.length ? lines.length - 1 : lines.length };
|
|
83
|
+
},
|
|
84
|
+
say: async (b64) => {
|
|
85
|
+
const d = line();
|
|
86
|
+
if (ac.state !== "running") await ac.resume().catch(() => {});
|
|
87
|
+
const bin = atob(b64);
|
|
88
|
+
const buf = new Uint8Array(bin.length);
|
|
89
|
+
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
|
90
|
+
const audio = await ac.decodeAudioData(buf.buffer);
|
|
91
|
+
const src = ac.createBufferSource();
|
|
92
|
+
src.buffer = audio;
|
|
93
|
+
src.connect(d);
|
|
94
|
+
await new Promise((r) => {
|
|
95
|
+
src.onended = r;
|
|
96
|
+
src.start();
|
|
97
|
+
});
|
|
98
|
+
return audio.duration;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
})();`;
|
|
102
|
+
|
|
103
|
+
/** 방문 컨텍스트에 목소리를 건다. 페이지보다 먼저 */
|
|
104
|
+
async function installVoice(ctx) {
|
|
105
|
+
await ctx.addInitScript({ content: PAGE_SCRIPT });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 글의 언어. 이 컴퓨터 음성을 고르는 데만 쓴다 */
|
|
109
|
+
function langOf(text) {
|
|
110
|
+
const s = String(text || "");
|
|
111
|
+
if (/[가-힣]/.test(s)) return "ko";
|
|
112
|
+
if (/[-ヿ]/.test(s)) return "ja";
|
|
113
|
+
if (/[一-鿿]/.test(s)) return "zh";
|
|
114
|
+
if (/[Ѐ-ӿ]/.test(s)) return "ru";
|
|
115
|
+
return "en";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const run = (cmd, args, ms = 30000) =>
|
|
119
|
+
new Promise((resolve, reject) =>
|
|
120
|
+
execFile(cmd, args, { timeout: ms, windowsHide: true, maxBuffer: 4 << 20 }, (err, stdout, stderr) =>
|
|
121
|
+
err ? reject(new Error(String(stderr || err.message).trim().split("\n")[0])) : resolve(String(stdout)),
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/** 사람마다 같은 목소리 — 같은 언어 음성이 여럿이면 사람 번호로 하나를 고정해 고른다 */
|
|
126
|
+
const pickIndex = (seed, n) => {
|
|
127
|
+
let h = 0;
|
|
128
|
+
for (const c of String(seed || "")) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
129
|
+
return n ? h % n : 0;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
async function synthWindows(textFile, out, lang, seed) {
|
|
133
|
+
const ps = [
|
|
134
|
+
"$ErrorActionPreference='Stop'",
|
|
135
|
+
"Add-Type -AssemblyName System.Speech",
|
|
136
|
+
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer",
|
|
137
|
+
`$v = @($s.GetInstalledVoices() | Where-Object { $_.Enabled -and $_.VoiceInfo.Culture.Name -like '${lang}*' })`,
|
|
138
|
+
`if ($v.Count -gt 0) { $s.SelectVoice($v[${pickIndex(seed, 97)} % $v.Count].VoiceInfo.Name) }`,
|
|
139
|
+
`$s.SetOutputToWaveFile('${out.replace(/'/g, "''")}')`,
|
|
140
|
+
`$s.Speak([IO.File]::ReadAllText('${textFile.replace(/'/g, "''")}', [Text.Encoding]::UTF8))`,
|
|
141
|
+
"$s.Dispose()",
|
|
142
|
+
].join("; ");
|
|
143
|
+
await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function synthMac(textFile, out, lang, seed) {
|
|
147
|
+
// say -v '?' : "Yuna ko_KR # 안녕하세요" — 그 언어 음성 이름들
|
|
148
|
+
let voice = null;
|
|
149
|
+
try {
|
|
150
|
+
const names = (await run("say", ["-v", "?"]))
|
|
151
|
+
.split("\n")
|
|
152
|
+
.map((l) => l.match(/^(.+?)\s+([a-z]{2})[_-][A-Z]{2}\s+#/))
|
|
153
|
+
.filter((m) => m && m[2] === lang)
|
|
154
|
+
.map((m) => m[1].trim());
|
|
155
|
+
if (names.length) voice = names[pickIndex(seed, names.length)];
|
|
156
|
+
} catch {
|
|
157
|
+
// 목록을 못 읽으면 기본 음성
|
|
158
|
+
}
|
|
159
|
+
await run("say", [...(voice ? ["-v", voice] : []), "--file-format=WAVE", "--data-format=LEI16@22050", "-o", out, "-f", textFile]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function synthLinux(textFile, out, lang) {
|
|
163
|
+
for (const cmd of ["espeak-ng", "espeak"]) {
|
|
164
|
+
try {
|
|
165
|
+
await run(cmd, ["-v", lang, "-w", out, "-f", textFile]);
|
|
166
|
+
return;
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (!/ENOENT|not found/i.test(e.message)) throw e;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw new Error("no speech voice on this computer (install espeak-ng)");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 문장 사이 쉼을 줄인다. 서버 음성 인식은 0.7초 조용하면 답이 끝난 것으로 친다(server voiceConfig speechEndTimeoutSec) —
|
|
176
|
+
* 이 컴퓨터 음성은 문장마다 그보다 길게 쉬어서 두 문장째가 잘린다. 16비트 PCM wav 만 고친다(그 밖은 그대로)
|
|
177
|
+
*/
|
|
178
|
+
function tightenPauses(wav, { maxGapSec = 0.3, floor = 400 } = {}) {
|
|
179
|
+
if (wav.length < 44 || wav.toString("ascii", 0, 4) !== "RIFF" || wav.toString("ascii", 8, 12) !== "WAVE") return wav;
|
|
180
|
+
let off = 12;
|
|
181
|
+
let fmt = null;
|
|
182
|
+
while (off + 8 <= wav.length) {
|
|
183
|
+
const id = wav.toString("ascii", off, off + 4);
|
|
184
|
+
const size = wav.readUInt32LE(off + 4);
|
|
185
|
+
if (id === "fmt ") fmt = { channels: wav.readUInt16LE(off + 10), rate: wav.readUInt32LE(off + 12), bits: wav.readUInt16LE(off + 22) };
|
|
186
|
+
if (id === "data") {
|
|
187
|
+
if (!fmt || fmt.bits !== 16) return wav;
|
|
188
|
+
const frame = 2 * fmt.channels;
|
|
189
|
+
const start = off + 8;
|
|
190
|
+
const end = Math.min(wav.length, start + size);
|
|
191
|
+
const maxGap = Math.round(maxGapSec * fmt.rate) * frame;
|
|
192
|
+
const out = [];
|
|
193
|
+
let run = 0; // 이어진 조용한 바이트
|
|
194
|
+
for (let i = start; i + frame <= end; i += frame) {
|
|
195
|
+
let loud = false;
|
|
196
|
+
for (let c = 0; c < fmt.channels; c++) if (Math.abs(wav.readInt16LE(i + 2 * c)) > floor) loud = true;
|
|
197
|
+
run = loud ? 0 : run + frame;
|
|
198
|
+
if (run <= maxGap) out.push(wav.subarray(i, i + frame));
|
|
199
|
+
}
|
|
200
|
+
const data = Buffer.concat(out);
|
|
201
|
+
const head = Buffer.from(wav.subarray(0, start));
|
|
202
|
+
head.writeUInt32LE(data.length, off + 4);
|
|
203
|
+
head.writeUInt32LE(head.length - 8 + data.length, 4);
|
|
204
|
+
return Buffer.concat([head, data]);
|
|
205
|
+
}
|
|
206
|
+
off += 8 + size + (size & 1);
|
|
207
|
+
}
|
|
208
|
+
return wav;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 할 말을 이 컴퓨터 음성으로 wav 로. @returns Buffer */
|
|
212
|
+
async function synth(text, { seed } = {}) {
|
|
213
|
+
const lang = langOf(text);
|
|
214
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "usercase-voice-"));
|
|
215
|
+
const textFile = path.join(dir, "say.txt");
|
|
216
|
+
const out = path.join(dir, "say.wav");
|
|
217
|
+
try {
|
|
218
|
+
fs.writeFileSync(textFile, String(text), "utf8");
|
|
219
|
+
if (process.platform === "win32") await synthWindows(textFile, out, lang, seed);
|
|
220
|
+
else if (process.platform === "darwin") await synthMac(textFile, out, lang, seed);
|
|
221
|
+
else await synthLinux(textFile, out, lang);
|
|
222
|
+
const wav = fs.readFileSync(out);
|
|
223
|
+
if (wav.length < 100) throw new Error("the voice made no sound");
|
|
224
|
+
return tightenPauses(wav);
|
|
225
|
+
} finally {
|
|
226
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 소리 내어 말한다 — 끝까지 틀고 돌아온다. @returns 말한 초 */
|
|
231
|
+
async function speak(page, text, opts = {}) {
|
|
232
|
+
const wav = await synth(text, opts);
|
|
233
|
+
return page.evaluate((b64) => window.__ucVoice.say(b64), wav.toString("base64"));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 모더레이터가 한 말(자막). from 번째 줄부터. 페이지가 아직 안 심겼으면 null */
|
|
237
|
+
async function heard(page, from = 0) {
|
|
238
|
+
return page.evaluate((f) => (window.__ucVoice ? window.__ucVoice.heard(f) : null), from).catch(() => null);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = { installVoice, speak, synth, heard, langOf, tightenPauses, PAGE_SCRIPT };
|