Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public SessionWebSocketConfig(
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(sessionMessageWebSocketHandler, "/ws/session-messages")
.addInterceptors(authenticationInterceptor);
.addInterceptors(authenticationInterceptor)
.setAllowedOriginPatterns(
"http://localhost:*",
"http://127.0.0.1:*");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
@Component
public class ScenarioDialogueEventExtractor {

private static final int RECENT_MESSAGE_LIMIT = 16;
private static final int RECENT_MESSAGE_LIMIT = 8;

private final AiProviderRegistry providerRegistry;
private final ObjectMapper objectMapper;
Expand Down
1 change: 1 addition & 0 deletions frontend/Unispeaking_fronted/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"build": "vite build",
"check:realtime-events": "node scripts/check-realtime-events.mjs",
"check:routes": "node scripts/check-routes.mjs",
"generate:teacher-previews": "node scripts/generate-teacher-previews.mjs",
"preview": "vite preview"
},
"dependencies": {
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,6 @@ assert.equal(fasterHarvey.voice, "Harvey");
assert.match(fasterHarvey.instructions, /210 English words per minute/);
assert.equal(fasterHarvey.input_audio_transcription.model, "qwen3-asr-flash-realtime");
assert.equal(fasterHarvey.turn_detection.type, "semantic_vad");
assert.equal(fasterHarvey.turn_detection.silence_duration_ms, 800);
assert.equal(fasterHarvey.turn_detection.silence_duration_ms, 600);

console.log("Realtime event normalization checks passed.");
58 changes: 58 additions & 0 deletions frontend/Unispeaking_fronted/scripts/generate-teacher-previews.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const apiKey = process.env.DASHSCOPE_API_KEY?.trim();
if (!apiKey) {
throw new Error("DASHSCOPE_API_KEY is required");
}

const endpoint = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
const model = process.env.QWEN_TTS_MODEL?.trim() || "qwen3-tts-flash";
const previews = [
["clara", "Katerina", "Hi, I’m Clara. Take your time — we’ll make speaking English feel natural and easy."],
["james", "Ryan", "Hello, I’m James. Let’s turn your ideas into clear, confident English."],
["leo", "Ethan", "Hey, I’m Leo! Don’t overthink it — just speak, and we’ll have a great chat."],
["david", "Aiden", "Hi, I’m David. We’ll make your English concise, natural, and ready for work."],
["emily", "Serena", "Hi, I’m Emily. Let’s talk about everyday life and enjoy the conversation."],
["arthur", "Eldric Sage", "Good to meet you. I’m Arthur. Let’s give your English more depth and confidence."],
];

const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const outputDirectory = path.resolve(scriptDirectory, "../public/teachers/audio");
await mkdir(outputDirectory, { recursive: true });

for (const [id, voice, text] of previews) {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
input: {
text,
voice,
language_type: "English",
},
}),
});
if (!response.ok) {
throw new Error(`${id} preview generation failed with HTTP ${response.status}`);
}
const result = await response.json();
const audioUrl = result?.output?.audio?.url;
if (!audioUrl) {
throw new Error(`${id} preview generation did not return an audio URL`);
}
const audioResponse = await fetch(audioUrl);
if (!audioResponse.ok) {
throw new Error(`${id} preview download failed with HTTP ${audioResponse.status}`);
}
const audio = Buffer.from(await audioResponse.arrayBuffer());
if (audio.length < 12 || audio.toString("ascii", 0, 4) !== "RIFF" || audio.toString("ascii", 8, 12) !== "WAVE") {
throw new Error(`${id} preview is not a WAV file`);
}
await writeFile(path.join(outputDirectory, `${id}.wav`), audio);
}
190 changes: 145 additions & 45 deletions frontend/Unispeaking_fronted/src/App.jsx

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions frontend/Unispeaking_fronted/src/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const teachers = [
method: "会放慢节奏,用轻松追问帮助你继续说下去。",
intro: "Hi, I’m Clara. Take your time — we’ll make speaking English feel natural and easy.",
image: "/teachers/clara.png",
previewAudio: "/teachers/audio/clara.wav",
voiceId: "Katerina",
},
{
Expand All @@ -17,6 +18,7 @@ export const teachers = [
method: "擅长梳理表达逻辑,适合希望说得更有条理的学习者。",
intro: "Hello, I’m James. Let’s turn your ideas into clear, confident English.",
image: "/teachers/james.png",
previewAudio: "/teachers/audio/james.wav",
voiceId: "Harvey",
},
{
Expand All @@ -27,6 +29,7 @@ export const teachers = [
method: "像朋友一样自然聊天,快速降低开口压力。",
intro: "Hey, I’m Leo! Don’t overthink it — just speak, and we’ll have a great chat.",
image: "/teachers/leo.png",
previewAudio: "/teachers/audio/leo.wav",
voiceId: "Raymond",
},
{
Expand All @@ -37,6 +40,7 @@ export const teachers = [
method: "关注表达效率,适合职场沟通与面试准备。",
intro: "Hi, I’m David. We’ll make your English concise, natural, and ready for work.",
image: "/teachers/david.png",
previewAudio: "/teachers/audio/david.wav",
voiceId: "Aiden",
},
{
Expand All @@ -47,6 +51,7 @@ export const teachers = [
method: "善于从日常话题展开,让对话持续而不尴尬。",
intro: "Hi, I’m Emily. Let’s talk about everyday life and enjoy the conversation.",
image: "/teachers/emily.png",
previewAudio: "/teachers/audio/emily.wav",
voiceId: "Tina",
},
{
Expand All @@ -57,6 +62,7 @@ export const teachers = [
method: "提供更成熟的表达方式,适合进阶交流。",
intro: "Good to meet you. I’m Arthur. Let’s give your English more depth and confidence.",
image: "/teachers/arthur.png",
previewAudio: "/teachers/audio/arthur.wav",
voiceId: "Dolce",
},
];
Expand Down
92 changes: 73 additions & 19 deletions frontend/Unispeaking_fronted/src/realtimeClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const DEFAULT_VOICE = "Katerina";
const DEFAULT_MODEL = "qwen3.5-omni-flash-realtime";
const DATA_CHANNEL_LABEL = "oai-events";
const DEFAULT_SPEECH_SPEED = "NATURAL";
const SCENARIO_COMPLETION_TIMEOUT_MS = 10_000;
const SCENARIO_CLOSING_TIMEOUT_MS = 20_000;
const SCENARIO_AUDIO_DRAIN_MS = 1_200;
const SPEECH_SPEED_INSTRUCTIONS = {
SLOWER: "Voice delivery rule: speak distinctly and very slowly, around 70 English words per minute, with clear pauses between short phrases.",
MODERATE: "Voice delivery rule: speak at a calm moderate pace, around 120 English words per minute, with clear pauses between ideas.",
Expand Down Expand Up @@ -176,7 +177,7 @@ export function buildRealtimeSessionConfig({
type: String(model || DEFAULT_MODEL).startsWith("qwen3.5-omni-") ? "semantic_vad" : "server_vad",
threshold: 0.5,
prefix_padding_ms: 500,
silence_duration_ms: 800,
silence_duration_ms: 600,
create_response: Boolean(automaticTurnResponses),
interrupt_response: true,
},
Expand Down Expand Up @@ -217,6 +218,10 @@ export function createRealtimeClient({
let scenarioCompletionPending = false;
let scenarioCompletionEmitted = false;
let scenarioCompletionTimer = null;
let scenarioAudioDrainTimer = null;
let responsePending = false;
let closingResponseRequested = false;
let statePipeline = Promise.resolve();

const emit = (event) => onEvent(event);

Expand Down Expand Up @@ -374,6 +379,14 @@ export function createRealtimeClient({

function emitScenarioCompleted() {
if (!scenarioCompletionPending || scenarioCompletionEmitted) return;
if (scenarioCompletionTimer) {
window.clearTimeout(scenarioCompletionTimer);
scenarioCompletionTimer = null;
}
if (scenarioAudioDrainTimer) {
window.clearTimeout(scenarioAudioDrainTimer);
scenarioAudioDrainTimer = null;
}
scenarioCompletionEmitted = true;
emit({ type: "local.scenario_completed" });
void stop({ reason: "state_machine" }).catch((error) => {
Expand All @@ -389,14 +402,35 @@ export function createRealtimeClient({
scenarioCompletionTimer = window.setTimeout(() => {
scenarioCompletionTimer = null;
emitScenarioCompleted();
}, SCENARIO_COMPLETION_TIMEOUT_MS);
}, SCENARIO_CLOSING_TIMEOUT_MS);
}

function requestTurnResponse() {
sendProviderEvent({
event_id: eventId("turn_response"),
type: "response.create",
});
function scheduleScenarioCompletionAfterAudioDrain() {
if (!scenarioCompletionPending || scenarioAudioDrainTimer) return;
scenarioAudioDrainTimer = window.setTimeout(() => {
scenarioAudioDrainTimer = null;
emitScenarioCompleted();
}, SCENARIO_AUDIO_DRAIN_MS);
}

function requestTurnResponse({ closing = false } = {}) {
if (responsePending) return false;
responsePending = true;
if (closing) {
closingResponseRequested = true;
armScenarioCompletionTimeout();
}
try {
sendProviderEvent({
event_id: eventId(closing ? "closing_response" : "turn_response"),
type: "response.create",
});
} catch (error) {
responsePending = false;
if (closing) closingResponseRequested = false;
throw error;
}
return true;
}

function applyScenarioState(state) {
Expand All @@ -420,6 +454,9 @@ export function createRealtimeClient({
currentTurnAudio = segmentRecorder?.stopSegment() || Promise.resolve(null);
segmentActive = false;
}
if (!responsePending) {
requestTurnResponse({ closing: true });
}
}

async function postStart({ offerSdp, voice, model }) {
Expand Down Expand Up @@ -455,7 +492,11 @@ export function createRealtimeClient({

if (isActiveResponseConflict(event)) {
initialResponseStarted = true;
inputReady = true;
if (scenarioCompletionPending && closingResponseRequested) {
closingResponseRequested = false;
}
responsePending = true;
inputReady = !customSceneId && !scenarioCompletionPending;
if (initialResponseFallbackTimer) {
window.clearTimeout(initialResponseFallbackTimer);
initialResponseFallbackTimer = null;
Expand Down Expand Up @@ -514,6 +555,7 @@ export function createRealtimeClient({

if (event.type === "response.created") {
initialResponseStarted = true;
responsePending = true;
inputReady = !customSceneId;
if (initialResponseFallbackTimer) {
window.clearTimeout(initialResponseFallbackTimer);
Expand All @@ -535,21 +577,26 @@ export function createRealtimeClient({
currentTurnAudio = segmentRecorder?.stopSegment() || Promise.resolve(null);
segmentActive = false;
}
const persisted = await addSessionMessage(
const persistenceOperation = addSessionMessage(
1,
transcript,
event.item_id || event.item?.id || event.event_id,
);
if (customSceneId) {
requestTurnResponse();
}
const persisted = await persistenceOperation;
if (customSceneId && persisted) {
const turnNo = ++learnerTurnNo;
const wavAudio = await currentTurnAudio;
currentTurnAudio = Promise.resolve(null);
const stateOperation = advanceCustomDialogueState(
const stateOperation = statePipeline.then(() => advanceCustomDialogueState(
customSceneId,
sessionId,
turnNo,
transcript,
);
));
statePipeline = stateOperation.catch(() => null);
const evaluationOperation = evaluateCustomDialogueTurn(
customSceneId,
sessionId,
Expand Down Expand Up @@ -578,15 +625,12 @@ export function createRealtimeClient({
try {
const scenarioState = await stateOperation;
applyScenarioState(scenarioState);
requestTurnResponse();
if (scenarioState?.completed) armScenarioCompletionTimeout();
} catch (error) {
emit({
type: "local.scenario_state_error",
turnNo,
message: error instanceof Error ? error.message : "场景状态推进失败",
});
requestTurnResponse();
} finally {
pendingOperations.delete(stateOperation);
}
Expand All @@ -603,12 +647,17 @@ export function createRealtimeClient({
);
}
if (event.type === "response.done") {
responsePending = false;
if (scenarioCompletionPending) {
if (scenarioCompletionTimer) {
window.clearTimeout(scenarioCompletionTimer);
scenarioCompletionTimer = null;
if (closingResponseRequested) {
if (scenarioCompletionTimer) {
window.clearTimeout(scenarioCompletionTimer);
scenarioCompletionTimer = null;
}
scheduleScenarioCompletionAfterAudioDrain();
} else {
requestTurnResponse({ closing: true });
}
emitScenarioCompleted();
} else if (customSceneId) {
inputReady = true;
setTrackEnabled();
Expand Down Expand Up @@ -804,6 +853,7 @@ export function createRealtimeClient({
} finally {
try { sessionSocket?.close?.(); } catch { /* already closed */ }
if (scenarioCompletionTimer) window.clearTimeout(scenarioCompletionTimer);
if (scenarioAudioDrainTimer) window.clearTimeout(scenarioAudioDrainTimer);
peer = null;
channel = null;
sessionSocket = null;
Expand All @@ -820,6 +870,10 @@ export function createRealtimeClient({
scenarioCompletionPending = false;
scenarioCompletionEmitted = false;
scenarioCompletionTimer = null;
scenarioAudioDrainTimer = null;
responsePending = false;
closingResponseRequested = false;
statePipeline = Promise.resolve();
pendingAcks = [];
persistedMessageIds = new Set();
sessionUpdateAcknowledged = false;
Expand Down
7 changes: 7 additions & 0 deletions frontend/Unispeaking_fronted/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,18 @@ svg { display: block; }
.audio-toggle--mini svg { width: 22px; height: 22px; }
.audio-toggle--mini:not(.is-muted) .audio-toggle__speaker { animation: audio-toggle-pop .5s; }
.audio-toggle--mini.is-muted .audio-toggle__muted { animation: audio-toggle-pop .5s; }
.audio-toggle--mini.is-playing { color: #55555d; background: #eeeeeb; }
.audio-toggle--mini.is-playing svg { animation: audio-toggle-playing 1.1s ease-in-out infinite; }
.audio-toggle--mini.has-error { color: #c3c3bf; cursor: not-allowed; }
@keyframes audio-toggle-pop {
0% { transform: rotate(0deg) scale(0); opacity: 0; }
50% { transform: rotate(-10deg) scale(1.2); }
100% { transform: rotate(0deg) scale(1); opacity: 1; }
}
@keyframes audio-toggle-playing {
0%, 100% { transform: scale(.92); }
50% { transform: scale(1.08); }
}
.audio-toggle__speaker, .audio-toggle__muted { position: absolute; inset: 0; display: grid; place-items: center; transition: opacity .3s, transform .3s; }
.audio-toggle__muted { opacity: 0; transform: scale(.72); }
.audio-toggle.is-muted .audio-toggle__speaker { opacity: 0; transform: scale(.72); }
Expand Down