use-stt
Version:
React hook for speech-to-text using multiple STT providers
658 lines (652 loc) • 20.5 kB
JavaScript
// src/hooks/useSTT.ts
import { useState as useState2, useCallback as useCallback2, useRef as useRef2, useEffect } from "react";
// src/audio/recorder.ts
var AudioRecorder = class {
constructor(options) {
this.mediaRecorder = null;
this.stream = null;
this.options = options;
}
getSupportedMimeType() {
const mimeTypes = [
"audio/webm",
"audio/mp4",
"audio/ogg",
"audio/wav"
];
if (this.options.mimeType && MediaRecorder.isTypeSupported(this.options.mimeType)) {
return this.options.mimeType;
}
const supportedType = mimeTypes.find((type) => MediaRecorder.isTypeSupported(type));
if (!supportedType) {
throw new Error("No supported audio MIME types found");
}
return supportedType;
}
async start() {
try {
if (this.mediaRecorder?.state === "recording") {
throw new Error("Recording already in progress");
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 16e3,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
const mimeType = this.getSupportedMimeType();
console.log("Creating MediaRecorder with options:", {
mimeType,
platform: typeof window !== "undefined" ? window.navigator.platform : "unknown",
userAgent: typeof window !== "undefined" ? window.navigator.userAgent : "unknown"
});
this.stream = stream;
this.mediaRecorder = new MediaRecorder(stream, { mimeType });
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0 && this.options.onDataAvailable) {
console.log("MediaRecorder data available:", {
size: event.data.size,
type: event.data.type
});
this.options.onDataAvailable(event.data);
}
};
this.mediaRecorder.onerror = (event) => {
const error = event.error || new Error("MediaRecorder error");
if (this.options.onError) {
this.options.onError(error);
}
};
this.mediaRecorder.start();
console.log("MediaRecorder started");
} catch (error) {
console.error("Error starting MediaRecorder:", error);
if (this.options.onError) {
this.options.onError(error);
}
throw error;
}
}
async stop() {
return new Promise((resolve, reject) => {
if (!this.mediaRecorder) {
reject(new Error("MediaRecorder not initialized"));
return;
}
console.log("Stopping MediaRecorder");
const handleStop = () => {
this.cleanup();
resolve();
};
const handleError = (event) => {
this.cleanup();
reject(new Error("MediaRecorder error"));
};
this.mediaRecorder.onstop = handleStop;
this.mediaRecorder.onerror = handleError;
this.mediaRecorder.stop();
});
}
pause() {
if (this.mediaRecorder?.state === "recording") {
this.mediaRecorder.pause();
}
}
resume() {
if (this.mediaRecorder?.state === "paused") {
this.mediaRecorder.resume();
}
}
cleanup() {
if (this.stream) {
this.stream.getTracks().forEach((track) => track.stop());
this.stream = null;
}
this.mediaRecorder = null;
}
};
// src/adapters/baseAdapter.ts
var BaseError = class extends Error {
constructor(message) {
super(message);
this.name = "BaseError";
}
};
var BaseAdapter = class {
constructor(options) {
this.recorder = null;
console.log("Initializing BaseAdapter");
this.options = options;
if (options.onResult) this.resultCallback = options.onResult;
if (options.onError) this.errorCallback = options.onError;
if (options.onStart) this.startCallback = options.onStart;
if (options.onEnd) this.endCallback = options.onEnd;
}
handleError(error) {
const err = error instanceof Error ? error : new BaseError("Unknown error");
this.errorCallback?.(err);
this.endCallback?.();
if (this.recorder) {
this.recorder.stop().catch(console.error);
this.recorder = null;
}
throw err;
}
async start() {
try {
console.log("Starting adapter...");
this.recorder = new AudioRecorder({
mimeType: "audio/webm",
onDataAvailable: async (data) => {
try {
console.log("Processing complete recording:", {
size: data.size,
type: data.type
});
const result = await this.processAudio(data);
this.resultCallback?.(result);
this.endCallback?.();
} catch (error) {
console.error("Error processing audio:", error);
this.handleError(error);
}
},
onError: (error) => this.handleError(error)
});
this.startCallback?.();
await this.recorder.start();
} catch (error) {
console.error("Error in start():", error);
this.handleError(error);
}
}
async stop() {
try {
if (!this.recorder) return;
console.log("Stopping recording...");
const currentRecorder = this.recorder;
await currentRecorder.stop();
this.recorder = null;
} catch (error) {
console.error("Error in stop():", error);
this.handleError(error);
}
}
pause() {
if (this.recorder) {
this.recorder.pause();
}
}
resume() {
if (this.recorder) {
this.recorder.resume();
}
}
async abort() {
console.log("Cleaning up adapter...");
if (this.recorder) {
try {
await this.recorder.stop();
this.recorder = null;
} catch (error) {
console.error("Error in abort():", error);
}
}
}
// Callback setters
onResult(callback) {
this.resultCallback = callback;
}
onError(callback) {
this.errorCallback = callback;
}
onStart(callback) {
this.startCallback = callback;
}
onEnd(callback) {
this.endCallback = callback;
}
};
// src/utils/audioConverter.ts
import { fetchFile } from "@ffmpeg/util";
var SUPPORTED_INPUT_FORMATS = [
"audio/webm",
"audio/mp4",
"audio/wav",
"audio/ogg",
"audio/mpeg"
];
function validateAudioFormat(blob) {
const mainType = blob.type.split(";")[0];
if (!SUPPORTED_INPUT_FORMATS.includes(mainType)) {
throw new Error(`Unsupported audio format: ${blob.type}. Supported formats are: ${SUPPORTED_INPUT_FORMATS.join(", ")}`);
}
}
async function convertAudioToWebM(ffmpeg, file, config = {}) {
console.log("Starting audio conversion to WebM...", {
fileType: file.type,
fileSize: file.size,
config
});
validateAudioFormat(file);
try {
console.log("Writing input file...");
const fileData = await fetchFile(file);
await ffmpeg.writeFile("input", fileData);
console.log("Input file written successfully");
console.log("Converting to WebM...");
const args = [
"-i",
"input",
"-ar",
`${config.outputSampleRate || 16e3}`,
// Sample rate
"-ac",
`${config.outputChannels || 1}`
// Channels
];
const filters = [];
if (config.denoise) {
filters.push("anlmdn");
}
if (config.normalize) {
filters.push(`loudnorm=I=${config.normalizationLevel || -16}`);
}
if (config.vad) {
filters.push(`silenceremove=start_periods=1:start_duration=1:start_threshold=${config.vadLevel || 1}:detection=peak`);
}
if (config.filters && config.filters.length > 0) {
filters.push(...config.filters);
}
if (filters.length > 0) {
args.push("-af", filters.join(","));
}
args.push(
"-c:a",
"libopus",
"-b:a",
config.bitrate || "24k",
"-compression_level",
`${config.compressionLevel || 10}`
);
args.push("output.webm");
console.log("FFmpeg conversion args:", args);
await ffmpeg.exec(args);
console.log("FFmpeg conversion completed");
const data = await ffmpeg.readFile("output.webm");
await ffmpeg.deleteFile("input");
await ffmpeg.deleteFile("output.webm");
const blob = new Blob([data], { type: "audio/webm;codecs=opus" });
console.log("Conversion complete:", {
inputSize: file.size,
outputSize: blob.size,
outputType: blob.type,
appliedFilters: filters
});
return blob;
} catch (error) {
console.error("Audio conversion failed:", {
error,
message: error instanceof Error ? error.message : "Unknown error",
stack: error instanceof Error ? error.stack : void 0
});
throw error;
}
}
async function convertAudioToMono(ffmpeg, file) {
return convertAudioToWebM(ffmpeg, file);
}
async function convertWithFFmpeg(ffmpeg, file, config = {}) {
console.log("Starting FFmpeg conversion with config:", config);
try {
console.log("Writing input file...");
await ffmpeg.writeFile("input", await fetchFile(file));
const args = [
"-i",
"input",
"-ar",
`${config.outputSampleRate || 16e3}`,
"-ac",
`${config.outputChannels || 1}`
];
if (config.normalize) {
args.push("-filter:a", "loudnorm");
}
args.push("-c:a", config.codec || "pcm_s16le");
const outputFile = `output.${config.outputFormat || "wav"}`;
args.push(outputFile);
console.log("Executing FFmpeg command with args:", args);
await ffmpeg.exec(args);
console.log("Reading converted file...");
const data = await ffmpeg.readFile(outputFile);
console.log("Cleaning up...");
await ffmpeg.deleteFile("input");
await ffmpeg.deleteFile(outputFile);
const blob = new Blob([data], {
type: `audio/${config.outputFormat || "wav"}`
});
console.log("Conversion complete. Output size:", blob.size);
return blob;
} catch (error) {
console.error("FFmpeg conversion error:", error);
throw new Error("Audio conversion failed: " + (error instanceof Error ? error.message : "Unknown error"));
}
}
async function getAudioMetadata(ffmpeg, file) {
console.log("Getting audio metadata...");
try {
console.log("Writing input file...");
await ffmpeg.writeFile("input", await fetchFile(file));
console.log("Getting file information...");
await ffmpeg.exec([
"-i",
"input",
"-f",
"null",
"-"
]);
console.log("Cleaning up...");
await ffmpeg.deleteFile("input");
const metadata = {
duration: 0,
// You'd get this from FFprobe output
sampleRate: 16e3,
channels: 1,
format: file.type,
size: file.size
};
console.log("Metadata extracted:", metadata);
return metadata;
} catch (error) {
console.error("FFmpeg metadata error:", error);
throw new Error("Failed to get audio metadata: " + (error instanceof Error ? error.message : "Unknown error"));
}
}
// src/adapters/whisperAdapter.ts
var WhisperAdapter = class extends BaseAdapter {
constructor(options) {
super(options);
console.log("Initializing WhisperAdapter");
this.ffmpeg = options.ffmpeg;
}
async processAudio(audioBlob) {
try {
if (audioBlob.size === 0) {
return {
transcript: "",
isFinal: true,
confidence: 1
};
}
const isWebM = audioBlob.type.includes("webm");
let processedBlob = audioBlob;
if (!isWebM) {
console.log("Converting audio to WebM format...", {
originalFormat: audioBlob.type,
originalSize: audioBlob.size
});
processedBlob = await convertAudioToMono(this.ffmpeg, audioBlob);
console.log("Conversion complete:", {
newFormat: processedBlob.type,
newSize: processedBlob.size
});
} else {
console.log("Audio already in WebM format, skipping conversion:", {
format: audioBlob.type,
size: audioBlob.size
});
}
console.log("Processing audio with transcribe function...");
const result = await this.options.transcribe(processedBlob);
return {
transcript: result.transcript,
isFinal: true,
confidence: result.confidence ?? 0.95
};
} catch (error) {
console.error("Error in processAudio:", error);
const baseError = new BaseError(error instanceof Error ? error.message : "Transcription failed");
baseError.cause = error;
throw baseError;
}
}
};
// src/hooks/useFFmpeg.ts
import { useState, useRef, useCallback } from "react";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { toBlobURL } from "@ffmpeg/util";
function useFFmpeg() {
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const ffmpegRef = useRef(null);
const load = useCallback(async () => {
try {
setLoading(true);
setError(null);
if (!ffmpegRef.current) {
ffmpegRef.current = new FFmpeg();
}
const ffmpeg = ffmpegRef.current;
ffmpeg.on("log", ({ message }) => {
console.log("FFmpeg:", message);
});
const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.10/dist/umd";
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm")
});
setLoaded(true);
} catch (err) {
console.error("Failed to load FFmpeg:", err);
setError(err instanceof Error ? err : new Error("Failed to load FFmpeg"));
} finally {
setLoading(false);
}
}, []);
return {
ffmpeg: ffmpegRef.current,
loaded,
loading,
error,
load
};
}
// src/hooks/useSTT.ts
var isDev = process.env.NODE_ENV === "development";
function useSTT(options) {
const { disabled = false } = options;
const [isRecording, setIsRecording] = useState2(false);
const [isProcessing, setIsProcessing] = useState2(false);
const [transcript, setTranscript] = useState2("");
const [error, setError] = useState2(null);
const [isInitialized, setIsInitialized] = useState2(false);
const [isStopping, setIsStopping] = useState2(false);
const adapterRef = useRef2(null);
const optionsRef = useRef2(options);
useEffect(() => {
optionsRef.current = options;
}, [options]);
const { ffmpeg, loaded, loading, error: ffmpegError, load: loadFFmpeg } = useFFmpeg();
useEffect(() => {
if (disabled) {
if (isDev) console.log("useSTT is disabled. Cleaning up adapter.");
if (adapterRef.current) {
adapterRef.current.abort();
adapterRef.current = null;
}
setIsRecording(false);
setIsStopping(false);
setIsInitialized(false);
} else if (loaded && ffmpeg && !adapterRef.current && !isInitialized) {
if (isDev) console.log("useSTT is re-enabled. Will attempt to initialize adapter if not already initializing.");
}
}, [disabled, loaded, ffmpeg, isInitialized]);
useEffect(() => {
let isMounted = true;
const initializeAdapter = async () => {
if (disabled || !loaded || !ffmpeg) {
if (ffmpegError && isMounted && !disabled) {
setError(ffmpegError);
}
return;
}
if (adapterRef.current && isInitialized) return;
if (isDev) console.log("useSTT: Initializing adapter.");
try {
console.log("Creating new WhisperAdapter instance");
const adapter = new WhisperAdapter({
...optionsRef.current,
ffmpeg,
onResult: (result) => {
if (!isMounted || optionsRef.current.disabled) return;
setTranscript(result.transcript);
if (result.isFinal) {
setIsProcessing(false);
}
},
onError: (err) => {
if (!isMounted || optionsRef.current.disabled) return;
setError(err);
setIsProcessing(false);
setIsRecording(false);
},
onStart: () => {
if (!isMounted || optionsRef.current.disabled) return;
setError(null);
setIsRecording(true);
setIsProcessing(true);
},
onEnd: () => {
if (!isMounted || optionsRef.current.disabled) return;
setIsRecording(false);
setIsStopping(false);
}
});
adapterRef.current = adapter;
if (isMounted) {
setIsInitialized(true);
setError(null);
}
} catch (err) {
console.error("Error initializing adapter:", err);
if (isMounted && !optionsRef.current.disabled) {
setError(err instanceof Error ? err : new BaseError("Failed to initialize adapter"));
setIsInitialized(false);
}
}
};
initializeAdapter();
return () => {
isMounted = false;
if (adapterRef.current && !optionsRef.current.disabled) {
if (isDev) console.log("useSTT: Unmounting, aborting adapter.");
adapterRef.current.abort();
adapterRef.current = null;
} else if (adapterRef.current && optionsRef.current.disabled) {
adapterRef.current = null;
}
};
}, [loaded, ffmpeg, ffmpegError, disabled, isInitialized]);
useEffect(() => {
if (disabled) {
if (isDev) console.log("useSTT is disabled. FFmpeg load call skipped.");
return;
}
if (!loaded && !loading && !ffmpegError) {
if (isDev) console.log("useSTT: loadFFmpeg called.");
loadFFmpeg();
}
}, [loadFFmpeg, disabled, loaded, loading, ffmpegError]);
const startRecording = useCallback2(async () => {
if (optionsRef.current.disabled) {
if (isDev) console.log("useSTT: startRecording aborted (STT is disabled).");
throw new Error("STT is disabled.");
}
if (ffmpegError) {
if (isDev) console.log("useSTT: startRecording aborted (FFmpeg error).");
throw ffmpegError;
}
if (!loaded || !ffmpeg) {
if (isDev) console.log("useSTT: startRecording aborted (FFmpeg not loaded).");
throw new Error("FFmpeg not loaded");
}
if (!isInitialized || !adapterRef.current) {
if (isDev) console.log("useSTT: startRecording aborted (adapter not initialized or no adapter).");
throw new Error("STT adapter not initialized.");
}
try {
await adapterRef.current.start();
} catch (err) {
if (!optionsRef.current.disabled) {
setIsRecording(false);
setIsProcessing(false);
setError(err instanceof Error ? err : new BaseError("Failed to start recording"));
}
throw err;
}
}, [loaded, ffmpeg, ffmpegError, isInitialized]);
const stopRecording = useCallback2(async () => {
if (!adapterRef.current) {
if (isDev) console.log("useSTT: stopRecording aborted (no adapter).");
return;
}
if (isDev) console.log("useSTT: stopRecording called.");
try {
setIsStopping(true);
await adapterRef.current.stop();
if (optionsRef.current.disabled) {
setIsStopping(false);
setIsRecording(false);
setIsProcessing(false);
}
} catch (err) {
if (!optionsRef.current.disabled) {
setError(err instanceof Error ? err : new BaseError("Failed to stop recording"));
}
setIsStopping(false);
setIsProcessing(false);
throw err;
}
}, []);
const pauseRecording = useCallback2(() => {
if (optionsRef.current.disabled || !adapterRef.current) {
if (isDev) console.log("useSTT: pauseRecording aborted (disabled or no adapter).");
return;
}
if (isDev) console.log("useSTT: pauseRecording called.");
adapterRef.current.pause();
}, []);
const resumeRecording = useCallback2(() => {
if (optionsRef.current.disabled || !adapterRef.current) {
if (isDev) console.log("useSTT: resumeRecording aborted (disabled or no adapter).");
return;
}
if (isDev) console.log("useSTT: resumeRecording called.");
adapterRef.current.resume();
}, []);
return {
transcript,
isRecording,
isProcessing: isProcessing || loading,
error: error || ffmpegError,
isInitialized,
isStopping,
startRecording,
stopRecording,
pauseRecording,
resumeRecording
};
}
export {
WhisperAdapter,
convertAudioToMono,
convertAudioToWebM,
convertWithFFmpeg,
getAudioMetadata,
useSTT
};
//# sourceMappingURL=index.mjs.map