generated at
scrapbox-speech-input
キーボードをATOKからGoogle日本語入力に切り替えるのがとても面倒なので、サクッと音声入力を開始できるpage menuを作ったtakker

Firefoxでは使えない

2021-03-29 13:49:45 代替候補を選べるようにしてみた
mobileでは使えないみたい
2021-03-28 17:23:41 Chrome for Androidでの挙動を直した
二重に入力されないようにした
連続して入力できるようにした
音声認識中は Analyzing... を表示するようにした
何も入力されなかったら自動で終了するようにした

参考
この記事を元に後で書き換えたい
音声認識起動は onstart で、終了は onend で検知する

既知の問題
doneChrome for Androidだと、入力途中の文字列を取得できない?
あと二重に入力されたりする
SpeechRecognition.interimResultsSpeechRecognition.continuousはChrome for Androidだとバグっていて使えないみたい

実装したいこと
音声入力終了時に、自動的に改行が入るようにする
どのタイミングにするかが悩ましい
onresult ?
onend ?
代替変換候補を表示する

js
import('/api/code/programming-notes/scrapbox-speech-input/script.js') .then(({initialize}) => initialize());
script.js
import {insertText} from '../scrapbox-insert-text-2/script.js'; import {press} from '../scrapbox-keyboard-emulation/script.js'; import {hasSpeechRecognition} from '../SpeechRecognitionに対応しているか判定する/script.js'; import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js'; import {interimArea} from './interimViewer.js'; import '../scrapbox-suggest-container-3/script.js'; const id = 'interimResultsViewer'; const pageMenuId = 'speech input'; const icons = { on: 'https://i.gyazo.com/0562c6a405a29661f18d0fdf8840065d.png', off: 'https://img.icons8.com/ios/4096/microphone.png', }; let enable = false; let terminate = false; //強制停止フラグ let recognized = false; // 何らかの音声認識に成功したら立てる export function initialize() { if (!hasSpeechRecognition()) { console.info('SpeechRecognition is not available on the browser.'); return; } const viewer = interimArea(); scrapboxDOM.textInput.parentElement.append(viewer); const suggestBox = document.createElement('suggest-container'); scrapboxDOM.editor.append(suggestBox); let recognition = new SpeechRecognition(); recognition.maxAlternatives = 10; if (!isMobile()) { recognition.interimResults = true; recognition.continuous = true; } // mobile版では、入力中の表示を出す if(isMobile()) { recognition.onspeechstart = () => { const textInput = scrapboxDOM.textInput; viewer.textContent = 'Analyzing...'; viewer.show({ height: textInput.style.height, top: textInput.style.top, left: textInput.style.left, lineHeight: textInput.style.lineHeight, }); } } let waiting = undefined; recognition.onresult = async ({results}) => { recognized = true; const textInput = scrapboxDOM.textInput; const result = [...results].pop(); //console.log(result); if (result.isFinal) { viewer.hide(); suggestBox.hide(); if (result.length > 1) { suggestBox.replace(...[...result].map(({transcript}) => { return { title: transcript, onClick: async () => { suggestBox.hide(); await waiting; for (let i = 0; i < result[0].transcript.length; i++) { press('ArrowLeft', {shiftKey: true}); } waiting = insertText(transcript); }, }; })); suggestBox.position({ top: parseInt(textInput.style.top) + 14, left: parseInt(textInput.style.left), }); } await waiting; // 直前の入力が完了するまで待つ waiting = insertText(result[0].transcript); } else { suggestBox.hide(); // 暫定の認識結果 viewer.textContent = result[0].transcript; viewer.show({ height: textInput.style.height, top: textInput.style.top, left: textInput.style.left, lineHeight: textInput.style.lineHeight, }); } }; recognition.onstart = () => { enable = true; recognized = false; terminate = false; suggestBox.hide(); document.getElementById(pageMenuId).firstElementChild.src = icons.on; }; recognition.onend = () => { viewer.hide(); // mobileで音声入力を継続させる // 条件:page menuを押していない かつ 前回何らかの音声認識に成功した if (isMobile() && !terminate && recognized) { recognition.start(); return; } enable = false; document.getElementById(pageMenuId).firstElementChild.src = icons.off; }; scrapbox.PageMenu.addMenu({ title: pageMenuId, image: icons.off, onClick: () => { if (enable) { recognition.stop(); terminate = true; return; } recognition.start(); }, }); }

暫定結果を表示するUI
interimViewer.js
import {style, h} from '../easyDOMgenerator/script.js'; const css = ` :host { color: #777; display: inline-block; position: absolute; word-wrap: nowrap; min-width: 10em; } `; customElements.define('interim-area', class extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.innerHTML = `<style>${css}</style><slot></slot>`; } show({height, top, left, lineHeight}) { this.hidden = false; this.style.height = height; this.style.top = top; this.style.left = left; this.style.lineHeight = lineHeight ?? '18px'; } hide() { this.hidden = true; } }); export const interimArea = (...params) => h('interim-area', ...params);

script.js
const isMobile = () => /mobile/i.test(navigator.userAgent);

MDN
Qiita

#2021-03-28 16:25:14
#2021-03-27 21:48:58