Skip to content

Instantly share code, notes, and snippets.

@u1-liquid
Last active September 25, 2025 10:04
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.12
// @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;
fetch(`${SYNTHESIS_URL}?speaker=${SPEAKER_ID}`, {
method: 'POST',
headers: {
'accept': 'audio/wav',
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
})
.then(async res => {
if (!res.ok) {
const text = await res.text();
console.error('[TTS] synthesis error:', res.status, text);
isPlaying = false;
return;
}
const buffer = await res.arrayBuffer();
const blob = new Blob([buffer], { type: 'audio/wav' });
lastObjectURL = URL.createObjectURL(blob);
audio.src = lastObjectURL;
audio.volume = 0.5;
audio.play();
})
.catch(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);
})();
services:
voicevox_engine:
image: voicevox/voicevox_engine:nvidia-latest
pull_policy: always
restart: on-failure
gpus: all
expose:
- "50021"
networks: [app]
gateway:
image: nginx:latest
depends_on: [voicevox_engine]
restart: unless-stopped
ports:
- "127.0.0.1:50021:50021"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
networks: [app]
networks:
app: {}
events {}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 90;
client_max_body_size 100m;
server {
listen 50021;
location / {
if ($http_origin = '') { set $http_origin "*"; }
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "$http_access_control_request_headers" always;
add_header Access-Control-Allow-Private-Network "true" always;
if ($request_method = 'OPTIONS') { return 204; }
proxy_pass http://voicevox_engine:50021;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header origin "app://.";
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Access-Control-Allow-Methods;
proxy_hide_header Access-Control-Allow-Headers;
proxy_hide_header Access-Control-Allow-Private-Network;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment