UNPKG

use-stt

Version:

React hook for speech-to-text using multiple STT providers

690 lines (682 loc) 22.1 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { WhisperAdapter: () => WhisperAdapter, convertAudioToMono: () => convertAudioToMono, convertAudioToWebM: () => convertAudioToWebM, convertWithFFmpeg: () => convertWithFFmpeg, getAudioMetadata: () => getAudioMetadata, useSTT: () => useSTT }); module.exports = __toCommonJS(index_exports); // src/hooks/useSTT.ts var import_react2 = require("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 var import_util = require("@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 (0, import_util.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 (0, import_util.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 (0, import_util.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 var import_react = require("react"); var import_ffmpeg = require("@ffmpeg/ffmpeg"); var import_util2 = require("@ffmpeg/util"); function useFFmpeg() { const [loaded, setLoaded] = (0, import_react.useState)(false); const [loading, setLoading] = (0, import_react.useState)(false); const [error, setError] = (0, import_react.useState)(null); const ffmpegRef = (0, import_react.useRef)(null); const load = (0, import_react.useCallback)(async () => { try { setLoading(true); setError(null); if (!ffmpegRef.current) { ffmpegRef.current = new import_ffmpeg.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 (0, import_util2.toBlobURL)(`${baseURL}/ffmpeg-core.js`, "text/javascript"), wasmURL: await (0, import_util2.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] = (0, import_react2.useState)(false); const [isProcessing, setIsProcessing] = (0, import_react2.useState)(false); const [transcript, setTranscript] = (0, import_react2.useState)(""); const [error, setError] = (0, import_react2.useState)(null); const [isInitialized, setIsInitialized] = (0, import_react2.useState)(false); const [isStopping, setIsStopping] = (0, import_react2.useState)(false); const adapterRef = (0, import_react2.useRef)(null); const optionsRef = (0, import_react2.useRef)(options); (0, import_react2.useEffect)(() => { optionsRef.current = options; }, [options]); const { ffmpeg, loaded, loading, error: ffmpegError, load: loadFFmpeg } = useFFmpeg(); (0, import_react2.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]); (0, import_react2.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]); (0, import_react2.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 = (0, import_react2.useCallback)(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 = (0, import_react2.useCallback)(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 = (0, import_react2.useCallback)(() => { 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 = (0, import_react2.useCallback)(() => { 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 }; } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { WhisperAdapter, convertAudioToMono, convertAudioToWebM, convertWithFFmpeg, getAudioMetadata, useSTT }); //# sourceMappingURL=index.js.map