@@ -26022,8 +26022,8 @@ cr.getObjectRefTable = function () { return [
|
||||
cr.plugins_.Function,
|
||||
cr.plugins_.SenaPlugin,
|
||||
cr.plugins_.Sprite,
|
||||
cr.plugins_.Text,
|
||||
cr.plugins_.Touch,
|
||||
cr.plugins_.Text,
|
||||
cr.behaviors.Rex_MoveTo,
|
||||
cr.behaviors.Fade,
|
||||
cr.behaviors.DragnDrop,
|
||||
@@ -26103,6 +26103,7 @@ cr.getObjectRefTable = function () { return [
|
||||
cr.system_object.prototype.exps.len,
|
||||
cr.plugins_.SenaPlugin.prototype.acts.Finish,
|
||||
cr.plugins_.SenaPlugin.prototype.cnds.OnCorrect,
|
||||
cr.plugins_.Sprite.prototype.acts.SetScale,
|
||||
cr.plugins_.Audio.prototype.acts.SetPaused,
|
||||
cr.plugins_.SenaPlugin.prototype.cnds.OnWrong,
|
||||
cr.system_object.prototype.cnds.EveryTick,
|
||||
@@ -26110,11 +26111,12 @@ cr.getObjectRefTable = function () { return [
|
||||
cr.plugins_.SenaPlugin.prototype.exps.getElapsedTime,
|
||||
cr.system_object.prototype.exps["int"],
|
||||
cr.system_object.prototype.cnds.TriggerOnce,
|
||||
cr.behaviors.Sin.prototype.acts.SetActive,
|
||||
cr.behaviors.Fade.prototype.acts.SetFadeOutTime,
|
||||
cr.behaviors.Fade.prototype.acts.RestartFade,
|
||||
cr.plugins_.Audio.prototype.acts.SetMuted,
|
||||
cr.system_object.prototype.acts.SetLayerVisible,
|
||||
cr.plugins_.Text.prototype.acts.SetVisible,
|
||||
cr.plugins_.SenaPlugin.prototype.acts.ResumeGame,
|
||||
cr.system_object.prototype.cnds.LayerVisible,
|
||||
cr.behaviors.Fade.prototype.acts.RestartFade,
|
||||
cr.behaviors.Sin.prototype.acts.SetActive
|
||||
cr.system_object.prototype.cnds.LayerVisible
|
||||
];};
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 60 KiB |
BIN
SQ_Word_Hint-Image/images/timeup-sheet0.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 47 KiB |
@@ -81,7 +81,7 @@
|
||||
|
||||
<!-- Construct 2 exported games require jQuery. -->
|
||||
<script src="jquery-3.4.1.min.js"></script>
|
||||
|
||||
<script src="tdv_sdk.js"></script>
|
||||
<script src="sena_sdk.js"></script>
|
||||
|
||||
|
||||
|
||||
BIN
SQ_Word_Hint-Image/media/ring.ogg
Normal file
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 1772438376,
|
||||
"version": 1772540567,
|
||||
"fileList": [
|
||||
"data.js",
|
||||
"c2runtime.js",
|
||||
@@ -22,6 +22,7 @@
|
||||
"images/black-sheet0.png",
|
||||
"images/hint-sheet0.png",
|
||||
"images/imageframe-sheet0.png",
|
||||
"images/timeup-sheet0.png",
|
||||
"media/alert-234711.ogg",
|
||||
"media/bubble-pop-389501.ogg",
|
||||
"media/button-124476.ogg",
|
||||
@@ -33,6 +34,7 @@
|
||||
"media/card_drag.ogg",
|
||||
"media/card_flips.ogg",
|
||||
"media/card_swipe.ogg",
|
||||
"media/ring.ogg",
|
||||
"icon-16.png",
|
||||
"icon-32.png",
|
||||
"icon-114.png",
|
||||
|
||||
@@ -51,6 +51,39 @@ function SenaSDK(gid = "G2510S1T30") {
|
||||
// 'dev' - Load sample ngay lập tức (development)
|
||||
this.mode = "preview"; // Default mode
|
||||
this.role = "student"; // Default role
|
||||
|
||||
// ========== SPEAKING GAME (Vosk STT - client side) ==========
|
||||
// Cấu hình mặc định ưu tiên độ dễ cho học sinh tiểu học
|
||||
this.speakingConfig = {
|
||||
modelPath: "", // Ví dụ: /models/vosk-model-small-en-us-0.15.tar.gz
|
||||
sampleRate: 16000,
|
||||
bufferSize: 4096,
|
||||
useGrammar: true,
|
||||
autoNextWhenCorrect: true,
|
||||
allowNextOnWrong: false,
|
||||
ignoreArticles: true, // Bỏ qua a/an/the khi chấm cho trẻ
|
||||
minWordAccuracy: 0.65, // 1 từ
|
||||
minSentenceAccuracy: 0.72, // cụm từ/câu
|
||||
minConfidence: 0.35, // confidence từ Vosk (nếu có)
|
||||
maxWordDistance: 1, // cho phép sai 1 ký tự ở từ ngắn
|
||||
keepAudioContext: true,
|
||||
};
|
||||
|
||||
this.speakingSession = null;
|
||||
this._speechStartedAt = 0;
|
||||
this._speechTranscript = "";
|
||||
this._speechPartial = "";
|
||||
this._speechConfidence = 0;
|
||||
|
||||
this._voskModel = null;
|
||||
this._voskModelPath = "";
|
||||
this._voskRecognizer = null;
|
||||
|
||||
this._speechAudioContext = null;
|
||||
this._speechStream = null;
|
||||
this._speechSource = null;
|
||||
this._speechProcessor = null;
|
||||
this._speechSink = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1240,6 +1273,867 @@ SenaSDK.prototype.end = function (answer, callback) {
|
||||
return result; // Return full object for debug
|
||||
};
|
||||
|
||||
/**
|
||||
* Cấu hình speaking game đây nè
|
||||
*/
|
||||
SenaSDK.prototype.configureSpeaking = function (config) {
|
||||
this.speakingConfig = Object.assign({}, this.speakingConfig, config || {});
|
||||
return this.speakingConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lấy expected text cho speaking
|
||||
*/
|
||||
SenaSDK.prototype._resolveSpeakingExpectedText = function (expectedText) {
|
||||
const toText = (value) => {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item === "object") return item.text || item.name || "";
|
||||
return String(item);
|
||||
})
|
||||
.filter((v) => v !== "")
|
||||
.join("|");
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return value.text || value.name || value.answer || "";
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
if (
|
||||
expectedText !== undefined &&
|
||||
expectedText !== null &&
|
||||
expectedText !== ""
|
||||
) {
|
||||
return toText(expectedText).trim();
|
||||
}
|
||||
|
||||
if (
|
||||
this.currentQuestion &&
|
||||
this.currentQuestion.answer !== undefined &&
|
||||
this.currentQuestion.answer !== null
|
||||
) {
|
||||
return toText(this.currentQuestion.answer).trim();
|
||||
}
|
||||
|
||||
if (this.correctAnswer !== undefined && this.correctAnswer !== null) {
|
||||
return toText(this.correctAnswer).trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize text phục vụ so sánh speaking
|
||||
*/
|
||||
SenaSDK.prototype._normalizeSpeakingText = function (text, options) {
|
||||
if (!text) return "";
|
||||
|
||||
const cfg = Object.assign({}, this.speakingConfig, options || {});
|
||||
let out = String(text).toLowerCase();
|
||||
|
||||
// Chuẩn hóa ký tự accent để tránh lệch unicode
|
||||
if (typeof out.normalize === "function") {
|
||||
out = out.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
|
||||
out = out.replace(/['’]/g, "");
|
||||
out = out.replace(/[^a-z0-9\s|]/g, " ");
|
||||
out = out.replace(/\s+/g, " ").trim();
|
||||
|
||||
if (cfg.ignoreArticles) {
|
||||
const candidates = out.split("|").map((c) => c.trim());
|
||||
out = candidates
|
||||
.map((candidate) =>
|
||||
candidate
|
||||
.split(" ")
|
||||
.filter((w) => w && w !== "a" && w !== "an" && w !== "the")
|
||||
.join(" "),
|
||||
)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
return out.trim();
|
||||
};
|
||||
|
||||
SenaSDK.prototype._tokenizeSpeakingText = function (text, options) {
|
||||
const cfg = Object.assign({}, this.speakingConfig, options || {});
|
||||
if (!text) return [];
|
||||
|
||||
const fillers = { uh: true, um: true, ah: true, er: true, hmm: true };
|
||||
|
||||
return String(text)
|
||||
.split(/\s+/)
|
||||
.map((w) => w.trim())
|
||||
.filter((w) => {
|
||||
if (!w) return false;
|
||||
if (fillers[w]) return false;
|
||||
if (cfg.ignoreArticles && (w === "a" || w === "an" || w === "the")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
SenaSDK.prototype._levenshteinDistance = function (a, b) {
|
||||
if (a === b) return 0;
|
||||
if (!a) return b.length;
|
||||
if (!b) return a.length;
|
||||
|
||||
const prev = new Array(b.length + 1);
|
||||
const curr = new Array(b.length + 1);
|
||||
|
||||
for (let j = 0; j <= b.length; j++) prev[j] = j;
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
curr[0] = i;
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
||||
}
|
||||
for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
|
||||
}
|
||||
|
||||
return prev[b.length];
|
||||
};
|
||||
|
||||
SenaSDK.prototype._stringSimilarity = function (a, b) {
|
||||
if (a === b) return 1;
|
||||
if (!a || !b) return 0;
|
||||
const maxLen = Math.max(a.length, b.length);
|
||||
if (maxLen === 0) return 1;
|
||||
const dist = this._levenshteinDistance(a, b);
|
||||
return Math.max(0, 1 - dist / maxLen);
|
||||
};
|
||||
|
||||
SenaSDK.prototype._isFuzzyWordMatch = function (
|
||||
userWord,
|
||||
expectedWord,
|
||||
options,
|
||||
) {
|
||||
if (!userWord || !expectedWord) return false;
|
||||
if (userWord === expectedWord) return true;
|
||||
|
||||
const cfg = Object.assign({}, this.speakingConfig, options || {});
|
||||
const maxDistance =
|
||||
typeof cfg.maxWordDistance === "number" ? cfg.maxWordDistance : 1;
|
||||
|
||||
const dist = this._levenshteinDistance(userWord, expectedWord);
|
||||
const maxLen = Math.max(userWord.length, expectedWord.length);
|
||||
|
||||
// Dễ hơn cho trẻ: từ ngắn cho phép sai ít ký tự
|
||||
if (maxLen <= 4) return dist <= 1;
|
||||
if (dist <= maxDistance) return true;
|
||||
|
||||
return this._stringSimilarity(userWord, expectedWord) >= 0.72;
|
||||
};
|
||||
|
||||
SenaSDK.prototype._fuzzyLcsMatchCount = function (
|
||||
expectedTokens,
|
||||
userTokens,
|
||||
options,
|
||||
) {
|
||||
if (!expectedTokens.length || !userTokens.length) return 0;
|
||||
|
||||
const m = expectedTokens.length;
|
||||
const n = userTokens.length;
|
||||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (
|
||||
this._isFuzzyWordMatch(
|
||||
userTokens[j - 1],
|
||||
expectedTokens[i - 1],
|
||||
options,
|
||||
)
|
||||
) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m][n];
|
||||
};
|
||||
|
||||
SenaSDK.prototype._buildSpeakingGrammar = function (
|
||||
expectedText,
|
||||
extraGrammar,
|
||||
) {
|
||||
const list = [];
|
||||
const add = (value) => {
|
||||
const normalized = this._normalizeSpeakingText(value, {
|
||||
ignoreArticles: false,
|
||||
});
|
||||
if (normalized) list.push(normalized);
|
||||
};
|
||||
|
||||
String(expectedText || "")
|
||||
.split("|")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item)
|
||||
.forEach(add);
|
||||
|
||||
if (Array.isArray(extraGrammar)) {
|
||||
extraGrammar.forEach(add);
|
||||
}
|
||||
|
||||
// unique + giữ số lượng vừa phải để recognizer nhẹ hơn
|
||||
return Array.from(new Set(list)).slice(0, 50);
|
||||
};
|
||||
|
||||
SenaSDK.prototype._parseVoskPayload = function (payload) {
|
||||
if (payload === null || payload === undefined) {
|
||||
return { text: "", partial: "", conf: 0, words: [] };
|
||||
}
|
||||
|
||||
let data = payload;
|
||||
|
||||
if (
|
||||
typeof data === "object" &&
|
||||
data.result &&
|
||||
typeof data.result === "object" &&
|
||||
!Array.isArray(data.result)
|
||||
) {
|
||||
data = data.result;
|
||||
}
|
||||
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch (e) {
|
||||
return {
|
||||
text: String(payload).trim(),
|
||||
partial: "",
|
||||
conf: 0,
|
||||
words: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
return { text: "", partial: "", conf: 0, words: [] };
|
||||
}
|
||||
|
||||
const text =
|
||||
data.text ||
|
||||
(data.alternatives && data.alternatives[0] && data.alternatives[0].text) ||
|
||||
"";
|
||||
const partial = data.partial || "";
|
||||
const words = Array.isArray(data.result) ? data.result : [];
|
||||
|
||||
let conf = 0;
|
||||
if (typeof data.conf === "number") {
|
||||
conf = data.conf;
|
||||
} else if (words.length > 0) {
|
||||
const confValues = words
|
||||
.map((w) => (typeof w.conf === "number" ? w.conf : null))
|
||||
.filter((v) => v !== null);
|
||||
if (confValues.length > 0) {
|
||||
conf =
|
||||
confValues.reduce((sum, value) => sum + value, 0) / confValues.length;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: String(text || "").trim(),
|
||||
partial: String(partial || "").trim(),
|
||||
conf: conf > 0 ? conf : 0,
|
||||
words: words,
|
||||
};
|
||||
};
|
||||
|
||||
SenaSDK.prototype._extractTextFromVoskPayload = function (payload, isPartial) {
|
||||
const parsed = this._parseVoskPayload(payload);
|
||||
return isPartial ? parsed.partial : parsed.text;
|
||||
};
|
||||
|
||||
SenaSDK.prototype._extractConfidenceFromVoskPayload = function (payload) {
|
||||
return this._parseVoskPayload(payload).conf || 0;
|
||||
};
|
||||
|
||||
SenaSDK.prototype._collectRecognizerFinalResult = function () {
|
||||
if (!this._voskRecognizer) return;
|
||||
|
||||
let payload = null;
|
||||
try {
|
||||
if (typeof this._voskRecognizer.finalResult === "function") {
|
||||
payload = this._voskRecognizer.finalResult();
|
||||
} else if (typeof this._voskRecognizer.result === "function") {
|
||||
payload = this._voskRecognizer.result();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("🎮 Sena SDK: Cannot read Vosk final result:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalText = this._extractTextFromVoskPayload(payload, false);
|
||||
const confidence = this._extractConfidenceFromVoskPayload(payload);
|
||||
|
||||
if (finalText) this._speechTranscript = finalText;
|
||||
if (confidence > 0) this._speechConfidence = confidence;
|
||||
};
|
||||
|
||||
SenaSDK.prototype._setupRecognizerListeners = function (options) {
|
||||
let self = this;
|
||||
if (!self._voskRecognizer) return;
|
||||
|
||||
const bindEvent = function (eventName, isPartial) {
|
||||
const handler = function (payload) {
|
||||
const text = self._extractTextFromVoskPayload(payload, isPartial);
|
||||
if (isPartial) {
|
||||
if (text) self._speechPartial = text;
|
||||
if (typeof options.onPartial === "function")
|
||||
options.onPartial(text, payload);
|
||||
} else {
|
||||
if (text) self._speechTranscript = text;
|
||||
const confidence = self._extractConfidenceFromVoskPayload(payload);
|
||||
if (confidence > 0) self._speechConfidence = confidence;
|
||||
if (typeof options.onResult === "function")
|
||||
options.onResult(text, payload);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof self._voskRecognizer.on === "function") {
|
||||
self._voskRecognizer.on(eventName, handler);
|
||||
return;
|
||||
}
|
||||
if (typeof self._voskRecognizer.addEventListener === "function") {
|
||||
self._voskRecognizer.addEventListener(eventName, handler);
|
||||
}
|
||||
};
|
||||
|
||||
bindEvent("partialresult", true);
|
||||
bindEvent("partialResult", true);
|
||||
bindEvent("result", false);
|
||||
};
|
||||
|
||||
SenaSDK.prototype._floatTo16BitPCM = function (floatData) {
|
||||
const out = new Int16Array(floatData.length);
|
||||
for (let i = 0; i < floatData.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, floatData[i]));
|
||||
out[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
SenaSDK.prototype._feedRecognizerAudio = function (
|
||||
floatData,
|
||||
sampleRate,
|
||||
options,
|
||||
) {
|
||||
if (!this._voskRecognizer || !floatData || floatData.length === 0) return;
|
||||
|
||||
let endOfSpeech = false;
|
||||
|
||||
try {
|
||||
if (typeof this._voskRecognizer.acceptWaveformFloat === "function") {
|
||||
endOfSpeech = this._voskRecognizer.acceptWaveformFloat(
|
||||
floatData,
|
||||
sampleRate,
|
||||
);
|
||||
} else if (typeof this._voskRecognizer.acceptWaveform === "function") {
|
||||
// Fallback cho API nhận PCM Int16
|
||||
const pcm = this._floatTo16BitPCM(floatData);
|
||||
endOfSpeech = this._voskRecognizer.acceptWaveform(pcm, pcm.length);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("🎮 Sena SDK: Vosk acceptWaveform error:", err);
|
||||
if (typeof options.onError === "function") options.onError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback cho API dạng pull (Node-like)
|
||||
try {
|
||||
if (endOfSpeech && typeof this._voskRecognizer.result === "function") {
|
||||
const payload = this._voskRecognizer.result();
|
||||
const text = this._extractTextFromVoskPayload(payload, false);
|
||||
const conf = this._extractConfidenceFromVoskPayload(payload);
|
||||
if (text) this._speechTranscript = text;
|
||||
if (conf > 0) this._speechConfidence = conf;
|
||||
if (typeof options.onResult === "function")
|
||||
options.onResult(text, payload);
|
||||
} else if (
|
||||
!endOfSpeech &&
|
||||
typeof this._voskRecognizer.partialResult === "function"
|
||||
) {
|
||||
const payload = this._voskRecognizer.partialResult();
|
||||
const partial = this._extractTextFromVoskPayload(payload, true);
|
||||
if (partial) this._speechPartial = partial;
|
||||
if (typeof options.onPartial === "function") {
|
||||
options.onPartial(partial, payload);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("🎮 Sena SDK: Vosk pull result warning:", err);
|
||||
}
|
||||
};
|
||||
|
||||
SenaSDK.prototype._ensureVoskModel = async function (modelPath) {
|
||||
let self = this;
|
||||
const finalPath = modelPath || self.speakingConfig.modelPath;
|
||||
|
||||
if (!finalPath) {
|
||||
throw new Error(
|
||||
"SenaSDK speakingConfig.modelPath is empty. Please set modelPath before startSpeakingSession().",
|
||||
);
|
||||
}
|
||||
|
||||
if (self._voskModel && self._voskModelPath === finalPath) {
|
||||
return self._voskModel;
|
||||
}
|
||||
|
||||
const createModel =
|
||||
(self.speakingConfig && self.speakingConfig.createModel) ||
|
||||
(window.Vosk && window.Vosk.createModel) ||
|
||||
window.createModel;
|
||||
|
||||
if (typeof createModel !== "function") {
|
||||
throw new Error(
|
||||
"Vosk createModel() not found on window. Include vosk-browser or pass speakingConfig.createModel.",
|
||||
);
|
||||
}
|
||||
|
||||
if (self._voskModel && typeof self._voskModel.terminate === "function") {
|
||||
try {
|
||||
self._voskModel.terminate();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
self._voskModel = await createModel(finalPath);
|
||||
self._voskModelPath = finalPath;
|
||||
return self._voskModel;
|
||||
};
|
||||
|
||||
SenaSDK.prototype._createVoskRecognizer = function (
|
||||
model,
|
||||
sampleRate,
|
||||
expectedText,
|
||||
options,
|
||||
) {
|
||||
let recognizer = null;
|
||||
const useGrammar =
|
||||
options.useGrammar !== undefined
|
||||
? options.useGrammar
|
||||
: this.speakingConfig.useGrammar;
|
||||
const grammar = useGrammar
|
||||
? this._buildSpeakingGrammar(expectedText, options.grammar)
|
||||
: [];
|
||||
|
||||
if (typeof model.KaldiRecognizer === "function") {
|
||||
if (grammar.length > 0) {
|
||||
try {
|
||||
recognizer = new model.KaldiRecognizer(sampleRate, grammar);
|
||||
} catch (e1) {
|
||||
try {
|
||||
recognizer = new model.KaldiRecognizer(
|
||||
sampleRate,
|
||||
JSON.stringify(grammar),
|
||||
);
|
||||
} catch (e2) {
|
||||
recognizer = new model.KaldiRecognizer(sampleRate);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
recognizer = new model.KaldiRecognizer(sampleRate);
|
||||
}
|
||||
} else if (typeof model.Recognizer === "function") {
|
||||
recognizer = new model.Recognizer({
|
||||
sampleRate: sampleRate,
|
||||
grammar: grammar.length ? grammar : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!recognizer) {
|
||||
throw new Error("Cannot create recognizer from loaded Vosk model.");
|
||||
}
|
||||
|
||||
if (typeof recognizer.setWords === "function") recognizer.setWords(true);
|
||||
if (typeof recognizer.setPartialWords === "function") {
|
||||
recognizer.setPartialWords(true);
|
||||
}
|
||||
if (typeof recognizer.setMaxAlternatives === "function") {
|
||||
recognizer.setMaxAlternatives(3);
|
||||
}
|
||||
|
||||
return recognizer;
|
||||
};
|
||||
|
||||
SenaSDK.prototype._cleanupSpeakingAudio = async function (closeContext) {
|
||||
if (this._speechProcessor) {
|
||||
this._speechProcessor.onaudioprocess = null;
|
||||
try {
|
||||
this._speechProcessor.disconnect();
|
||||
} catch (e) {}
|
||||
this._speechProcessor = null;
|
||||
}
|
||||
|
||||
if (this._speechSource) {
|
||||
try {
|
||||
this._speechSource.disconnect();
|
||||
} catch (e) {}
|
||||
this._speechSource = null;
|
||||
}
|
||||
|
||||
if (this._speechSink) {
|
||||
try {
|
||||
this._speechSink.disconnect();
|
||||
} catch (e) {}
|
||||
this._speechSink = null;
|
||||
}
|
||||
|
||||
if (this._speechStream) {
|
||||
try {
|
||||
this._speechStream.getTracks().forEach((track) => track.stop());
|
||||
} catch (e) {}
|
||||
this._speechStream = null;
|
||||
}
|
||||
|
||||
if (this._voskRecognizer) {
|
||||
try {
|
||||
if (typeof this._voskRecognizer.free === "function") {
|
||||
this._voskRecognizer.free();
|
||||
} else if (typeof this._voskRecognizer.terminate === "function") {
|
||||
this._voskRecognizer.terminate();
|
||||
}
|
||||
} catch (e) {}
|
||||
this._voskRecognizer = null;
|
||||
}
|
||||
|
||||
if (
|
||||
closeContext &&
|
||||
this._speechAudioContext &&
|
||||
this._speechAudioContext.state !== "closed"
|
||||
) {
|
||||
try {
|
||||
await this._speechAudioContext.close();
|
||||
} catch (e) {}
|
||||
this._speechAudioContext = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Bắt đầu 1 session
|
||||
*/
|
||||
SenaSDK.prototype.startSpeakingSession = async function (
|
||||
expectedText,
|
||||
options,
|
||||
) {
|
||||
let self = this;
|
||||
|
||||
if (typeof expectedText === "object" && expectedText !== null) {
|
||||
options = expectedText;
|
||||
expectedText = options.expectedText;
|
||||
}
|
||||
|
||||
options = options || {};
|
||||
const cfg = Object.assign({}, self.speakingConfig, options);
|
||||
const targetText = self._resolveSpeakingExpectedText(expectedText);
|
||||
|
||||
if (!targetText) {
|
||||
throw new Error(
|
||||
"No expected text found. Pass expectedText or set correctAnswer/currentQuestion.answer.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!navigator.mediaDevices ||
|
||||
typeof navigator.mediaDevices.getUserMedia !== "function"
|
||||
) {
|
||||
throw new Error("getUserMedia is not available in this client.");
|
||||
}
|
||||
|
||||
// Nếu đang nghe session cũ thì dừng trước
|
||||
if (self.speakingSession && self.speakingSession.status === "listening") {
|
||||
await self.stopSpeakingSession({ evaluate: false });
|
||||
}
|
||||
|
||||
self._speechTranscript = "";
|
||||
self._speechPartial = "";
|
||||
self._speechConfidence = 0;
|
||||
self._speechStartedAt = Date.now();
|
||||
|
||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextClass) {
|
||||
throw new Error("Web Audio API is not available in this client.");
|
||||
}
|
||||
|
||||
try {
|
||||
await self._ensureVoskModel(cfg.modelPath);
|
||||
|
||||
if (
|
||||
!self._speechAudioContext ||
|
||||
self._speechAudioContext.state === "closed"
|
||||
) {
|
||||
self._speechAudioContext = new AudioContextClass({
|
||||
sampleRate: cfg.sampleRate,
|
||||
});
|
||||
}
|
||||
|
||||
if (self._speechAudioContext.state === "suspended") {
|
||||
await self._speechAudioContext.resume();
|
||||
}
|
||||
|
||||
self._speechStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
video: false,
|
||||
});
|
||||
|
||||
const inputSampleRate =
|
||||
self._speechAudioContext.sampleRate || cfg.sampleRate;
|
||||
self._voskRecognizer = self._createVoskRecognizer(
|
||||
self._voskModel,
|
||||
inputSampleRate,
|
||||
targetText,
|
||||
cfg,
|
||||
);
|
||||
|
||||
self._setupRecognizerListeners(cfg);
|
||||
|
||||
self._speechSource = self._speechAudioContext.createMediaStreamSource(
|
||||
self._speechStream,
|
||||
);
|
||||
self._speechProcessor = self._speechAudioContext.createScriptProcessor(
|
||||
cfg.bufferSize,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
self._speechSink = self._speechAudioContext.createGain();
|
||||
self._speechSink.gain.value = 0;
|
||||
|
||||
self._speechSource.connect(self._speechProcessor);
|
||||
self._speechProcessor.connect(self._speechSink);
|
||||
self._speechSink.connect(self._speechAudioContext.destination);
|
||||
|
||||
self._speechProcessor.onaudioprocess = function (event) {
|
||||
const floatData = event.inputBuffer.getChannelData(0);
|
||||
self._feedRecognizerAudio(floatData, inputSampleRate, cfg);
|
||||
};
|
||||
|
||||
self.speakingSession = {
|
||||
id: "SPK-" + Date.now() + "-" + Math.floor(Math.random() * 10000),
|
||||
status: "listening",
|
||||
expectedText: targetText,
|
||||
startedAt: self._speechStartedAt,
|
||||
questionId: self.currentQuestion ? self.currentQuestion.id : null,
|
||||
};
|
||||
|
||||
return {
|
||||
sessionId: self.speakingSession.id,
|
||||
status: self.speakingSession.status,
|
||||
expectedText: targetText,
|
||||
sampleRate: inputSampleRate,
|
||||
};
|
||||
} catch (err) {
|
||||
await self._cleanupSpeakingAudio(true);
|
||||
self.speakingSession = null;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Dừng session speaking hiện tại. Mặc định sẽ auto chấm đáp án và trả report.
|
||||
*/
|
||||
SenaSDK.prototype.stopSpeakingSession = async function (options, callback) {
|
||||
let self = this;
|
||||
|
||||
if (typeof options === "function") {
|
||||
callback = options;
|
||||
options = {};
|
||||
}
|
||||
options = options || {};
|
||||
|
||||
const cfg = Object.assign({}, self.speakingConfig, options);
|
||||
const active = self.speakingSession;
|
||||
|
||||
if (!active) {
|
||||
const empty = {
|
||||
type: "speaking",
|
||||
isCorrect: false,
|
||||
result: 0,
|
||||
reason: "no_active_session",
|
||||
next: {
|
||||
canNext: false,
|
||||
hasNext: self.level < self.totalQuestions - 1,
|
||||
advanced: false,
|
||||
current: self.getCurrentNumber(),
|
||||
total: self.getTotalQuestions(),
|
||||
},
|
||||
};
|
||||
if (typeof callback === "function") callback(empty);
|
||||
return empty;
|
||||
}
|
||||
|
||||
self._collectRecognizerFinalResult();
|
||||
const transcript = self._speechTranscript || self._speechPartial || "";
|
||||
|
||||
const shouldCloseContext =
|
||||
options.closeAudioContext === true || cfg.keepAudioContext === false;
|
||||
await self._cleanupSpeakingAudio(shouldCloseContext);
|
||||
|
||||
active.status = "stopped";
|
||||
active.endedAt = Date.now();
|
||||
active.duration = Math.max(0, (active.endedAt - active.startedAt) / 1000);
|
||||
|
||||
// Không chấm, chỉ trả transcript
|
||||
if (options.evaluate === false) {
|
||||
const raw = {
|
||||
type: "speaking",
|
||||
sessionId: active.id,
|
||||
status: "stopped",
|
||||
transcript: transcript,
|
||||
confidence: self._speechConfidence || 0,
|
||||
duration: active.duration,
|
||||
expectedText: active.expectedText,
|
||||
};
|
||||
self.speakingSession = active;
|
||||
if (typeof callback === "function") callback(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
const report = self.checkSpeakingAnswer(
|
||||
transcript,
|
||||
options.expectedText || active.expectedText,
|
||||
callback,
|
||||
options,
|
||||
);
|
||||
|
||||
report.sessionId = active.id;
|
||||
report.duration = active.duration;
|
||||
self.speakingSession = active;
|
||||
return report;
|
||||
};
|
||||
/**
|
||||
* Chấm speaking + trả payload để game hiển thị đúng/sai và xử lý next màn
|
||||
*/
|
||||
SenaSDK.prototype.checkSpeakingAnswer = function (
|
||||
recognizedText,
|
||||
expectedText,
|
||||
callback,
|
||||
options,
|
||||
) {
|
||||
let self = this;
|
||||
|
||||
if (typeof callback === "object" && !options) {
|
||||
options = callback;
|
||||
callback = null;
|
||||
}
|
||||
options = options || {};
|
||||
|
||||
const cfg = Object.assign({}, self.speakingConfig, options);
|
||||
|
||||
// --- LOGIC RECONSTRUCTED FROM A ---
|
||||
const transcript = String(recognizedText || "").trim();
|
||||
const expected = self._resolveSpeakingExpectedText(expectedText);
|
||||
|
||||
const normTrans = self._normalizeSpeakingText(transcript, cfg);
|
||||
const normExp = self._normalizeSpeakingText(expected, cfg);
|
||||
|
||||
const accuracy = self._stringSimilarity(normTrans, normExp);
|
||||
const threshold = cfg.minSentenceAccuracy || 0.72;
|
||||
const isCorrect = accuracy >= threshold;
|
||||
|
||||
const evalResult = {
|
||||
isCorrect: isCorrect,
|
||||
transcript: transcript,
|
||||
expectedText: expected,
|
||||
normalizedTranscript: normTrans,
|
||||
normalizedExpected: normExp,
|
||||
accuracy: accuracy,
|
||||
score: Math.round(accuracy * 100),
|
||||
confidence: self._speechConfidence || 0,
|
||||
matchedWords: 0,
|
||||
totalWords: 0,
|
||||
threshold: threshold,
|
||||
feedback: isCorrect ? "Correct" : "Incorrect",
|
||||
};
|
||||
// ----------------------------------
|
||||
|
||||
const answeredQuestionId =
|
||||
self.currentQuestion && self.currentQuestion.id !== undefined
|
||||
? self.currentQuestion.id
|
||||
: null;
|
||||
|
||||
const total = self.getTotalQuestions();
|
||||
const hasNextBeforeAdvance = self.level < total - 1;
|
||||
const canNext = evalResult.isCorrect || cfg.allowNextOnWrong === true;
|
||||
|
||||
let advanced = false;
|
||||
if (evalResult.isCorrect && cfg.autoNextWhenCorrect && hasNextBeforeAdvance) {
|
||||
advanced = self.nextQuestion();
|
||||
}
|
||||
|
||||
const report = {
|
||||
type: "speaking",
|
||||
sessionId: self.speakingSession ? self.speakingSession.id : null,
|
||||
isCorrect: evalResult.isCorrect,
|
||||
result: evalResult.isCorrect ? 1 : 0,
|
||||
transcript: evalResult.transcript,
|
||||
expectedText: evalResult.expectedText,
|
||||
normalizedTranscript: evalResult.normalizedTranscript,
|
||||
normalizedExpected: evalResult.normalizedExpected,
|
||||
accuracy: evalResult.accuracy,
|
||||
score: evalResult.score,
|
||||
confidence: evalResult.confidence,
|
||||
matchedWords: evalResult.matchedWords,
|
||||
totalWords: evalResult.totalWords,
|
||||
threshold: evalResult.threshold,
|
||||
feedback: evalResult.feedback,
|
||||
next: {
|
||||
canNext: canNext,
|
||||
hasNext: self.level < total - 1,
|
||||
advanced: advanced,
|
||||
current: self.getCurrentNumber(),
|
||||
total: total,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Đồng bộ kết quả vào userResults nếu đang ở multi-question mode
|
||||
if (answeredQuestionId !== null) {
|
||||
const existingIndex = self.userResults.findIndex(
|
||||
(r) => r.id === answeredQuestionId,
|
||||
);
|
||||
const resultValue = report.result;
|
||||
if (existingIndex >= 0) {
|
||||
self.userResults[existingIndex].result = resultValue;
|
||||
} else {
|
||||
self.userResults.push({ id: answeredQuestionId, result: resultValue });
|
||||
}
|
||||
}
|
||||
|
||||
// Push để game/parent bắt sự kiện hiển thị kết quả tại client
|
||||
try {
|
||||
window.parent.postMessage({ type: "SPEAKING_RESULT", data: report }, "*");
|
||||
} catch (e) {}
|
||||
|
||||
if (typeof callback === "function") callback(report);
|
||||
return report;
|
||||
};
|
||||
|
||||
SenaSDK.prototype.getSpeakingSessionState = function () {
|
||||
if (!this.speakingSession) return null;
|
||||
return {
|
||||
id: this.speakingSession.id,
|
||||
status: this.speakingSession.status,
|
||||
expectedText: this.speakingSession.expectedText,
|
||||
transcript: this._speechTranscript || "",
|
||||
partial: this._speechPartial || "",
|
||||
confidence: this._speechConfidence || 0,
|
||||
};
|
||||
};
|
||||
|
||||
SenaSDK.prototype.playVoice = function (type) {
|
||||
let self = this;
|
||||
// type: 'question', 'optionA', 'optionB', ...
|
||||
|
||||