diff --git a/src/equicordplugins/vcNarratorCustom/index.tsx b/src/equicordplugins/vcNarratorCustom/index.tsx index e838dcc35..4b4ac2376 100644 --- a/src/equicordplugins/vcNarratorCustom/index.tsx +++ b/src/equicordplugins/vcNarratorCustom/index.tsx @@ -31,6 +31,7 @@ import { SearchableSelect, SelectedChannelStore, SelectedGuildStore, + showToast, useMemo, UserStore, VoiceStateStore, @@ -60,6 +61,8 @@ import { const cl = classNameFactory("vc-narrator-"); const API_BASE = "https://tiktok-tts-aio.exampleuser.workers.dev"; +const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "m4a", "aac", "flac", "wma"]; +const SOUND_PLACEHOLDER = "{{SOUND}}"; type LastApiCallStatus = { at: number; @@ -166,6 +169,98 @@ function TroubleshootingSettings() { ); } +function CustomSoundSettings({ soundKey, nameKey }: { soundKey: string; nameKey: string; }) { + const fileInputRef = React.useRef(null); + const [busy, setBusy] = React.useState(false); + const forceUpdate = useForceUpdater(); + const customSound = settings.store[soundKey]; + const customSoundName = settings.store[nameKey] ?? ""; + const hasCustomSound = typeof customSound === "string" && customSound.startsWith("data:audio/"); + + const uploadSound = async (event: React.ChangeEvent) => { + const file = event.currentTarget.files?.[0]; + if (!file) return; + + const extension = file.name.split(".").pop()?.toLowerCase(); + if (!extension || !AUDIO_EXTENSIONS.includes(extension)) { + showToast("Please choose an audio file."); + event.currentTarget.value = ""; + return; + } + + setBusy(true); + try { + const dataUri = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(String(reader.result ?? "")); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + if (!dataUri.startsWith("data:audio/")) { + showToast("Please choose an audio file."); + return; + } + + settings.store[soundKey] = dataUri; + settings.store[nameKey] = file.name; + forceUpdate(); + showToast("Custom sound saved."); + } catch { + showToast("Could not load that audio file."); + } finally { + event.currentTarget.value = ""; + setBusy(false); + } + }; + + const previewSound = () => { + if (!hasCustomSound) return; + + setBusy(true); + const audio = new Audio(customSound); + audio.volume = settings.store.volume; + audio.onended = () => setBusy(false); + audio.onerror = () => setBusy(false); + audio.play().catch(() => setBusy(false)); + }; + + return ( +
+ + {hasCustomSound ? `Selected: ${customSoundName || "Custom audio"}` : "No custom sound selected."} + + +
+ + + +
+
+ ); +} + interface VoiceState { userId: string; channelId?: string; @@ -183,6 +278,7 @@ interface QueueItem { userId?: string; interruptKey?: string; useDefaultVoice?: boolean; + sound?: string; } const mainQueue: QueueItem[] = []; const stateQueue: QueueItem[] = []; @@ -265,6 +361,7 @@ async function processQueue() { userId: intro.userId, interruptKey: action.interruptKey, useDefaultVoice: false, + sound: intro.sound ?? action.sound, }; mainQueue.push(combined); } @@ -275,7 +372,7 @@ async function processQueue() { onQueueChange?.(); try { - await speak(item.text, item.userId, item.interruptKey, item.useDefaultVoice); + await speak(item.text, item.userId, item.interruptKey, item.useDefaultVoice, item.sound); } catch (e) { console.error("TTS Error:", e); } @@ -288,7 +385,7 @@ async function processQueue() { }, delay); } -function queueSpeak(text: string, userId?: string, interruptKey?: string, queue: "main" | "state" = "main", useDefaultVoice?: boolean) { +function queueSpeak(text: string, userId?: string, interruptKey?: string, queue: "main" | "state" = "main", useDefaultVoice?: boolean, sound?: string) { if (text.trim().length === 0) return; const targetQueue = queue === "state" ? stateQueue : mainQueue; @@ -305,48 +402,45 @@ function queueSpeak(text: string, userId?: string, interruptKey?: string, queue: interruptPlayback(interruptKey); } - targetQueue.push({ text, userId, interruptKey, useDefaultVoice }); + targetQueue.push({ text, userId, interruptKey, useDefaultVoice, sound }); onQueueChange?.(); processQueue(); } -async function speak(text: string, userId?: string, interruptKey?: string, useDefaultVoice?: boolean): Promise { +async function speak(text: string, userId?: string, interruptKey?: string, useDefaultVoice?: boolean, sound?: string): Promise { return new Promise(resolve => { - const onEnd = () => { - if (currentStop === onEnd) { - currentAudio = null; - currentInterruptKey = undefined; - currentStop = null; - } - resolve(); - }; - - const playAudio = (url: string) => { + const playAudio = (url: string, playbackRate: number) => new Promise(resolveAudio => { const audio = new Audio(url); + let settled = false; + + const finish = (completed: boolean) => { + if (settled) return; + settled = true; + if (currentStop === stop) { + currentAudio = null; + currentInterruptKey = undefined; + currentStop = null; + } + resolveAudio(completed); + }; + + const stop = () => finish(false); + audio.volume = settings.store.volume; - audio.playbackRate = settings.store.rate; - audio.onended = onEnd; - audio.onerror = onEnd; + audio.playbackRate = playbackRate; + audio.onended = () => finish(true); + audio.onerror = stop; currentAudio = audio; currentInterruptKey = interruptKey; - currentStop = onEnd; - audio.play().catch(onEnd); - }; + currentStop = stop; + audio.play().catch(stop); + }); - void (async () => { - const voice = useDefaultVoice - ? DEFAULT_VOICE - : getVoiceForUser(userId, { - userVoiceMap: settings.store.userVoiceMap, - customVoice: settings.store.customVoice, - defaultVoice: DEFAULT_VOICE, - }); - - const cacheKey = `${voice}_${text}`; + const getTtsUrl = async (speechText: string, voice: string) => { + const cacheKey = `${voice}_${speechText}`; if (ttsCache.has(cacheKey)) { - playAudio(ttsCache.get(cacheKey)!); - return; + return ttsCache.get(cacheKey)!; } try { @@ -354,8 +448,7 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe if (cachedBlob) { const url = URL.createObjectURL(cachedBlob); ttsCache.set(cacheKey, url); - playAudio(url); - return; + return url; } } catch (err) { console.error("Error accessing IndexedDB:", err); @@ -369,7 +462,7 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe headers: { "Content-Type": "application/json" }, referrerPolicy: "no-referrer", body: JSON.stringify({ - text: text, + text: speechText, voice: voice, base64: true, }), @@ -378,8 +471,7 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe if (!response.ok) { console.error(`TTS failed: ${response.status}`); - resolve(); - return; + return null; } const audioData = atob((await response.text()).trim()); @@ -394,13 +486,50 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe ttsCache.set(cacheKey, url); setCachedVoiceInDB(cacheKey, blob).catch(console.error); - playAudio(url); + return url; } catch (e) { recordApiError(e); console.error("TTS Network Error:", e); - resolve(); + return null; } - })().catch(onEnd); + }; + + const playSpeech = async (speechText: string, voice: string) => { + const trimmedText = speechText.trim(); + if (!trimmedText) return true; + + const url = await getTtsUrl(trimmedText, voice); + return !url || await playAudio(url, settings.store.rate); + }; + + void (async () => { + const voice = useDefaultVoice + ? DEFAULT_VOICE + : getVoiceForUser(userId, { + userVoiceMap: settings.store.userVoiceMap, + customVoice: settings.store.customVoice, + defaultVoice: DEFAULT_VOICE, + }); + + const soundUrl = typeof sound === "string" && sound.startsWith("data:audio/") ? sound : ""; + const soundIndex = text.indexOf(SOUND_PLACEHOLDER); + + if (!soundUrl || soundIndex === -1) { + await playSpeech(text.replaceAll(SOUND_PLACEHOLDER, ""), voice); + resolve(); + return; + } + + const beforeSound = text.slice(0, soundIndex); + const afterSound = text.slice(soundIndex + SOUND_PLACEHOLDER.length).replaceAll(SOUND_PLACEHOLDER, ""); + + if (await playSpeech(beforeSound, voice)) { + const soundCompleted = await playAudio(soundUrl, 1); + if (soundCompleted) await playSpeech(afterSound, voice); + } + + resolve(); + })().catch(() => resolve()); }); } @@ -552,7 +681,12 @@ function playSample(tempSettings: any, type: string) { currentUser.globalName ?? currentUser.username, (myGuildId ? GuildMemberStore.getNick(myGuildId, currentUser.id) : null) ?? currentUser.username, settingsobj.latinOnly - ) + ), + undefined, + undefined, + "main", + undefined, + settingsobj[type + "Sound"] ); } @@ -595,19 +729,52 @@ const settings = definePluginSettings({ }, joinMessage: { type: OptionType.STRING, - description: "Placeholders: {{USER}}, {{DISPLAY_NAME}}, {{NICKNAME}}, {{CHANNEL}}", + description: "Placeholders: {{USER}}, {{DISPLAY_NAME}}, {{NICKNAME}}, {{CHANNEL}}, {{SOUND}}.", default: "{{DISPLAY_NAME}} joined", }, + joinSound: { + type: OptionType.COMPONENT, + component: () => , + default: "", + }, + joinSoundName: { + type: OptionType.STRING, + description: "Join sound file name.", + default: "", + hidden: true, + }, leaveMessage: { type: OptionType.STRING, - description: "Leave Message", + description: "Placeholders: {{USER}}, {{DISPLAY_NAME}}, {{NICKNAME}}, {{CHANNEL}}, {{SOUND}}.", default: "{{DISPLAY_NAME}} left", }, + leaveSound: { + type: OptionType.COMPONENT, + component: () => , + default: "", + }, + leaveSoundName: { + type: OptionType.STRING, + description: "Leave sound file name.", + default: "", + hidden: true, + }, moveMessage: { type: OptionType.STRING, - description: "Move Message", + description: "Placeholders: {{USER}}, {{DISPLAY_NAME}}, {{NICKNAME}}, {{CHANNEL}}, {{SOUND}}.", default: "{{DISPLAY_NAME}} moved to {{CHANNEL}}", }, + moveSound: { + type: OptionType.COMPONENT, + component: () => , + default: "", + }, + moveSoundName: { + type: OptionType.STRING, + description: "Move sound file name.", + default: "", + hidden: true, + }, announceOthersMute: { description: "Announce when other users mute/unmute in your current VC", type: OptionType.BOOLEAN, @@ -879,7 +1046,7 @@ export default definePlugin({ name: "VcNarratorCustom", description: "Announces when users join, leave, or move voice channels via narrator using TikTok TTS. Revamped and back from the dead.", tags: ["Accessibility", "Voice"], - authors: [Devs.Ven, Devs.Nyako, EquicordDevs.Loukios, EquicordDevs.examplegit], + authors: [Devs.Ven, Devs.Nyako, EquicordDevs.Loukios, EquicordDevs.examplegit, EquicordDevs.qdnx], settings, contextMenus: { "user-context": UserContextMenuPatch, @@ -979,7 +1146,14 @@ export default definePlugin({ const nickname = u && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName); const channel = ChannelStore.getChannel(id)?.name ?? "channel"; - queueSpeak(formatText(template, u, channel, displayName, nickname, settings.store.latinOnly), userId); + queueSpeak( + formatText(template, u, channel, displayName, nickname, settings.store.latinOnly), + userId, + undefined, + "main", + undefined, + settings.store[type + "Sound"] + ); } } diff --git a/src/equicordplugins/vcNarratorCustom/styles.css b/src/equicordplugins/vcNarratorCustom/styles.css index be0eac08b..32767303f 100644 --- a/src/equicordplugins/vcNarratorCustom/styles.css +++ b/src/equicordplugins/vcNarratorCustom/styles.css @@ -53,6 +53,21 @@ color: var(--text-muted); } +.vc-narrator-custom-sound { + display: flex; + flex-direction: column; + gap: 10px; +} + +.vc-narrator-custom-sound-text { + margin: 0; + opacity: 0.85; +} + +.vc-narrator-file-input { + display: none; +} + /* Voice select modal */ .vc-narrator-modal-title { flex-grow: 1; @@ -137,4 +152,4 @@ flex-wrap: wrap; gap: 6px; justify-content: center; -} \ No newline at end of file +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 49b5bdb77..7af8f8d13 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1041,6 +1041,10 @@ export const EquicordDevs = Object.freeze({ name: "Loukios", id: 211461918127292416n }, + qdnx: { + name: "qdnx", + id: 1374803023506702508n + }, vappstar: { name: "vappstar", id: 747192967311261748n