Add announcement voice controls
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user