UNPKG

@fine-dev/fine-js

Version:

Javascript client for Fine BaaS

144 lines (120 loc) 4.89 kB
import { useSyncExternalStore } from "react" import { AIConfig } from "./ai" import { Fetch } from "./types" type Status = | "idle" | "recording" | "paused" | "recording_error" | "stopped" | "transcribing" | "transcription_error" | "transcribed" export class FineTranscriber { public error: Error | null = null private mediaRecorder: MediaRecorder | null = null private chunks: Blob[] = [] private file: File | null = null private statusListeners = new Set<(status: Status) => void>() private _status: Status = "idle" private config: AIConfig constructor({ baseUrl, headers, fetch: customFetch }: { baseUrl: string; fetch?: Fetch; headers?: HeadersInit }) { this.config = { baseUrl: baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl, fetch(input, init) { if (!customFetch) customFetch = fetch.bind(window) return customFetch(input, { ...init, headers, credentials: "include" }) } } } private createMediaRecorder(stream: MediaStream) { this.mediaRecorder = new MediaRecorder(stream) this.chunks = [] this.mediaRecorder.addEventListener("dataavailable", (e) => e.data.size > 0 && this.chunks.push(e.data)) this.mediaRecorder.addEventListener("error", (e) => this.raiseError("recording", e.error)) this.mediaRecorder.addEventListener("start", () => this.setStatus("recording")) this.mediaRecorder.addEventListener("pause", () => this.setStatus("paused")) this.mediaRecorder.addEventListener("resume", () => this.setStatus("recording")) this.mediaRecorder.addEventListener("stop", () => { const audioBlob = new Blob(this.chunks, { type: "audio/webm" }) this.file = new File([audioBlob], "recording.webm", { type: "audio/webm" }) this.setStatus("stopped") }) return this.mediaRecorder } private setStatus(status: Status) { this._status = status this.statusListeners.forEach((fn) => fn(status)) } private raiseError(phase: "transcription" | "recording", error: Error) { this.error = error this.setStatus(`${phase}_error`) throw this.error } get status() { return this._status } onStatusChange(callback: (status: Status) => void) { this.statusListeners.add(callback) return () => this.statusListeners.delete(callback) } recording = { start: () => { if (this.mediaRecorder && this.mediaRecorder.state === "paused") return this.mediaRecorder.resume() return navigator.mediaDevices .getUserMedia({ audio: true }) .then((stream) => { this.createMediaRecorder(stream).start() this.setStatus("recording") }) .catch((e) => { const error = e instanceof Error ? e : new Error(typeof e === "string" ? e : JSON.stringify(e)) this.raiseError("recording", error) }) }, stop: () => this.mediaRecorder && this.mediaRecorder.state !== "inactive" && this.mediaRecorder.stop(), pause: () => this.mediaRecorder && this.mediaRecorder.state === "recording" && this.mediaRecorder.pause() } fromFile(file: File) { if (this.mediaRecorder) { if (this.mediaRecorder.state === "recording") this.mediaRecorder.stop() this.mediaRecorder = null } this.file = file return this.transcribe() } async transcribe(): Promise<string> { this.recording.stop() await new Promise((resolve) => setTimeout(resolve)) if (!this.file) { this.raiseError("transcription", new Error("No audio to transcribe.")) throw this.error } this.setStatus("transcribing") const formData = new FormData() formData.append("audio", this.file) try { const res = await this.config.fetch(this.config.baseUrl, { method: "POST", body: formData }) if (!res.ok) { const msg = await res.text() this.raiseError("transcription", new Error(`Transcription failed: ${res.status} ${msg}`)) } const { text } = await res.json() this.setStatus("transcribed") return text } catch (err) { this.raiseError( "transcription", err instanceof Error ? err : new Error(typeof err === "string" ? err : JSON.stringify(err)) ) throw this.error } } useStatus() { return useSyncExternalStore( (cb) => this.onStatusChange(cb), () => this.status, () => "idle" ) } }