mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
feat: add /samsay command to utilize SAM speech synthesizer for text-to-speech functionality
This commit is contained in:
@@ -929,6 +929,7 @@ export function updateQuickCommands(mode) {
|
||||
container.innerHTML = `
|
||||
<button class="quick-cmd" onclick="runChatCommand('/help')" title="Show chat help">/help</button>
|
||||
<button class="quick-cmd" onclick="runChatCommand('/nick')" title="Change nickname">/nick</button>
|
||||
<button class="quick-cmd" onclick="runChatCommand('/samsay')" title="SAM speech">/samsay</button>
|
||||
<button class="quick-cmd" onclick="runChatCommand('/quit')" title="Exit chat">/quit</button>
|
||||
`;
|
||||
} else {
|
||||
@@ -958,6 +959,15 @@ export function runChatCommand(command) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === '/samsay') {
|
||||
// For /samsay, just fill in the command prefix
|
||||
inlineInput.value = '/samsay ';
|
||||
chatMode.inputLine = inlineInput.value;
|
||||
resetMentionAutocomplete();
|
||||
inlineInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
inlineInput.value = command;
|
||||
submitInlineInput();
|
||||
@@ -1017,6 +1027,14 @@ async function syncChatMessages(onlyNew = false) {
|
||||
recent.forEach(msg => {
|
||||
const color = getUserColor(msg.sender);
|
||||
term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`);
|
||||
|
||||
// If message starts with /samsay, speak it
|
||||
if (msg.text.startsWith('/samsay ')) {
|
||||
const samMessage = msg.text.substring(8).trim();
|
||||
if (samMessage && typeof window.samSpeak === 'function') {
|
||||
window.samSpeak(samMessage);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1037,6 +1055,14 @@ function renderChatMessage(msg) {
|
||||
// Write the new message
|
||||
term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`);
|
||||
|
||||
// If message starts with /samsay, speak it
|
||||
if (msg.text.startsWith('/samsay ')) {
|
||||
const samMessage = msg.text.substring(8).trim();
|
||||
if (samMessage && typeof window.samSpeak === 'function') {
|
||||
window.samSpeak(samMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Redraw separator
|
||||
term.writeln('─'.repeat(term.cols || 60));
|
||||
|
||||
|
||||
56
website/scripts/commands/samsay.js
Normal file
56
website/scripts/commands/samsay.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* SAM Say command - Use SAM (Software Automatic Mouth) to speak text
|
||||
*/
|
||||
|
||||
/**
|
||||
* SAM (Software Automatic Mouth) speech synthesizer instance
|
||||
* @type {object|null}
|
||||
*/
|
||||
let sam = null;
|
||||
|
||||
/**
|
||||
* Initialize SAM speech synthesizer with SAM voice
|
||||
* @returns {object|null} SAM instance or null if unavailable
|
||||
*/
|
||||
function initSam() {
|
||||
if (sam) return sam;
|
||||
if (typeof SamJs !== 'undefined') {
|
||||
// SAM preset: speed=72, pitch=64, mouth=128, throat=128
|
||||
sam = new SamJs({ speed: 72, pitch: 64, mouth: 128, throat: 128 });
|
||||
}
|
||||
return sam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using SAM
|
||||
* @param {string} text - Text to speak
|
||||
* @returns {void}
|
||||
*/
|
||||
function samSpeak(text) {
|
||||
const samInstance = initSam();
|
||||
if (samInstance) {
|
||||
try {
|
||||
samInstance.speak(text);
|
||||
} catch (_err) {
|
||||
// Silently fail if speech doesn't work
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
description: 'Use SAM speech synthesizer to speak text',
|
||||
execute: (term, writeClickable, VERSION, args) => {
|
||||
const message = args.join(' ');
|
||||
|
||||
if (!message) {
|
||||
return 'Usage: samsay <message>';
|
||||
}
|
||||
|
||||
if (typeof SamJs === 'undefined') {
|
||||
return 'SAM speech synthesizer is not available.';
|
||||
}
|
||||
|
||||
samSpeak(message);
|
||||
return `SAM says: ${message}`;
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import contactCmd from './scripts/commands/contact.js';
|
||||
import privacyCmd from './scripts/commands/privacy.js';
|
||||
import blueskyCmd from './scripts/commands/bluesky.js';
|
||||
import numbermatchCmd, { gameMode, processGameInput, handleTileClick } from './scripts/commands/numbermatch.js';
|
||||
import samsayCmd from './scripts/commands/samsay.js';
|
||||
|
||||
// Import Matrix client
|
||||
import {
|
||||
@@ -36,6 +37,44 @@ import {
|
||||
sendChatMessage
|
||||
} from './matrix-client.js';
|
||||
|
||||
/**
|
||||
* SAM (Software Automatic Mouth) speech synthesizer instance
|
||||
* @type {object|null}
|
||||
*/
|
||||
let sam = null;
|
||||
|
||||
/**
|
||||
* Initialize SAM speech synthesizer with SAM voice
|
||||
* @returns {object|null} SAM instance or null if unavailable
|
||||
*/
|
||||
function initSam() {
|
||||
if (sam) return sam;
|
||||
if (typeof SamJs !== 'undefined') {
|
||||
// SAM preset: speed=72, pitch=64, mouth=128, throat=128
|
||||
sam = new SamJs({ speed: 72, pitch: 64, mouth: 128, throat: 128 });
|
||||
}
|
||||
return sam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using SAM
|
||||
* @param {string} text - Text to speak
|
||||
* @returns {void}
|
||||
*/
|
||||
function samSpeak(text) {
|
||||
const samInstance = initSam();
|
||||
if (samInstance) {
|
||||
try {
|
||||
samInstance.speak(text);
|
||||
} catch (_err) {
|
||||
// Silently fail if speech doesn't work
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose samSpeak globally for matrix-client
|
||||
window.samSpeak = samSpeak;
|
||||
|
||||
// Version
|
||||
const VERSION = '1.0.0';
|
||||
|
||||
@@ -483,6 +522,10 @@ const commands = {
|
||||
description: numbermatchCmd.description,
|
||||
execute: (args) => numbermatchCmd.execute(term, writeClickable, VERSION, args, commandHistory)
|
||||
},
|
||||
samsay: {
|
||||
description: samsayCmd.description,
|
||||
execute: (args) => samsayCmd.execute(term, writeClickable, VERSION, args, commandHistory)
|
||||
},
|
||||
chat: {
|
||||
description: 'Connect to chat room',
|
||||
execute: async (args) => {
|
||||
@@ -829,7 +872,12 @@ function positionInlineInput() {
|
||||
|
||||
if (mentionSuggestions.style.display !== 'none') {
|
||||
mentionSuggestions.style.left = inlineInput.style.left;
|
||||
mentionSuggestions.style.top = ((cursorY * charHeight) - charHeight - 6) + 'px';
|
||||
|
||||
// Get the actual height of the suggestions box
|
||||
const suggestionsHeight = mentionSuggestions.offsetHeight || 0;
|
||||
|
||||
// Position it above the cursor line
|
||||
mentionSuggestions.style.top = ((cursorY * charHeight) - suggestionsHeight - 6) + 'px';
|
||||
mentionSuggestions.style.fontSize = term.options.fontSize + 'px';
|
||||
mentionSuggestions.style.lineHeight = charHeight + 'px';
|
||||
}
|
||||
@@ -864,6 +912,192 @@ function hideInlineInput() {
|
||||
mentionSuggestions.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Command autocomplete state
|
||||
*/
|
||||
const commandAutocomplete = {
|
||||
matches: [],
|
||||
index: -1,
|
||||
tokenStart: 0,
|
||||
tokenEnd: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Get command matches for autocomplete
|
||||
* @param {string} query - Command prefix to match
|
||||
* @param {boolean} isChatMode - Whether in chat mode
|
||||
* @returns {Array<{name: string, description: string}>} Matching commands
|
||||
*/
|
||||
function getCommandMatches(query, isChatMode) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
if (isChatMode) {
|
||||
// Chat commands
|
||||
const chatCommands = [
|
||||
{ name: '/help', description: 'Show chat commands' },
|
||||
{ name: '/nick', description: 'Change display name' },
|
||||
{ name: '/samsay', description: 'Send message with SAM speech' },
|
||||
{ name: '/quit', description: 'Exit chat mode' }
|
||||
];
|
||||
return chatCommands.filter(cmd => cmd.name.startsWith(lowerQuery));
|
||||
} else {
|
||||
// Terminal commands
|
||||
const terminalCommands = Object.keys(commands).map(name => ({
|
||||
name,
|
||||
description: commands[name].description || ''
|
||||
}));
|
||||
return terminalCommands.filter(cmd => cmd.name.toLowerCase().startsWith(lowerQuery));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if command suggestions are visible
|
||||
* @returns {boolean} True if suggestions are visible
|
||||
*/
|
||||
function hasVisibleCommandSuggestions() {
|
||||
return mentionSuggestions.style.display !== 'none'
|
||||
&& commandAutocomplete.matches.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset command autocomplete state
|
||||
* @returns {void}
|
||||
*/
|
||||
function resetCommandAutocomplete() {
|
||||
commandAutocomplete.matches = [];
|
||||
commandAutocomplete.index = -1;
|
||||
commandAutocomplete.tokenStart = 0;
|
||||
commandAutocomplete.tokenEnd = 0;
|
||||
mentionSuggestions.style.display = 'none';
|
||||
mentionSuggestions.innerHTML = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render command suggestions
|
||||
* @returns {void}
|
||||
*/
|
||||
function renderCommandSuggestions() {
|
||||
const { matches, index } = commandAutocomplete;
|
||||
|
||||
if (matches.length === 0) {
|
||||
mentionSuggestions.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const limitedMatches = matches.slice(0, 5);
|
||||
mentionSuggestions.innerHTML = limitedMatches.map((cmd, i) => {
|
||||
const selected = i === index ? ' selected' : '';
|
||||
return `<div class="mention-item${selected}">${cmd.name} <span style="opacity: 0.6">- ${cmd.description}</span></div>`;
|
||||
}).join('');
|
||||
|
||||
mentionSuggestions.style.display = 'block';
|
||||
positionInlineInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh command suggestions based on current input
|
||||
* @returns {void}
|
||||
*/
|
||||
function refreshCommandSuggestions() {
|
||||
const value = inlineInput.value;
|
||||
const cursorPos = inlineInput.selectionStart;
|
||||
|
||||
// Check if we're at the start with a "/" or just typing a command
|
||||
const isChatMode = chatMode.active;
|
||||
let query = '';
|
||||
let tokenStart = 0;
|
||||
let tokenEnd = cursorPos;
|
||||
|
||||
if (isChatMode) {
|
||||
// In chat mode, look for /command at start
|
||||
if (value.startsWith('/')) {
|
||||
const match = value.match(/^(\/\w*)/);
|
||||
if (match && cursorPos <= match[1].length) {
|
||||
query = match[1];
|
||||
tokenStart = 0;
|
||||
tokenEnd = match[1].length;
|
||||
} else {
|
||||
resetCommandAutocomplete();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
resetCommandAutocomplete();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// In terminal mode, look for command at start (no slash)
|
||||
if (cursorPos === value.length) {
|
||||
const match = value.match(/^(\w*)/);
|
||||
if (match && match[1].length > 0) {
|
||||
query = match[1];
|
||||
tokenStart = 0;
|
||||
tokenEnd = match[1].length;
|
||||
} else {
|
||||
resetCommandAutocomplete();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
resetCommandAutocomplete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const matches = getCommandMatches(query, isChatMode);
|
||||
|
||||
if (matches.length === 0) {
|
||||
resetCommandAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
commandAutocomplete.matches = matches;
|
||||
commandAutocomplete.index = -1;
|
||||
commandAutocomplete.tokenStart = tokenStart;
|
||||
commandAutocomplete.tokenEnd = tokenEnd;
|
||||
|
||||
renderCommandSuggestions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the selected command autocomplete
|
||||
* @returns {boolean} True if a command was applied
|
||||
*/
|
||||
function applyCommandAutocomplete() {
|
||||
if (!hasVisibleCommandSuggestions()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cycle through commands or select first
|
||||
if (commandAutocomplete.index < commandAutocomplete.matches.length - 1) {
|
||||
commandAutocomplete.index++;
|
||||
} else {
|
||||
commandAutocomplete.index = 0;
|
||||
}
|
||||
|
||||
const selected = commandAutocomplete.matches[commandAutocomplete.index];
|
||||
const fullValue = inlineInput.value;
|
||||
const valueAfter = fullValue.slice(commandAutocomplete.tokenEnd);
|
||||
const isChatMode = chatMode.active;
|
||||
|
||||
// For chat commands, include the slash; for terminal commands, don't
|
||||
const commandText = selected.name;
|
||||
const needsSpace = !valueAfter.startsWith(' ') && valueAfter.length > 0;
|
||||
const newValue = commandText + (needsSpace ? ' ' : '') + valueAfter;
|
||||
const newCursor = commandText.length + (needsSpace ? 1 : 0);
|
||||
|
||||
inlineInput.value = newValue;
|
||||
inlineInput.setSelectionRange(newCursor, newCursor);
|
||||
|
||||
if (isChatMode) {
|
||||
chatMode.inputLine = newValue;
|
||||
}
|
||||
|
||||
// Update token end for continued cycling
|
||||
commandAutocomplete.tokenEnd = commandText.length;
|
||||
|
||||
renderCommandSuggestions();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit current input
|
||||
*/
|
||||
@@ -889,6 +1123,7 @@ async function submitInlineInput() {
|
||||
term.writeln('\x1b[33mChat Commands:\x1b[0m');
|
||||
term.writeln(' /help - Show this help message');
|
||||
term.writeln(' /nick [name] - Change your display name');
|
||||
term.writeln(' /samsay [text] - Send message with SAM speech');
|
||||
term.writeln(' /quit - Exit chat mode');
|
||||
term.writeln('─'.repeat(term.cols || 60));
|
||||
term.write('\x1b[1;32m>\x1b[0m ');
|
||||
@@ -939,6 +1174,24 @@ async function submitInlineInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd.startsWith('/samsay ')) {
|
||||
const message = cmd.substring(8).trim();
|
||||
|
||||
if (!message) {
|
||||
// Move cursor up to separator, clear it
|
||||
term.write('\x1b[1A\x1b[2K\r');
|
||||
term.writeln('\x1b[31mError: /samsay [text]\x1b[0m');
|
||||
term.writeln('─'.repeat(term.cols || 60));
|
||||
term.write('\x1b[1;32m>\x1b[0m ');
|
||||
setTimeout(() => positionInlineInput(), 10);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send to chat (will play when message comes back)
|
||||
await sendChatMessage(cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd && !cmd.startsWith('/')) {
|
||||
// Don't write the message here - let sync handle it
|
||||
await sendChatMessage(cmd);
|
||||
@@ -1084,24 +1337,62 @@ async function init() {
|
||||
// Handle inline input events
|
||||
inlineInput.addEventListener('keydown', async (e) => {
|
||||
playTerminalKeySound(e);
|
||||
if (e.key === 'Escape' && hasVisibleMentionSuggestions()) {
|
||||
|
||||
// Check if we have visible suggestions (mentions OR commands)
|
||||
const hasMentions = hasVisibleMentionSuggestions();
|
||||
const hasCommands = hasVisibleCommandSuggestions();
|
||||
const hasSuggestions = hasMentions || hasCommands;
|
||||
|
||||
if (e.key === 'Escape' && hasSuggestions) {
|
||||
e.preventDefault();
|
||||
resetMentionAutocomplete();
|
||||
} else if (e.key === ' ' && hasVisibleMentionSuggestions()) {
|
||||
e.preventDefault();
|
||||
commitSelectedMentionSuggestion();
|
||||
} else if (e.key === 'Enter' && hasVisibleMentionSuggestions()) {
|
||||
if (hasMentions) resetMentionAutocomplete();
|
||||
if (hasCommands) resetCommandAutocomplete();
|
||||
} else if (e.key === ' ' && hasSuggestions) {
|
||||
e.preventDefault();
|
||||
if (hasMentions) commitSelectedMentionSuggestion();
|
||||
if (hasCommands) {
|
||||
resetCommandAutocomplete();
|
||||
inlineInput.value += ' ';
|
||||
if (chatMode.active) chatMode.inputLine = inlineInput.value;
|
||||
}
|
||||
} else if (e.key === 'Enter' && hasSuggestions) {
|
||||
e.preventDefault();
|
||||
if (hasMentions) {
|
||||
commitSelectedMentionSuggestion();
|
||||
} else if (hasCommands) {
|
||||
// Just accept the current command and submit
|
||||
resetCommandAutocomplete();
|
||||
submitInlineInput();
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitInlineInput();
|
||||
} else if (e.key === 'Tab' && chatMode.active) {
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (chatMode.active && hasMentions) {
|
||||
await applyMentionAutocomplete();
|
||||
} else if (hasCommands) {
|
||||
applyCommandAutocomplete();
|
||||
} else {
|
||||
// Trigger command suggestions on Tab
|
||||
refreshCommandSuggestions();
|
||||
if (hasVisibleCommandSuggestions()) {
|
||||
applyCommandAutocomplete();
|
||||
} else if (chatMode.active) {
|
||||
// Try mention autocomplete in chat
|
||||
await applyMentionAutocomplete();
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (commandHistory.length > 0) {
|
||||
if (hasCommands) {
|
||||
// Navigate command suggestions up
|
||||
if (commandAutocomplete.index > 0) {
|
||||
commandAutocomplete.index--;
|
||||
renderCommandSuggestions();
|
||||
}
|
||||
} else if (commandHistory.length > 0) {
|
||||
// Navigate command history
|
||||
if (historyIndex === -1 || historyIndex >= commandHistory.length) {
|
||||
historyIndex = commandHistory.length - 1;
|
||||
} else if (historyIndex > 0) {
|
||||
@@ -1111,7 +1402,13 @@ async function init() {
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (historyIndex < commandHistory.length - 1) {
|
||||
if (hasCommands) {
|
||||
// Navigate command suggestions down
|
||||
if (commandAutocomplete.index < commandAutocomplete.matches.length - 1) {
|
||||
commandAutocomplete.index++;
|
||||
renderCommandSuggestions();
|
||||
}
|
||||
} else if (historyIndex < commandHistory.length - 1) {
|
||||
historyIndex++;
|
||||
inlineInput.value = commandHistory[historyIndex] || '';
|
||||
} else {
|
||||
@@ -1126,6 +1423,9 @@ async function init() {
|
||||
chatMode.inputLine = inlineInput.value;
|
||||
await refreshMentionSuggestionsFromInput();
|
||||
}
|
||||
|
||||
// Refresh command suggestions if typing at the start
|
||||
refreshCommandSuggestions();
|
||||
});
|
||||
|
||||
// Click on terminal focuses input
|
||||
|
||||
Reference in New Issue
Block a user