354 lines
8.9 KiB
TypeScript
354 lines
8.9 KiB
TypeScript
import i18n from './i18n'
|
|
|
|
export type SpeechRecognitionErrorCode =
|
|
| 'aborted'
|
|
| 'audio-capture'
|
|
| 'bad-grammar'
|
|
| 'language-not-supported'
|
|
| 'network'
|
|
| 'no-speech'
|
|
| 'not-allowed'
|
|
| 'phrases-not-supported'
|
|
| 'service-not-allowed'
|
|
|
|
export type SpeechRecognitionResultEvent = {
|
|
results: ArrayLike<{
|
|
0?: { transcript?: string }
|
|
}>
|
|
}
|
|
|
|
export type SpeechRecognitionErrorEvent = {
|
|
error?: SpeechRecognitionErrorCode | string
|
|
message?: string
|
|
}
|
|
|
|
export type SpeechRecognitionInstance = {
|
|
lang: string
|
|
interimResults: boolean
|
|
continuous: boolean
|
|
processLocally?: boolean
|
|
start: () => void
|
|
stop: () => void
|
|
onresult?: (event: SpeechRecognitionResultEvent) => void
|
|
onerror?: (event: SpeechRecognitionErrorEvent) => void
|
|
onend?: () => void
|
|
}
|
|
|
|
type LocalAvailability =
|
|
| 'available'
|
|
| 'downloadable'
|
|
| 'downloading'
|
|
| 'unavailable'
|
|
|
|
type LocalSpeechOptions = {
|
|
langs: string[]
|
|
processLocally: true
|
|
}
|
|
|
|
export type SpeechRecognitionConstructor = {
|
|
new (): SpeechRecognitionInstance
|
|
available?: (
|
|
options: LocalSpeechOptions
|
|
) => Promise<LocalAvailability>
|
|
install?: (options: LocalSpeechOptions) => Promise<boolean>
|
|
}
|
|
|
|
export type PreparedSpeechRecognition = {
|
|
recognition: SpeechRecognitionInstance
|
|
local: boolean
|
|
}
|
|
|
|
type SpeechPreparationTimeouts = {
|
|
availabilityMs?: number
|
|
installMs?: number
|
|
}
|
|
|
|
async function withTimeout<T>(
|
|
operation: Promise<T>,
|
|
timeoutMs: number,
|
|
message: string
|
|
): Promise<T> {
|
|
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
try {
|
|
return await Promise.race([
|
|
operation,
|
|
new Promise<never>((_, reject) => {
|
|
timeout = setTimeout(
|
|
() => reject(new Error(message)),
|
|
timeoutMs
|
|
)
|
|
})
|
|
])
|
|
} finally {
|
|
if (timeout) {
|
|
clearTimeout(timeout)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getSpeechRecognitionConstructor(
|
|
target: Window
|
|
): SpeechRecognitionConstructor | undefined {
|
|
const speechWindow = target as unknown as {
|
|
SpeechRecognition?: SpeechRecognitionConstructor
|
|
webkitSpeechRecognition?: SpeechRecognitionConstructor
|
|
}
|
|
return (
|
|
speechWindow.SpeechRecognition ??
|
|
speechWindow.webkitSpeechRecognition
|
|
)
|
|
}
|
|
|
|
export async function prepareSpeechRecognition(
|
|
Recognition: SpeechRecognitionConstructor,
|
|
lang: string,
|
|
onDownload?: () => void,
|
|
timeouts: SpeechPreparationTimeouts = {},
|
|
userAgent = navigator.userAgent
|
|
): Promise<PreparedSpeechRecognition> {
|
|
if (isElectronUserAgent(userAgent)) {
|
|
throw new Error(
|
|
i18n.t('composer.voice.serviceNotLoaded', { ns: 'app' })
|
|
)
|
|
}
|
|
const recognition = new Recognition()
|
|
const options: LocalSpeechOptions = {
|
|
langs: [lang],
|
|
processLocally: true
|
|
}
|
|
let local = false
|
|
|
|
if (Recognition.available) {
|
|
const availability = await withTimeout(
|
|
Recognition.available(options),
|
|
timeouts.availabilityMs ?? 5_000,
|
|
i18n.t('composer.voice.availabilityTimeout', { ns: 'app' })
|
|
)
|
|
if (availability === 'available') {
|
|
local = true
|
|
} else if (
|
|
availability === 'downloadable' &&
|
|
Recognition.install
|
|
) {
|
|
onDownload?.()
|
|
local = await withTimeout(
|
|
Recognition.install(options),
|
|
timeouts.installMs ?? 120_000,
|
|
i18n.t('composer.voice.downloadTimeout', { ns: 'app' })
|
|
)
|
|
} else if (availability === 'downloading') {
|
|
throw new Error(
|
|
i18n.t('composer.voice.packDownloading', { ns: 'app' })
|
|
)
|
|
}
|
|
}
|
|
|
|
if (local) {
|
|
recognition.processLocally = true
|
|
}
|
|
recognition.lang = lang
|
|
recognition.interimResults = false
|
|
recognition.continuous = false
|
|
return { recognition, local }
|
|
}
|
|
|
|
export type PcmRecordingResult = {
|
|
audio: ArrayBuffer
|
|
sampleRate: 16_000
|
|
}
|
|
|
|
export type PcmRecording = {
|
|
result: Promise<PcmRecordingResult>
|
|
stop: () => void
|
|
cancel: () => void
|
|
}
|
|
|
|
type AudioContextConstructor = new () => AudioContext
|
|
|
|
function recordingAbortError(): Error {
|
|
const error = new Error(
|
|
i18n.t('composer.voice.recordingCancelled', { ns: 'app' })
|
|
)
|
|
error.name = 'AbortError'
|
|
return error
|
|
}
|
|
|
|
export function resamplePcm(
|
|
samples: Float32Array,
|
|
sourceRate: number,
|
|
targetRate = 16_000
|
|
): Float32Array {
|
|
if (
|
|
samples.length === 0 ||
|
|
!Number.isFinite(sourceRate) ||
|
|
sourceRate <= 0 ||
|
|
!Number.isFinite(targetRate) ||
|
|
targetRate <= 0
|
|
) {
|
|
return new Float32Array()
|
|
}
|
|
if (sourceRate === targetRate) {
|
|
return samples.slice()
|
|
}
|
|
const outputLength = Math.max(
|
|
1,
|
|
Math.floor((samples.length * targetRate) / sourceRate)
|
|
)
|
|
const output = new Float32Array(outputLength)
|
|
const ratio = sourceRate / targetRate
|
|
for (let index = 0; index < outputLength; index += 1) {
|
|
const position = index * ratio
|
|
const leftIndex = Math.min(Math.floor(position), samples.length - 1)
|
|
const rightIndex = Math.min(leftIndex + 1, samples.length - 1)
|
|
const fraction = position - leftIndex
|
|
output[index] =
|
|
(samples[leftIndex] ?? 0) * (1 - fraction) +
|
|
(samples[rightIndex] ?? 0) * fraction
|
|
}
|
|
return output
|
|
}
|
|
|
|
export async function startPcmRecording(
|
|
mediaDevices: Pick<MediaDevices, 'getUserMedia'>,
|
|
AudioContextType: AudioContextConstructor,
|
|
maxSeconds = 20
|
|
): Promise<PcmRecording> {
|
|
const stream = await mediaDevices.getUserMedia({
|
|
audio: {
|
|
channelCount: 1,
|
|
echoCancellation: true,
|
|
noiseSuppression: true
|
|
},
|
|
video: false
|
|
})
|
|
let context: AudioContext | undefined
|
|
let source: MediaStreamAudioSourceNode | undefined
|
|
let processor: ScriptProcessorNode | undefined
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
let settled = false
|
|
const chunks: Float32Array[] = []
|
|
let sampleCount = 0
|
|
let resolveResult!: (result: PcmRecordingResult) => void
|
|
let rejectResult!: (reason: Error) => void
|
|
const result = new Promise<PcmRecordingResult>((resolve, reject) => {
|
|
resolveResult = resolve
|
|
rejectResult = reject
|
|
})
|
|
|
|
const cleanup = (): void => {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = undefined
|
|
}
|
|
processor?.disconnect()
|
|
source?.disconnect()
|
|
for (const track of stream.getTracks()) {
|
|
track.stop()
|
|
}
|
|
if (context) {
|
|
void context.close().catch(() => undefined)
|
|
}
|
|
}
|
|
const stop = (): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
cleanup()
|
|
if (!context || sampleCount === 0) {
|
|
rejectResult(
|
|
new Error(i18n.t('composer.voice.noRecording', { ns: 'app' }))
|
|
)
|
|
return
|
|
}
|
|
const combined = new Float32Array(sampleCount)
|
|
let offset = 0
|
|
for (const chunk of chunks) {
|
|
combined.set(chunk, offset)
|
|
offset += chunk.length
|
|
}
|
|
const resampled = resamplePcm(combined, context.sampleRate)
|
|
resolveResult({
|
|
audio: resampled.buffer as ArrayBuffer,
|
|
sampleRate: 16_000
|
|
})
|
|
}
|
|
const cancel = (): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
cleanup()
|
|
rejectResult(recordingAbortError())
|
|
}
|
|
|
|
try {
|
|
context = new AudioContextType()
|
|
source = context.createMediaStreamSource(stream)
|
|
processor = context.createScriptProcessor(4_096, 1, 1)
|
|
const maximumSamples = Math.ceil(
|
|
Math.min(context.sampleRate, 192_000) * maxSeconds
|
|
)
|
|
processor.onaudioprocess = (event) => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
const channel = event.inputBuffer.getChannelData(0)
|
|
const remaining = maximumSamples - sampleCount
|
|
if (remaining <= 0) {
|
|
stop()
|
|
return
|
|
}
|
|
const chunk = channel.slice(0, remaining)
|
|
chunks.push(chunk)
|
|
sampleCount += chunk.length
|
|
if (sampleCount >= maximumSamples) {
|
|
stop()
|
|
}
|
|
}
|
|
source.connect(processor)
|
|
processor.connect(context.destination)
|
|
timer = setTimeout(stop, maxSeconds * 1_000)
|
|
return { result, stop, cancel }
|
|
} catch (error) {
|
|
cleanup()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export function isElectronUserAgent(userAgent: string): boolean {
|
|
return /\bElectron\/[\d.]+\b/u.test(userAgent)
|
|
}
|
|
|
|
export function describeSpeechRecognitionError(
|
|
event: SpeechRecognitionErrorEvent
|
|
): string {
|
|
switch (event.error) {
|
|
case 'aborted':
|
|
return i18n.t('composer.voice.errors.aborted', { ns: 'app' })
|
|
case 'audio-capture':
|
|
return i18n.t('composer.voice.errors.audioCapture', {
|
|
ns: 'app'
|
|
})
|
|
case 'language-not-supported':
|
|
return i18n.t('composer.voice.errors.languageNotSupported', {
|
|
ns: 'app'
|
|
})
|
|
case 'network':
|
|
return i18n.t('composer.voice.errors.network', { ns: 'app' })
|
|
case 'no-speech':
|
|
return i18n.t('composer.voice.errors.noSpeech', { ns: 'app' })
|
|
case 'not-allowed':
|
|
case 'service-not-allowed':
|
|
return i18n.t('composer.voice.errors.permission', { ns: 'app' })
|
|
case 'phrases-not-supported':
|
|
return i18n.t('composer.voice.errors.phrasesNotSupported', {
|
|
ns: 'app'
|
|
})
|
|
case 'bad-grammar':
|
|
return i18n.t('composer.voice.errors.badGrammar', { ns: 'app' })
|
|
default:
|
|
return i18n.t('composer.voice.errors.generic', { ns: 'app' })
|
|
}
|
|
}
|