Skip to content

Instantly share code, notes, and snippets.

@u1-liquid
Last active May 31, 2025 11:19
Show Gist options
  • Save u1-liquid/da1a04abf7a69f7229d107b03655b751 to your computer and use it in GitHub Desktop.
Save u1-liquid/da1a04abf7a69f7229d107b03655b751 to your computer and use it in GitHub Desktop.
VOICEVOX TTS for Google Meet
// ==UserScript==
// @name VOICEVOX TTS for Google Meet
// @namespace https://github.com/u1-liquid
// @version 1.0.4
// @description Google Meetのチャット送信メッセージをローカルのVOICEVOXで読み上げる
// @grant GM_xmlhttpRequest
// @author u1-liquid
// @source https://gist.github.com/u1-liquid/da1a04abf7a69f7229d107b03655b751
// @match https://meet.google.com/*
// @run-at document-body
// @connect localhost
// @connect synchthia-sounds.storage.googleapis.com
// @updateURL https://gist.github.com/u1-liquid/da1a04abf7a69f7229d107b03655b751/raw/meet-voicevox-tts.user.js
// @downloadURL https://gist.github.com/u1-liquid/da1a04abf7a69f7229d107b03655b751/raw/meet-voicevox-tts.user.js
// @supportURL https://gist.github.com/u1-liquid/da1a04abf7a69f7229d107b03655b751#new_comment_field
// ==/UserScript==
(async function() {
'use strict';
const ELEMENT_CHECK_INTERVAL = 1000;
const QUEUE_PLAY_INTERVAL = 300;
const ELEMENT_QUERY_SELECTOR = 'textarea';
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 lastElement = 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 handleKeydownEvent(event) {
if (event.keyCode != 13 || event.isComposing || event.repeat || event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) return;
const element = document.querySelector(ELEMENT_QUERY_SELECTOR);
if (!element) return;
let text = element.value.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(line);
}
}
setInterval(() => {
const element = document.querySelector(ELEMENT_QUERY_SELECTOR);
if (!element) return;
if (element !== lastElement) {
if (lastElement) {
console.log('[TTS] re-initialize EventListener');
try {
lastElement.removeEventListener('keydown', handleKeydownEvent);
} catch {
// ignore
}
} else {
console.log('[TTS] initialize EventListener');
}
element.addEventListener('keydown', handleKeydownEvent);
lastElement = element;
}
}, ELEMENT_CHECK_INTERVAL);
setInterval(() => {
if (!lastElement || queue.length == 0 || isPlaying) return;
const text = queue.shift();
const sound = sounds.find(item => item.names.includes(text));
if (sound === undefined) {
console.log(`[TTS] speak message: ${text}`);
speak(text);
} else {
console.log(`[TTS] play sound: ${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