diff --git a/interview-app/public/index.html b/interview-app/public/index.html
index f54fdf8..9b49dc9 100644
--- a/interview-app/public/index.html
+++ b/interview-app/public/index.html
@@ -2245,6 +2245,8 @@
Parent Supervision Required
requiresUserApiKey: false,
apiKeyHeader: 'x-anthropic-api-key',
anthropicApiKey: '',
+ lastSpokenNormalized: '',
+ lastSpokenEndedAt: 0,
};
// ===== DOM refs =====
@@ -3387,7 +3389,8 @@ Parent Supervision Required
// Strip markdown + emoji for speech (emoji still shows in chat, just not spoken)
const clean = text
.replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, '')
- .replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, 'Check out this quick preview I made!')
+ // Avoid speaking preview boilerplate; STT can hear it and create loops.
+ .replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, '')
.replace(/---INTERVIEW_COMPLETE---[\s\S]*---END_INTERVIEW---/g, 'I\'ve put together a summary of your app idea!')
.replace(/[#*_`\[\]()>]/g, '')
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, '')
@@ -3397,6 +3400,7 @@ Parent Supervision Required
// Chrome silently stops speaking long text. Split into sentences and chain them.
const chunks = clean.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [clean];
+ const normalizedClean = clean.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
state.isSpeaking = true;
setBartMood('talking');
@@ -3405,7 +3409,13 @@ Parent Supervision Required
let i = 0;
function speakNext() {
if (i >= chunks.length) {
- setTimeout(() => { state.isSpeaking = false; setBartMood('idle'); resolve(); }, 400);
+ setTimeout(() => {
+ state.lastSpokenNormalized = normalizedClean;
+ state.lastSpokenEndedAt = Date.now();
+ state.isSpeaking = false;
+ setBartMood('idle');
+ resolve();
+ }, 400);
return;
}
const chunk = chunks[i].trim();
@@ -3426,6 +3436,21 @@ Parent Supervision Required
});
}
+function normalizeSpeechText(raw) {
+ return String(raw || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+function looksLikeAssistantEcho(raw) {
+ const text = normalizeSpeechText(raw);
+ if (!text) return false;
+ if (text === 'check out this quick preview i made') return true;
+
+ const ageMs = Date.now() - (state.lastSpokenEndedAt || 0);
+ if (!state.lastSpokenNormalized || ageMs > 8000 || text.length < 12) return false;
+
+ return state.lastSpokenNormalized.includes(text) || text.includes(state.lastSpokenNormalized.slice(0, 40));
+}
+
// ===== Speech Recognition (STT) =====
function initRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
@@ -3461,6 +3486,11 @@ Parent Supervision Required
silenceTimer = setTimeout(() => {
if (state.isListening) {
const text = $input.value.trim();
+ if (looksLikeAssistantEcho(text)) {
+ $input.value = '';
+ $sendBtn.disabled = true;
+ return;
+ }
stopListening();
if (text) sendMessage(text);
}
@@ -3473,6 +3503,27 @@ Parent Supervision Required
const text = $input.value.trim();
clearTimeout(silenceTimer);
if (text) {
+ if (looksLikeAssistantEcho(text)) {
+ $input.value = '';
+ $sendBtn.disabled = true;
+ if (state.micActive && !state.isWaiting && !state.isSpeaking) {
+ try {
+ recog.start();
+ return;
+ } catch (e) {
+ setTimeout(() => {
+ if (state.micActive && !state.isWaiting && !state.isSpeaking) {
+ try { recog.start(); } catch (e2) {}
+ }
+ }, 300);
+ return;
+ }
+ }
+ state.isListening = false;
+ $micBtn.classList.remove('recording');
+ $listeningLabel.classList.remove('visible');
+ return;
+ }
// Got text — send it, mic will restart after AI responds
state.isListening = false;
$micBtn.classList.remove('recording');