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
119 changes: 114 additions & 5 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const muteIndicator = document.getElementById('mute-indicator');
const indicatorText = muteIndicator?.querySelector('.indicator-text') ?? null;
const aiCircle = document.querySelector('[data-role="ai"]');
const userCircle = document.querySelector('[data-role="user"]');
const backgroundUrls = document.getElementById('background-urls');

let currentImageModel = 'flux';
let chatHistory = [];
Expand Down Expand Up @@ -63,12 +64,62 @@ function setCircleState(circle, { speaking = false, listening = false, error = f
circle.classList.toggle('is-speaking', speaking);
circle.classList.toggle('is-listening', listening);
circle.classList.toggle('is-error', error);
circle.classList.toggle('is-active', speaking || listening || error);

if (label) {
circle.setAttribute('aria-label', label);
}
}

const URL_REGEX = /(https?:\/\/[^\s]+)/gi;
const URL_BADGE_POSITIONS = ['top-left', 'top-right', 'bottom-right', 'bottom-left'];

function extractUrlsFromText(text = '') {
if (!text) {
return [];
}

const matches = text.match(URL_REGEX) ?? [];
return matches.map((match) => match.replace(/[\s.,;!?]+$/, ''));
}

function sanitizeTextForSpeech(text = '') {
return text.replace(URL_REGEX, ' ').replace(/\s{2,}/g, ' ').trim();
}

function updateBackgroundLinkOverlay(urls) {
if (!backgroundUrls) {
return;
}

const previousBadges = [...backgroundUrls.querySelectorAll('.url-badge')];
previousBadges.forEach((badge) => {
badge.classList.remove('is-visible');
badge.addEventListener(
'transitionend',
() => {
badge.remove();
},
{ once: true }
);
});

if (!urls.length) {
return;
}

urls.slice(0, URL_BADGE_POSITIONS.length).forEach((url, index) => {
const badge = document.createElement('span');
badge.className = 'url-badge';
badge.dataset.position = URL_BADGE_POSITIONS[index % URL_BADGE_POSITIONS.length];
badge.textContent = url;
backgroundUrls.appendChild(badge);
requestAnimationFrame(() => {
badge.classList.add('is-visible');
});
});
}

async function loadSystemPrompt() {
try {
const response = await fetch(resolveAssetPath('ai-instruct.txt'));
Expand Down Expand Up @@ -104,6 +155,22 @@ function setupSpeechRecognition() {
});
};

recognition.onsoundstart = () => {
setCircleState(userCircle, {
listening: true,
speaking: true,
label: 'Hearing you speak'
});
};

recognition.onsoundend = () => {
setCircleState(userCircle, {
listening: true,
speaking: false,
label: 'Processing what you said'
});
};

recognition.onaudiostart = () => {
setCircleState(userCircle, {
listening: true,
Expand Down Expand Up @@ -319,6 +386,8 @@ document.addEventListener('keydown', (event) => {
}
});

let speakingFallbackTimeout = null;

function speak(text) {
if (synth.speaking) {
console.error('Speech synthesis is already speaking.');
Expand All @@ -341,22 +410,52 @@ function speak(text) {
console.warn('UK English female voice not found, using default.');
}

utterance.onstart = () => {
console.log('AI is speaking...');
setCircleState(aiCircle, {
speaking: true,
label: 'Unity is speaking'
});

if (speakingFallbackTimeout) {
clearTimeout(speakingFallbackTimeout);
}

speakingFallbackTimeout = setTimeout(() => {
if (synth.speaking) {
return;
}
setCircleState(aiCircle, {
speaking: true,
label: 'Unity is speaking'
speaking: false,
label: 'Unity is idle'
});
}, Math.max(4000, text.length * 90));

utterance.onstart = () => {
console.log('AI is speaking...');
};

utterance.onend = () => {
console.log('AI finished speaking.');
if (speakingFallbackTimeout) {
clearTimeout(speakingFallbackTimeout);
speakingFallbackTimeout = null;
}
setCircleState(aiCircle, {
speaking: false,
label: 'Unity is idle'
});
};

utterance.onerror = () => {
if (speakingFallbackTimeout) {
clearTimeout(speakingFallbackTimeout);
speakingFallbackTimeout = null;
}
setCircleState(aiCircle, {
speaking: false,
label: 'Unity encountered a speech error'
});
};

synth.speak(utterance);
}

Expand Down Expand Up @@ -527,7 +626,17 @@ async function getAIResponse(userInput) {
}

chatHistory.push({ role: 'assistant', content: aiText });
speak(aiText);

const extractedUrls = extractUrlsFromText(aiText);
const sanitizedText = sanitizeTextForSpeech(aiText);

updateBackgroundLinkOverlay(extractedUrls);

if (sanitizedText) {
speak(sanitizedText);
} else if (extractedUrls.length) {
speak('I have shared a link with you.');
}
} catch (error) {
console.error('Error getting text from Pollinations AI:', error);
setCircleState(aiCircle, {
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
</head>
<body>
<div id="background" aria-hidden="true"></div>
<div id="background-urls" aria-hidden="true"></div>
<main class="layout" aria-live="polite">
<section class="voice-stage" role="group" aria-label="Voice activity monitors">
<article class="voice-circle ai" data-role="ai" aria-label="Unity is idle">
Expand Down
112 changes: 108 additions & 4 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,66 @@ body {
z-index: -1;
}

#background-urls {
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
display: flex;
flex-wrap: wrap;
align-content: space-between;
padding: clamp(18px, 6vw, 64px);
gap: clamp(16px, 4vw, 48px);
mix-blend-mode: screen;
}

#background-urls .url-badge {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 22px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: linear-gradient(135deg, rgba(9, 10, 20, 0.72) 0%, rgba(40, 48, 68, 0.55) 100%);
box-shadow: 0 0 42px rgba(10, 12, 22, 0.58);
color: rgba(255, 255, 255, 0.82);
font-size: clamp(0.65rem, 1.25vw, 0.95rem);
letter-spacing: 0.18em;
text-transform: uppercase;
text-align: center;
word-break: break-all;
backdrop-filter: blur(18px);
opacity: 0;
transform: scale(0.9);
transition: opacity 0.5s ease, transform 0.5s ease;
}

#background-urls .url-badge.is-visible {
opacity: 1;
transform: scale(1);
}

#background-urls .url-badge[data-position="top-left"] {
align-self: flex-start;
margin-left: 0;
}

#background-urls .url-badge[data-position="top-right"] {
align-self: flex-start;
margin-left: auto;
}

#background-urls .url-badge[data-position="bottom-left"] {
align-self: flex-end;
margin-left: 0;
}

#background-urls .url-badge[data-position="bottom-right"] {
align-self: flex-end;
margin-left: auto;
}

