update UI
All checks were successful
Deploy to Production / deploy (push) Successful in 8s

This commit is contained in:
Đặng Minh Quang
2026-03-03 19:24:07 +07:00
parent 865a70495a
commit db77668bd8
14 changed files with 905 additions and 7 deletions

View File

@@ -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
];};

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -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>

Binary file not shown.

View 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",

View File

@@ -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', ...

Binary file not shown.

Binary file not shown.

Binary file not shown.