diff --git a/kkkmeet-server.el b/kkkmeet-server.el index c57d1ab..6074f37 100644 --- a/kkkmeet-server.el +++ b/kkkmeet-server.el @@ -122,6 +122,19 @@ (let ((normalized (string-trim (replace-regexp-in-string "[ \t\n\r]+" " " (or text ""))))) (substring normalized 0 (min 240 (length normalized))))) +(defun kkkmeet-server--clamp-number (value fallback min max) + "Return VALUE clamped between MIN and MAX, or FALLBACK." + (let ((number (if (numberp value) value fallback))) + (min max (max min number)))) + +(defun kkkmeet-server--normalize-voice-settings (settings) + "Return sanitized voice SETTINGS." + (let ((safe-settings (if (listp settings) settings nil))) + `((amplitude . ,(kkkmeet-server--clamp-number (alist-get 'amplitude safe-settings) 100 0 200)) + (pitch . ,(kkkmeet-server--clamp-number (alist-get 'pitch safe-settings) 32 0 99)) + (speed . ,(kkkmeet-server--clamp-number (alist-get 'speed safe-settings) 135 80 240)) + (wordgap . ,(kkkmeet-server--clamp-number (alist-get 'wordgap safe-settings) 4 0 20))))) + (defun kkkmeet-server--normalize-client-id (value) "Return a safe persistent client id from VALUE." (let ((normalized (string-trim (or value "")))) @@ -504,6 +517,8 @@ When KEEP-OPEN is nil, close PROCESS after writing." (name . ,(kkkmeet-server--normalize-name (alist-get 'name payload))) (text . ,text) + (voice . ,(kkkmeet-server--normalize-voice-settings + (alist-get 'voice payload))) (updatedAt . ,(floor (* 1000 (float-time))))))) (kkkmeet-server--broadcast safe-room-id from event) (kkkmeet-server--send-json process 202 '((ok . t))))))) diff --git a/public/app.js b/public/app.js index deeddff..ea9f0e9 100644 --- a/public/app.js +++ b/public/app.js @@ -36,6 +36,10 @@ const announcementDialog = document.querySelector("#announcement-dialog"); const announcementForm = document.querySelector("#announcement-form"); const announcementInput = document.querySelector("#announcement-input"); const announcementCancelButton = document.querySelector("#announcement-cancel-button"); +const announcementAmplitudeInput = document.querySelector("#announcement-amplitude"); +const announcementPitchInput = document.querySelector("#announcement-pitch"); +const announcementSpeedInput = document.querySelector("#announcement-speed"); +const announcementWordgapInput = document.querySelector("#announcement-wordgap"); const roomId = "main"; const nameStorageKey = "kkkmeet.displayName"; @@ -45,6 +49,7 @@ const cameraStorageKey = "kkkmeet.cameraDeviceId"; const micStorageKey = "kkkmeet.micDeviceId"; const micVolumeStorageKey = "kkkmeet.selfMicVolume"; const noiseSuppressionStorageKey = "kkkmeet.noiseSuppression"; +const announcementVoiceStorageKey = "kkkmeet.announcementVoice"; const watchHeightStorageKey = "kkkmeet.watchPanelHeight"; const watchCollapsedStorageKey = "kkkmeet.watchPanelCollapsed"; const rtcConfig = { @@ -167,16 +172,64 @@ function normalizeAnnouncement(text) { return String(text || "").trim().replace(/\s+/g, " ").slice(0, 240); } -function speakText(text) { +function voiceSetting(value, fallback, min, max) { + const parsed = Number(value); + return Number.isFinite(parsed) ? clamp(parsed, min, max) : fallback; +} + +function defaultVoiceSettings() { + return { + amplitude: 100, + pitch: 32, + speed: 135, + wordgap: 4 + }; +} + +function normalizeVoiceSettings(settings = {}) { + const defaults = defaultVoiceSettings(); + return { + amplitude: voiceSetting(settings.amplitude, defaults.amplitude, 0, 200), + pitch: voiceSetting(settings.pitch, defaults.pitch, 0, 99), + speed: voiceSetting(settings.speed, defaults.speed, 80, 240), + wordgap: voiceSetting(settings.wordgap, defaults.wordgap, 0, 20) + }; +} + +function currentAnnouncementVoiceSettings() { + return normalizeVoiceSettings({ + amplitude: announcementAmplitudeInput.value, + pitch: announcementPitchInput.value, + speed: announcementSpeedInput.value, + wordgap: announcementWordgapInput.value + }); +} + +function saveAnnouncementVoiceSettings() { + localStorage.setItem(announcementVoiceStorageKey, JSON.stringify(currentAnnouncementVoiceSettings())); +} + +function applyAnnouncementVoiceSettings(settings) { + const voice = normalizeVoiceSettings(settings); + announcementAmplitudeInput.value = String(voice.amplitude); + announcementPitchInput.value = String(voice.pitch); + announcementSpeedInput.value = String(voice.speed); + announcementWordgapInput.value = String(voice.wordgap); +} + +function savedAnnouncementVoiceSettings() { + try { + return normalizeVoiceSettings(JSON.parse(localStorage.getItem(announcementVoiceStorageKey) || "null") || {}); + } catch { + return defaultVoiceSettings(); + } +} + +function speakText(text, voiceSettings = {}) { if (typeof window.speak !== "function") return; try { - window.speak(text, { - amplitude: 100, - pitch: 32, - speed: 135, - wordgap: 4 - }); + window.speak(text, normalizeVoiceSettings(voiceSettings)); } catch (error) { console.warn("Could not play voice announcement.", error); } @@ -184,13 +237,13 @@ function speakText(text) { function announceParticipantJoined(name) { const participantName = normalizeName(name || "A participant") || "A participant"; - speakText(`${participantName} has joined.`); + speakText(`${participantName} has joined.`, defaultVoiceSettings()); } function speakAnnouncement(message) { const text = normalizeAnnouncement(message?.text || ""); if (!text) return; - speakText(text); + speakText(text, message?.voice); } function openAnnouncementDialog() { @@ -863,7 +916,9 @@ async function refreshDeviceLists() { } function savedSelfMicVolume() { - const saved = Number(localStorage.getItem(micVolumeStorageKey)); + const stored = localStorage.getItem(micVolumeStorageKey); + if (stored === null) return 100; + const saved = Number(stored); return Number.isFinite(saved) ? clamp(saved, 0, 200) : 100; } @@ -1441,16 +1496,54 @@ async function sendVoiceAnnouncement(text) { const announcementText = normalizeAnnouncement(text); if (!announcementText) return; - await sendRoomEvent({ type: "voice-announcement", text: announcementText }); - speakAnnouncement({ text: announcementText }); + const voice = currentAnnouncementVoiceSettings(); + saveAnnouncementVoiceSettings(); + await sendRoomEvent({ type: "voice-announcement", text: announcementText, voice }); + speakAnnouncement({ text: announcementText, voice }); setActivity(displayName, "sent an announcement."); } +async function constrainScreenTrack(track) { + if (!track?.applyConstraints) return; + + try { + await track.applyConstraints({ + width: { max: 1920 }, + height: { max: 1080 }, + frameRate: { max: 30 } + }); + } catch (error) { + console.warn("Could not constrain screen share resolution.", error); + } +} + +async function getScreenShareStream() { + const constraints = { + video: { + width: { max: 1920 }, + height: { max: 1080 }, + frameRate: { max: 30 } + }, + audio: false + }; + + try { + return await navigator.mediaDevices.getDisplayMedia(constraints); + } catch (error) { + if (!["OverconstrainedError", "TypeError", "ConstraintNotSatisfiedError"].includes(error.name)) { + throw error; + } + console.warn("Screen share constraints were rejected, falling back to default capture.", error); + return navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); + } +} + async function startScreenShare() { if (!localStream || screenStream) return; - screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); + screenStream = await getScreenShareStream(); const screenTrack = screenStream.getVideoTracks()[0]; + await constrainScreenTrack(screenTrack); createLocalPresentationTile(screenStream); await replaceOutboundVideoTrack(screenTrack); shareButton.textContent = "Stop Sharing"; @@ -1582,6 +1675,15 @@ noiseSuppressionInput.addEventListener("change", () => { }); }); +[ + announcementAmplitudeInput, + announcementPitchInput, + announcementSpeedInput, + announcementWordgapInput +].forEach((input) => { + input.addEventListener("input", saveAnnouncementVoiceSettings); +}); + cameraSelect.addEventListener("change", () => { replaceLocalTrack("video", cameraSelect.value).catch((error) => { console.error(error); @@ -1643,6 +1745,7 @@ shareButton.addEventListener("click", async () => { selfMicVolumeInput.value = String(savedSelfMicVolume()); selfMicMeter.value = 0; noiseSuppressionInput.checked = savedNoiseSuppression(); +applyAnnouncementVoiceSettings(savedAnnouncementVoiceSettings()); async function initializeApp() { const savedName = localStorage.getItem(nameStorageKey); diff --git a/public/index.html b/public/index.html index 07ec118..669b545 100644 --- a/public/index.html +++ b/public/index.html @@ -182,6 +182,24 @@ placeholder="Announcement text" required > +
+ + + + +
diff --git a/public/styles.css b/public/styles.css index c4c0cc9..3216b15 100644 --- a/public/styles.css +++ b/public/styles.css @@ -148,6 +148,26 @@ h1 { gap: 10px; } +.voice-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; +} + +.voice-controls label { + display: grid; + gap: 5px; + color: #aeb7c3; + font-size: 12px; + font-weight: 650; +} + +.voice-controls input { + width: 100%; + height: 18px; + padding: 0; +} + @media (max-width: 620px) { .captcha-row { grid-template-columns: 1fr; @@ -156,6 +176,10 @@ h1 { .captcha-control canvas { width: 100%; } + + .voice-controls { + grid-template-columns: 1fr; + } } input, @@ -496,6 +520,10 @@ button[aria-pressed="false"] { background: #171b20; } +.presentation-tile video { + object-fit: contain; +} + .tile-name { position: absolute; left: 12px; diff --git a/server.js b/server.js index 4b4f9b4..b2fe0bd 100644 --- a/server.js +++ b/server.js @@ -84,6 +84,22 @@ function normalizeAnnouncement(text) { return String(text || "").trim().replace(/\s+/g, " ").slice(0, 240); } +function clampNumber(value, fallback, min, max) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.min(max, parsed)); +} + +function normalizeVoiceSettings(settings = {}) { + const source = settings && typeof settings === "object" ? settings : {}; + return { + amplitude: clampNumber(source.amplitude, 100, 0, 200), + pitch: clampNumber(source.pitch, 32, 0, 99), + speed: clampNumber(source.speed, 135, 80, 240), + wordgap: clampNumber(source.wordgap, 4, 0, 20) + }; +} + function normalizeClientId(value) { const normalized = String(value || "").trim(); return /^[a-z0-9_-]{16,80}$/i.test(normalized) ? normalized : ""; @@ -294,6 +310,7 @@ const server = http.createServer(async (req, res) => { from: payload.from, name: normalizeName(payload.name), text, + voice: normalizeVoiceSettings(payload.voice), updatedAt: Date.now() }; broadcast(roomId, payload.from, event);