@fine-dev/fine-js
Version:
Javascript client for Fine BaaS
144 lines (120 loc) • 4.89 kB
text/typescript
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"
)
}
}