Skip to content

Instantly share code, notes, and snippets.

@u1-liquid
Last active May 12, 2025 02:52
Show Gist options
  • Save u1-liquid/088e54bdc31a1b5845e5dd3b8961fac6 to your computer and use it in GitHub Desktop.
Save u1-liquid/088e54bdc31a1b5845e5dd3b8961fac6 to your computer and use it in GitHub Desktop.
VOICEVOX TTS for Gather.Town
// ==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