Last active
May 12, 2025 02:52
-
-
Save u1-liquid/088e54bdc31a1b5845e5dd3b8961fac6 to your computer and use it in GitHub Desktop.
VOICEVOX TTS for Gather.Town
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name VOICEVOX TTS for Gather.Town | |
// @namespace https://github.com/u1-liquid | |
// @version 1.0.5 | |
// @description Gather.Townのチャット送信メッセージをローカルのVOICEVOXで読み上げる | |
// @grant GM_xmlhttpRequest | |
// @author u1-liquid | |
// @source https://gist.github.com/u1-liquid/088e54bdc31a1b5845e5dd3b8961fac6 | |
// @match https://app.gather.town/* | |
// @run-at document-body | |
// @connect localhost | |
// @connect synchthia-sounds.storage.googleapis.com | |
// @updateURL https://gist.github.com/u1-liquid/088e54bdc31a1b5845e5dd3b8961fac6/raw/gather-voicevox-tts.user.js | |
// @downloadURL https://gist.github.com/u1-liquid/088e54bdc31a1b5845e5dd3b8961fac6/raw/gather-voicevox-tts.user.js | |
// @supportURL https://gist.github.com/u1-liquid/088e54bdc31a1b5845e5dd3b8961fac6#new_comment_field | |
// ==/UserScript== | |
(async function() { | |
'use strict'; | |
const WINDOW_CHECK_INTERVAL = 1000; | |
const QUEUE_PLAY_INTERVAL = 300; | |
const WINDOW_QUERY_SELECTOR = 'div .sendbird-conversation__messages-padding'; | |
const MESSAGE_SELECTOR = 'div .sendbird-message-content__middle__message-item-body.sendbird-text-message-item-body.outgoing'; | |
const AUDIO_QUERY_URL = 'http://localhost:50021/audio_query'; | |
const SYNTHESIS_URL = 'http://localhost:50021/synthesis'; | |
const SPEAKER_ID = 14; | |
const SPEED_SCALE = 1.25; | |
const VOLUME_SCALE = 0.7; | |
const OUTPUT_LABEL = 'Line TTS (Virtual Audio Cable)'; | |
const audio = new Audio(); | |
const queue = []; | |
let isPlaying = false; | |
let sounds = []; | |
let lastObjectURL = null; | |
let lastContainer = null; | |
let lastMessageId = '0'; | |
let observer = null; | |
const cleanupAudio = (event) => { | |
isPlaying = false; | |
if (lastObjectURL) { | |
URL.revokeObjectURL(lastObjectURL); | |
lastObjectURL = null; | |
} | |
}; | |
audio.onpause = cleanupAudio; | |
audio.onended = cleanupAudio; | |
audio.onerror = cleanupAudio; | |
try { | |
const devices = await navigator.mediaDevices.enumerateDevices(); | |
const audioOutputs = devices.filter(d => d.kind === 'audiooutput'); | |
const target = audioOutputs.find(d => d.label === OUTPUT_LABEL); | |
if (target) { | |
audio.setSinkId(target.deviceId); | |
console.log('[TTS] found output device:', target.label); | |
} else { | |
console.warn(`[TTS] '${OUTPUT_LABEL}' device not found; using default output.`); | |
} | |
} catch (err) { | |
console.error('[TTS] error enumerating audio devices:', err); | |
} | |
GM_xmlhttpRequest({ | |
method: 'GET', | |
url: 'https://synchthia-sounds.storage.googleapis.com/index.json', | |
headers: { 'accept': 'application/json' }, | |
onload: function(res) { | |
if (res.status !== 200) { | |
console.error('[TTS] sozai.dev error:', res.status, res.responseText); | |
sounds = []; | |
return; | |
} | |
try { | |
sounds = JSON.parse(res.responseText); | |
console.log('[TTS] sozai.dev loaded:', sounds.length); | |
} catch (e) { | |
console.error('[TTS] sozai.dev JSON parse error:', e); | |
sounds = []; | |
return; | |
} | |
}, | |
onerror: function(err) { | |
console.error('[TTS] sozai.dev network error:', err); | |
sounds = []; | |
} | |
}); | |
function speak(text) { | |
isPlaying = true; | |
GM_xmlhttpRequest({ | |
method: 'POST', | |
url: AUDIO_QUERY_URL + `?text=${encodeURIComponent(text)}&speaker=${SPEAKER_ID}`, | |
onload: function(res1) { | |
if (res1.status !== 200) { | |
console.error('[TTS] audio_query error:', res1.status, res1.responseText); | |
isPlaying = false; | |
return; | |
} | |
let params; | |
try { | |
params = JSON.parse(res1.responseText); | |
} catch (e) { | |
console.error('[TTS] audio_query JSON parse error:', e); | |
isPlaying = false; | |
return; | |
} | |
// パラメータ調整 | |
params.speedScale = SPEED_SCALE; | |
params.volumeScale = VOLUME_SCALE; | |
GM_xmlhttpRequest({ | |
method: 'POST', | |
url: SYNTHESIS_URL + `?speaker=${SPEAKER_ID}`, | |
headers: { 'Content-Type': 'application/json' }, | |
data: JSON.stringify(params), | |
responseType: 'arraybuffer', | |
onload: function(res2) { | |
if (res2.status !== 200) { | |
console.error('[TTS] synthesis error:', res2.status, res2.responseText); | |
isPlaying = false; | |
return; | |
} | |
const blob = new Blob([res2.response], { type: 'audio/wav' }); | |
lastObjectURL = URL.createObjectURL(blob); | |
audio.src = lastObjectURL; | |
audio.volume = 1.00; | |
audio.play(); | |
}, | |
onerror: function(err) { | |
console.error('[TTS] synthesis network error:', err); | |
isPlaying = false; | |
} | |
}); | |
}, | |
onerror: function(err) { | |
console.error('[TTS] audio_query network error:', err); | |
isPlaying = false; | |
} | |
}); | |
} | |
function handleMutations(mutationList) { | |
for (const mutationRecord of mutationList) { | |
for (const element of mutationRecord.addedNodes) { | |
if (Number.parseInt(element.attributes['data-sb-created-at'].value) < (Date.now() - 1000)) return; | |
const message = element.querySelector(MESSAGE_SELECTOR); | |
if (!message) return; | |
const messageId = element.attributes['data-sb-message-id'].value; | |
if (messageId === '0' || lastMessageId === messageId) return; | |
lastMessageId = messageId; | |
let text = message.innerText.trim(); | |
if (!text || text.startsWith('.')) return; | |
if (text === '/skip' || text === '/stop') { | |
console.log('[TTS] command:', text); | |
if (text === '/stop') { queue.length = 0; } | |
audio.pause(); | |
return; | |
} | |
text = text.replace(/\bhttps?:\/\/\S+\b/g, 'URL'); | |
for (const line of text.split('\n')) { | |
queue.push({ messageId, text: line }); | |
} | |
} | |
} | |
} | |
setInterval(() => { | |
const container = document.querySelector(WINDOW_QUERY_SELECTOR); | |
if (!container) return; | |
if (container !== lastContainer) { | |
if (observer) { | |
console.log('[TTS] re-initialize MutationObserver'); | |
observer.disconnect(); | |
} else { | |
console.log('[TTS] initialize MutationObserver'); | |
} | |
lastContainer = container; | |
observer = new MutationObserver(handleMutations); | |
observer.observe(container, { childList: true }); | |
handleMutations([{ addedNodes: container.childNodes }]); | |
} | |
}, WINDOW_CHECK_INTERVAL); | |
setInterval(() => { | |
if (!observer || queue.length == 0 || isPlaying) return; | |
const { messageId, text } = queue.shift(); | |
const sound = sounds.find(item => item.names.includes(text)); | |
if (sound === undefined) { | |
console.log(`[TTS] speak message: id(${messageId}) ${text}`); | |
speak(text); | |
} else { | |
console.log(`[TTS] play sound: id(${messageId}) ${text}(${sound.url})`); | |
isPlaying = true; | |
audio.src = sound.url; | |
audio.volume = 0.2; | |
audio.play(); | |
} | |
}, QUEUE_PLAY_INTERVAL); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment