Files
Odoo-Modules/fusion_notes/static/src/js/voice_note_service.js
2026-02-22 01:22:18 -05:00

266 lines
7.8 KiB
JavaScript

/** @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<string>
* 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:<mime>;base64,<data>" - extract <data>
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}