Last active
May 31, 2025 11:19
-
-
Save u1-liquid/da1a04abf7a69f7229d107b03655b751 to your computer and use it in GitHub Desktop.
VOICEVOX TTS for Google Meet
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 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