Spaces:
Running
Running
| <html lang="fr"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Enregistreur Audio — Envoi API</title> | |
| <!-- Tailwind CDN (utile pour preview rapide). Dans un projet Angular avec Tailwind installé, vous pouvez supprimer cette ligne. --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen flex items-center justify-center p-6"> | |
| <main class="w-full max-w-xl bg-white rounded-2xl shadow p-6"> | |
| <h1 class="text-2xl font-semibold mb-4">Enregistreur audio</h1> | |
| <section id="controls" class="space-y-3"> | |
| <div class="flex gap-3"> | |
| <button id="startBtn" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50">Démarrer</button> | |
| <button id="stopBtn" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50" disabled>Stopper</button> | |
| <button id="downloadBtn" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50" disabled>Télécharger</button> | |
| </div> | |
| <div class="text-sm text-gray-600">Durée : <span id="timer">00:00</span></div> | |
| <div class="mt-2"> | |
| <label class="block text-sm font-medium text-gray-700">API endpoint (changer par défaut si besoin)</label> | |
| <input id="apiEndpoint" class="mt-1 block w-full rounded border-gray-200 shadow-sm" value="https://n8n.bziiit.com/webhook/transcript-audio" /> | |
| </div> | |
| <div class="mt-2 text-xs text-gray-500">Note : l'audio sera automatiquement envoyé à l'API lors de l'arrêt de la captation.</div> | |
| </section> | |
| <hr class="my-4" /> | |
| <section id="upload" class="space-y-3"> | |
| <h2 class="text-lg font-medium text-gray-800">Upload d'un fichier audio</h2> | |
| <div class="flex gap-3 items-center"> | |
| <input id="fileInput" type="file" accept="audio/*" class="block w-full text-sm text-gray-500 file:mr-3 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" /> | |
| <button id="uploadBtn" class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50" disabled>Transcrire</button> | |
| </div> | |
| <div id="fileInfo" class="text-xs text-gray-500"></div> | |
| </section> | |
| <hr class="my-4" /> | |
| <section id="player" class="space-y-3"> | |
| <audio id="playback" controls class="w-full" hidden></audio> | |
| <div id="status" class="text-sm text-gray-700">Statut : <span id="statusText">Prêt</span></div> | |
| <!-- Section pour la transcription --> | |
| <div id="transcription-section" class="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200" hidden> | |
| <h3 class="text-lg font-medium text-blue-800 mb-2">Transcription</h3> | |
| <div id="transcription-text" class="text-gray-800 bg-white p-3 rounded border whitespace-pre-wrap"></div> | |
| <div id="transcription-info" class="text-xs text-blue-600 mt-2"></div> | |
| </div> | |
| <pre id="log" class="text-xs text-gray-500 overflow-auto max-h-28 bg-gray-50 p-2 rounded"></pre> | |
| </section> | |
| </main> | |
| <script type="module"> | |
| /* | |
| Clean, modular vanilla JS pour enregistrement audio et envoi à une API. | |
| Principes appliqués : séparation des responsabilités, petites fonctions, erreurs gérées. | |
| */ | |
| // ---- Elements DOM ---- | |
| const startBtn = document.getElementById('startBtn'); | |
| const stopBtn = document.getElementById('stopBtn'); | |
| const downloadBtn = document.getElementById('downloadBtn'); | |
| const playback = document.getElementById('playback'); | |
| const statusText = document.getElementById('statusText'); | |
| const logEl = document.getElementById('log'); | |
| const timerEl = document.getElementById('timer'); | |
| const apiEndpointInput = document.getElementById('apiEndpoint'); | |
| const transcriptionSection = document.getElementById('transcription-section'); | |
| const transcriptionText = document.getElementById('transcription-text'); | |
| const transcriptionInfo = document.getElementById('transcription-info'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const uploadBtn = document.getElementById('uploadBtn'); | |
| const fileInfo = document.getElementById('fileInfo'); | |
| // ---- State ---- | |
| let mediaRecorder = null; | |
| let recordedChunks = []; | |
| let startTime = null; | |
| let timerInterval = null; | |
| // ---- Utilities (single responsibility functions) ---- | |
| function log(message) { | |
| const time = new Date().toLocaleTimeString(); | |
| logEl.textContent = `[${time}] ${message}\n` + logEl.textContent; | |
| } | |
| function setStatus(text) { | |
| statusText.textContent = text; | |
| log(text); | |
| } | |
| function displayTranscription(text, usage = null) { | |
| transcriptionText.textContent = text; | |
| transcriptionSection.hidden = false; | |
| if (usage && usage.type === 'duration' && usage.seconds) { | |
| transcriptionInfo.textContent = `Durée de traitement : ${usage.seconds} secondes`; | |
| } else { | |
| transcriptionInfo.textContent = ''; | |
| } | |
| log(`Transcription reçue : ${text}`); | |
| } | |
| function formatDuration(ms) { | |
| const totalSeconds = Math.floor(ms / 1000); | |
| const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0'); | |
| const seconds = String(totalSeconds % 60).padStart(2, '0'); | |
| return `${minutes}:${seconds}`; | |
| } | |
| function startTimer() { | |
| startTime = Date.now(); | |
| timerEl.textContent = '00:00'; | |
| timerInterval = setInterval(() => { | |
| timerEl.textContent = formatDuration(Date.now() - startTime); | |
| }, 250); | |
| } | |
| function stopTimer() { | |
| clearInterval(timerInterval); | |
| timerInterval = null; | |
| } | |
| function formatFileSize(bytes) { | |
| if (bytes === 0) return '0 Bytes'; | |
| const k = 1024; | |
| const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
| } | |
| function updateFileInfo(file) { | |
| if (file) { | |
| const duration = file.name.includes('.') ? 'Format: ' + file.name.split('.').pop().toUpperCase() : ''; | |
| fileInfo.textContent = `${file.name} (${formatFileSize(file.size)}) ${duration}`; | |
| uploadBtn.disabled = false; | |
| } else { | |
| fileInfo.textContent = ''; | |
| uploadBtn.disabled = true; | |
| } | |
| } | |
| // ---- Core logic ---- | |
| async function requestMicrophone() { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| return stream; | |
| } catch (err) { | |
| setStatus('Erreur : impossible d\'accéder au microphone'); | |
| console.error(err); | |
| throw err; | |
| } | |
| } | |
| function initMediaRecorder(stream) { | |
| // Prefer opus/webm if available; fallback to default | |
| const options = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') | |
| ? { mimeType: 'audio/webm;codecs=opus' } | |
| : undefined; | |
| const mr = new MediaRecorder(stream, options); | |
| mr.addEventListener('dataavailable', (e) => { | |
| if (e.data && e.data.size > 0) recordedChunks.push(e.data); | |
| }); | |
| mr.addEventListener('start', () => setStatus('Enregistrement démarré')); | |
| mr.addEventListener('stop', () => setStatus('Enregistrement arrêté')); | |
| mr.addEventListener('error', (e) => setStatus('Erreur MediaRecorder: ' + e.message)); | |
| return mr; | |
| } | |
| async function startRecording() { | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| setStatus('API getUserMedia non supportée par ce navigateur.'); | |
| return; | |
| } | |
| try { | |
| startBtn.disabled = true; | |
| stopBtn.disabled = false; | |
| const stream = await requestMicrophone(); | |
| mediaRecorder = initMediaRecorder(stream); | |
| recordedChunks = []; | |
| mediaRecorder.start(); | |
| startTimer(); | |
| } catch (err) { | |
| startBtn.disabled = false; | |
| stopBtn.disabled = true; | |
| } | |
| } | |
| async function stopRecording() { | |
| if (!mediaRecorder) return; | |
| stopBtn.disabled = true; | |
| startBtn.disabled = false; | |
| mediaRecorder.stop(); | |
| stopTimer(); | |
| // Small delay to ensure 'dataavailable' has been pushed | |
| await new Promise((resolve) => setTimeout(resolve, 200)); | |
| const blob = new Blob(recordedChunks, { type: recordedChunks[0]?.type || 'audio/webm' }); | |
| // enable playback | |
| const url = URL.createObjectURL(blob); | |
| playback.src = url; | |
| playback.hidden = false; | |
| downloadBtn.disabled = false; | |
| // auto-send to API | |
| try { | |
| await sendAudioToApi(blob); | |
| } catch (err) { | |
| console.error('Envoi API échoué', err); | |
| } | |
| } | |
| function downloadBlob(blob, filename = 'enregistrement.webm') { | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| } | |
| async function uploadFile() { | |
| const file = fileInput.files[0]; | |
| if (!file) { | |
| setStatus('Aucun fichier sélectionné.'); | |
| return; | |
| } | |
| // Vérifier que c'est bien un fichier audio ou vidéo | |
| if (!file.type.startsWith('audio/') && !file.type.startsWith('video/')) { | |
| setStatus('Veuillez sélectionner un fichier audio ou vidéo.'); | |
| return; | |
| } | |
| uploadBtn.disabled = true; | |
| try { | |
| await sendAudioToApi(file); | |
| setStatus('Fichier uploadé avec succès'); | |
| } catch (err) { | |
| setStatus('Erreur lors de l\'upload du fichier'); | |
| console.error(err); | |
| } finally { | |
| uploadBtn.disabled = false; | |
| } | |
| } | |
| // ---- Networking ---- | |
| /** | |
| * Envoie le Blob audio à l'API. Respecte le principe d'une seule responsabilité : cette fonction | |
| * ne s'occupe que d'envoyer. Pour modifier l'endpoint ou les headers, modifier apiEndpointInput.value. | |
| */ | |
| async function sendAudioToApi(blob) { | |
| const endpoint = apiEndpointInput.value.trim(); | |
| if (!endpoint) { | |
| setStatus('Aucun endpoint API configuré.'); | |
| return; | |
| } | |
| setStatus('Envoi de l\'audio à l\'API...'); | |
| const form = new FormData(); | |
| // Nom du fichier : timestamp + extension déduite | |
| const extension = blob.type.includes('wav') ? 'wav' : (blob.type.includes('mpeg') ? 'mp3' : 'webm'); | |
| const filename = `recording-${Date.now()}.${extension}`; | |
| form.append('file', blob, filename); | |
| // Exemple : ajouter d'autres champs si nécessaire | |
| // form.append('userId', '123'); | |
| try { | |
| const response = await fetch(endpoint, { | |
| method: 'POST', | |
| body: form, | |
| // headers: ne pas définir Content-Type (le navigateur s'en charge pour multipart/form-data) | |
| }); | |
| if (!response.ok) { | |
| const text = await response.text().catch(() => ''); | |
| setStatus(`Envoi échoué (status ${response.status})`); | |
| log(`Réponse API : ${text}`); | |
| throw new Error('Upload failed'); | |
| } | |
| const result = await response.json().catch(() => null); | |
| setStatus('Envoi réussi'); | |
| log('Réponse API JSON : ' + JSON.stringify(result)); | |
| // Extraire et afficher la transcription si présente | |
| if (result && result.text) { | |
| displayTranscription(result.text, result.usage); | |
| } else { | |
| log('Aucune transcription trouvée dans la réponse de l\'API'); | |
| } | |
| return result; | |
| } catch (err) { | |
| setStatus('Erreur lors de l\'envoi à l\'API'); | |
| console.error(err); | |
| throw err; | |
| } | |
| } | |
| // ---- Event binding ---- | |
| startBtn.addEventListener('click', () => startRecording()); | |
| stopBtn.addEventListener('click', () => stopRecording()); | |
| downloadBtn.addEventListener('click', () => { | |
| if (!recordedChunks.length) return; | |
| const blob = new Blob(recordedChunks, { type: recordedChunks[0]?.type || 'audio/webm' }); | |
| downloadBlob(blob); | |
| }); | |
| // File upload events | |
| fileInput.addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| updateFileInfo(file); | |
| }); | |
| uploadBtn.addEventListener('click', () => uploadFile()); | |
| // ---- Initialization ---- | |
| (function init() { | |
| if (!window.MediaRecorder) { | |
| setStatus('MediaRecorder non supporté par ce navigateur.'); | |
| startBtn.disabled = true; | |
| } else { | |
| setStatus('Prêt'); | |
| } | |
| // pour debug / développement | |
| log('Interface prête.'); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |