Skip to content
Closed
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
218 changes: 218 additions & 0 deletions apps/client/src/common/hooks/useCustomFieldTTS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { useEffect, useRef } from 'react';

import { useFlatRundownWithMetadata } from '../hooks-query/useRundown';
import useCustomFields from '../hooks-query/useCustomFields';

/**
* Parses time string in hh:mm:ss or mm:ss format to seconds
* @param timeStr - Time string to parse
* @returns Number of seconds, or null if invalid format
*/
function parseTimeToSeconds(timeStr: string): number | null {
if (!timeStr || typeof timeStr !== 'string') {
return null;
}

// Remove whitespace
const trimmed = timeStr.trim();

// Match hh:mm:ss or mm:ss format
const timePattern = /^(\d{1,2}):(\d{2}):(\d{2})$|^(\d{1,2}):(\d{2})$/;
const match = trimmed.match(timePattern);

if (!match) {
return null;
}

// hh:mm:ss format
if (match[1] !== undefined) {
const hours = parseInt(match[1], 10);
const minutes = parseInt(match[2], 10);
const seconds = parseInt(match[3], 10);
return hours * 3600 + minutes * 60 + seconds;
}

// mm:ss format
if (match[4] !== undefined) {
const minutes = parseInt(match[4], 10);
const seconds = parseInt(match[5], 10);
return minutes * 60 + seconds;
}

return null;
}

/**
* Gets available voices from Web Speech API
*/
function getAvailableVoices(): SpeechSynthesisVoice[] {
if (typeof window === 'undefined' || !window.speechSynthesis) {
return [];
}
return window.speechSynthesis.getVoices();
}

/**
* Finds a voice by URI or name
*/
function findVoice(voiceId: string, language: string): SpeechSynthesisVoice | null {
if (!voiceId) {
return null;
}

const voices = getAvailableVoices();
if (voices.length === 0) {
return null;
}

// Try to find by URI first
let voice = voices.find((v) => v.voiceURI === voiceId);
if (voice) {
return voice;
}

// Try to find by name
voice = voices.find((v) => v.name === voiceId);
if (voice) {
return voice;
}

// Fallback to first voice matching the language
voice = voices.find((v) => v.lang.startsWith(language.split('-')[0]));
if (voice) {
return voice;
}

// Last resort: return first available voice
return voices[0] || null;
}

/**
* Hook to monitor custom fields and read aloud time values using TTS
* Only works in cuesheet view
*/
export function useCustomFieldTTS() {
const { data: rundown } = useFlatRundownWithMetadata();
const { data: customFields } = useCustomFields();

const previousValuesRef = useRef<Map<string, string>>(new Map());
const speechSynthesisRef = useRef<SpeechSynthesis | null>(null);
const isSpeakingRef = useRef(false);

useEffect(() => {
// Check if Web Speech API is available
if (typeof window === 'undefined' || !window.speechSynthesis) {
console.warn('Web Speech API not available');
return;
}

speechSynthesisRef.current = window.speechSynthesis;

if (!customFields) {
return;
}

// Process each entry in the rundown
rundown.forEach((entry) => {
if (!entry.custom) {
return;
}

// Check each custom field
Object.entries(entry.custom).forEach(([fieldKey, fieldValue]) => {
if (!fieldValue || typeof fieldValue !== 'string') {
return;
}

// Get TTS settings for this custom field
const fieldConfig = customFields[fieldKey];
if (!fieldConfig?.tts?.enabled) {
return;
}

const ttsSettings = fieldConfig.tts;
if (!ttsSettings) {
return;
}

// Create a unique key for this entry+field combination
const entryFieldKey = `${entry.id}-${fieldKey}`;
const previousValue = previousValuesRef.current.get(entryFieldKey);

// Only process if value has changed
if (previousValue === fieldValue) {
return;
}

// Update the previous value
previousValuesRef.current.set(entryFieldKey, fieldValue);

// Parse time to seconds
const seconds = parseTimeToSeconds(fieldValue);
if (seconds === null) {
return;
}

// Check if seconds are below threshold
if (seconds > ttsSettings.threshold) {
return;
}

// Format the time to read just the number
const secondsText = `${seconds}`;

// Cancel any pending speech to prevent queue issues
// This helps prevent Google voices from skipping numbers
if (speechSynthesisRef.current.speaking || speechSynthesisRef.current.pending) {
speechSynthesisRef.current.cancel();
}

// Small delay to ensure cancellation is processed before speaking
// This prevents the "every second number" issue with Google voices
setTimeout(() => {
// Check again if we should still speak (value might have changed)
const currentValue = previousValuesRef.current.get(entryFieldKey);
if (currentValue !== fieldValue) {
return; // Value changed, don't speak
}

// Speak the time value
isSpeakingRef.current = true;

const utterance = new SpeechSynthesisUtterance(secondsText);
utterance.lang = ttsSettings.language || 'en-US';

// Set speech rate to be faster (1.0 is normal, 1.5-2.0 is faster)
// Using 1.1 for a slight speed increase
utterance.rate = 1.1;

// Set voice if available
const voice = findVoice(ttsSettings.voice, ttsSettings.language);
if (voice) {
utterance.voice = voice;
}

utterance.onend = () => {
isSpeakingRef.current = false;
};

utterance.onerror = () => {
isSpeakingRef.current = false;
};

speechSynthesisRef.current.speak(utterance);
}, 100);
});
});
}, [rundown, customFields]);

// Cleanup: cancel any ongoing speech when component unmounts
useEffect(() => {
return () => {
if (speechSynthesisRef.current) {
speechSynthesisRef.current.cancel();
isSpeakingRef.current = false;
}
};
}, []);
}
35 changes: 35 additions & 0 deletions apps/client/src/common/hooks/useVoices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';

/**
* Hook to get available voices from Web Speech API
*/
export function useVoices(): SpeechSynthesisVoice[] {
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);

useEffect(() => {
if (typeof window === 'undefined' || !window.speechSynthesis) {
return;
}

const loadVoices = () => {
const availableVoices = window.speechSynthesis.getVoices();
setVoices(availableVoices);
};

// Load voices immediately
loadVoices();

// Some browsers load voices asynchronously
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = loadVoices;
}

return () => {
if (window.speechSynthesis.onvoiceschanged) {
window.speechSynthesis.onvoiceschanged = null;
}
};
}, []);

return voices;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import IconButton from '../../../../../common/components/buttons/IconButton';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import Tag from '../../../../../common/components/tag/Tag';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import * as Panel from '../../../panel-utils/PanelUtils';

import CustomFieldForm from './CustomFieldForm';
Expand All @@ -23,6 +24,7 @@ interface CustomFieldEntryProps {

export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, fieldKey, type, onEdit, onDelete } = props;
const { data } = useCustomFields();
const [isEditing, setIsEditing] = useState(false);

const handleEdit = async (patch: CustomField) => {
Expand All @@ -31,6 +33,8 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
};

if (isEditing) {
// Get the full field data to pass TTS settings
const fullField = data?.[fieldKey];
return (
<tr>
<td colSpan={99}>
Expand All @@ -40,6 +44,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
initialColour={colour}
initialLabel={label}
initialKey={fieldKey}
initialTTS={fullField?.tts}
/>
</td>
</tr>
Expand Down
Loading