scrapbox-speech-input
キーボードをATOKからGoogle日本語入力に切り替えるのがとても面倒なので、サクッと音声入力を開始できるpage menuを作った
Firefoxでは使えない
2021-03-29 13:49:45 代替候補を選べるようにしてみた
mobileでは使えないみたい
2021-03-28 17:23:41 Chrome for Androidでの挙動を直した
二重に入力されないようにした
連続して入力できるようにした
音声認識中は Analyzing...
を表示するようにした
何も入力されなかったら自動で終了するようにした
参考
この記事を元に後で書き換えたい
音声認識起動は onstart
で、終了は onend
で検知する
既知の問題
Chrome for Androidだと、入力途中の文字列を取得できない?
あと二重に入力されたりする
実装したいこと
音声入力終了時に、自動的に改行が入るようにする
どのタイミングにするかが悩ましい
onresult
?
onend
?
代替変換候補を表示する
jsimport('/api/code/programming-notes/scrapbox-speech-input/script.js')
.then(({initialize}) => initialize());
script.jsimport {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.jsimport {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.jsconst isMobile = () => /mobile/i.test(navigator.userAgent);