/** @odoo-module **/ // Fusion Notes - Voice Recording Service // Copyright 2026 Nexa Systems Inc. // License OPL-1 // // Provides AudioRecorder (MediaRecorder API) and SpeechRecognizer // (Web Speech API fallback) for voice-to-text in Odoo chatter. /** * AudioRecorder - Records microphone audio using the MediaRecorder API. * * Usage: * const recorder = new AudioRecorder(); * await recorder.start(); // request mic + begin recording * const blob = await recorder.stop(); // stop and get audio Blob * recorder.cancel(); // abort without producing output */ export class AudioRecorder { constructor() { this.mediaRecorder = null; this.audioChunks = []; this.stream = null; this.mimeType = this._getSupportedMimeType(); } /** * Pick the best supported MIME type for recording. * Whisper accepts webm, ogg, mp4, mp3, wav, etc. */ _getSupportedMimeType() { const types = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/mp4', ]; for (const type of types) { if ( typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(type) ) { return type; } } return 'audio/webm'; } /** Base MIME type without codec suffix (for Whisper file upload). */ get baseMimeType() { return this.mimeType.split(';')[0]; } /** Whether MediaRecorder is available in this browser. */ get isSupported() { return ( typeof navigator !== 'undefined' && navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function' && typeof MediaRecorder !== 'undefined' ); } /** Request microphone access and begin recording. */ async start() { if (!this.isSupported) { throw new Error( 'Audio recording is not supported in this browser.' ); } this.audioChunks = []; this.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, }, }); this.mediaRecorder = new MediaRecorder(this.stream, { mimeType: this.mimeType, }); this.mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { this.audioChunks.push(event.data); } }; // Collect data every second for more granular chunks this.mediaRecorder.start(1000); } /** Stop recording and return the audio Blob. */ async stop() { return new Promise((resolve, reject) => { if ( !this.mediaRecorder || this.mediaRecorder.state === 'inactive' ) { reject(new Error('No active recording.')); return; } this.mediaRecorder.onstop = () => { const blob = new Blob(this.audioChunks, { type: this.baseMimeType, }); this._cleanup(); resolve(blob); }; this.mediaRecorder.onerror = (e) => { this._cleanup(); reject(e); }; this.mediaRecorder.stop(); }); } /** Cancel recording and release resources without producing output. */ cancel() { if ( this.mediaRecorder && this.mediaRecorder.state !== 'inactive' ) { try { this.mediaRecorder.stop(); } catch { // Already stopped } } this._cleanup(); } /** Release microphone stream and reset state. */ _cleanup() { if (this.stream) { this.stream.getTracks().forEach((track) => track.stop()); this.stream = null; } this.mediaRecorder = null; this.audioChunks = []; } } /** * SpeechRecognizer - Fallback using the browser Web Speech API. * * Provides real-time transcription without a server round-trip. * Supported in Chrome, Edge, and some Chromium browsers. * * Usage: * const recognizer = new SpeechRecognizer(); * const promise = recognizer.start(); // returns Promise * recognizer.stop(); // stop and resolve promise * const text = await promise; */ export class SpeechRecognizer { constructor() { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; this.isSupported = !!SR; this.recognition = this.isSupported ? new SR() : null; this.transcript = ''; this.isListening = false; this._resolveStop = null; if (this.recognition) { this.recognition.continuous = true; this.recognition.interimResults = true; this.recognition.lang = 'en-US'; } } /** * Start listening. Returns a promise that resolves with the final * transcript when stop() is called or recognition ends naturally. */ start() { if (!this.isSupported || !this.recognition) { throw new Error( 'Speech Recognition is not supported in this browser.' ); } this.transcript = ''; this.isListening = true; let finalTranscript = ''; return new Promise((resolve, reject) => { this.recognition.onresult = (event) => { let interimTranscript = ''; for (let i = event.resultIndex; i < event.results.length; i++) { if (event.results[i].isFinal) { finalTranscript += event.results[i][0].transcript + ' '; } else { interimTranscript += event.results[i][0].transcript; } } this.transcript = (finalTranscript + interimTranscript).trim(); }; this.recognition.onerror = (event) => { this.isListening = false; if (event.error === 'no-speech') { resolve(finalTranscript.trim()); } else { reject( new Error(`Speech recognition error: ${event.error}`) ); } }; this.recognition.onend = () => { this.isListening = false; resolve(finalTranscript.trim()); }; this.recognition.start(); this._resolveStop = () => { this.recognition.stop(); }; }); } /** Signal recognition to stop; the start() promise will resolve. */ stop() { if (this._resolveStop) { this._resolveStop(); } } /** Abort immediately without waiting for results. */ cancel() { if (this.recognition && this.isListening) { try { this.recognition.abort(); } catch { // Already stopped } } this.isListening = false; this.transcript = ''; this._resolveStop = null; } } /** * Convert a Blob to a base64-encoded string (without data-URI prefix). */ export function blobToBase64(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => { // reader.result is "data:;base64," - extract const base64 = reader.result.split(',')[1]; resolve(base64); }; reader.onerror = reject; reader.readAsDataURL(blob); }); }