Add announcement voice controls

This commit is contained in:
codex
2026-06-12 21:47:49 +02:00
parent f303711d45
commit cb736a051a
5 changed files with 194 additions and 13 deletions
+15
View File
@@ -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)))))))
+116 -13
View File
@@ -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);
+18
View File
@@ -182,6 +182,24 @@
placeholder="Announcement text"
required
></textarea>
<div class="voice-controls" aria-label="Announcement voice settings">
<label>
<span>Volume</span>
<input id="announcement-amplitude" type="range" min="0" max="200" step="1" value="100" />
</label>
<label>
<span>Pitch</span>
<input id="announcement-pitch" type="range" min="0" max="99" step="1" value="32" />
</label>
<label>
<span>Speed</span>
<input id="announcement-speed" type="range" min="80" max="240" step="1" value="135" />
</label>
<label>
<span>Word gap</span>
<input id="announcement-wordgap" type="range" min="0" max="20" step="1" value="4" />
</label>
</div>
<div class="dialog-actions">
<button id="announcement-cancel-button" type="button">Cancel</button>
<button type="submit">Send</button>
+28
View File
@@ -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;
+17
View File
@@ -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);