.layout {
width: 100%;
flex: 1;
Expand Down Expand Up @@ -73,6 +133,26 @@ body {
transition: border-color 0.4s ease, box-shadow 0.4s ease, transform 0.4s ease;
}

.voice-circle::after {
content: "";
position: absolute;
inset: 20%;
border-radius: 50%;
background: radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.55) 0%, rgba(255, 255, 255, 0.08) 65%, transparent 100%);
opacity: 0;
transition: opacity 0.3s ease;
z-index: 0;
}

.voice-circle.is-active:not(.is-speaking):not(.is-error) {
box-shadow: 0 0 42px -18px rgba(124, 92, 255, 0.6);
}

.voice-circle.user.is-active:not(.is-speaking):not(.is-error) {
border-color: rgba(67, 217, 189, 0.65);
box-shadow: 0 0 42px -18px rgba(67, 217, 189, 0.65);
}

.pulse-ring {
position: absolute;
inset: 12%;
Expand All @@ -91,18 +171,27 @@ body {
}

.voice-circle.is-speaking {
box-shadow: 0 0 42px -18px rgba(255, 255, 255, 0.6);
transform: translateY(-6px) scale(1.03);
box-shadow: 0 0 54px -16px rgba(255, 255, 255, 0.75);
transform: translateY(-6px) scale(1.05);
}

.voice-circle.is-speaking .pulse-ring {
animation: pulse 1.4s ease-in-out infinite;
opacity: 1;
}

.voice-circle.is-speaking::after {
opacity: 0.65;
}

.voice-circle.is-listening {
border-color: rgba(67, 217, 189, 0.8);
box-shadow: 0 0 42px -12px rgba(67, 217, 189, 0.6);
border-color: rgba(67, 217, 189, 0.85);
box-shadow: 0 0 48px -12px rgba(67, 217, 189, 0.75);
}

.voice-circle.is-listening .pulse-ring {
opacity: 1;
animation: breathe 1.6s ease-in-out infinite;
}

.voice-circle.is-error {
Expand Down Expand Up @@ -174,6 +263,21 @@ body {
}
}

@keyframes breathe {
0% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.08);
opacity: 0.4;
}
100% {
transform: scale(1);
opacity: 0.7;
}
}

@media (max-width: 720px) {
.voice-stage {
flex-direction: column;
Expand Down