Transcripcion en vivo + transcripcion mejorada
This commit is contained in:
		| @ -3,8 +3,84 @@ | ||||
| @import "tailwindcss/utilities"; | ||||
|  | ||||
| /* This file is for your main application CSS */ | ||||
| .realtime { | ||||
|   white-space: pre-wrap; | ||||
|   font-family: monospace; | ||||
|   margin-top: 1em; | ||||
| } | ||||
|     body { | ||||
|       background-color: #f4f4f9; | ||||
|       color: #333; | ||||
|       font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       justify-content: center; | ||||
|       height: 100vh; | ||||
|       margin: 0; | ||||
|     } | ||||
|     #container { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       align-items: center; | ||||
|       width: 100%; | ||||
|       max-width: 700px; | ||||
|       padding: 20px; | ||||
|       box-sizing: border-box; | ||||
|       gap: 20px; /* Add more vertical space between items */ | ||||
|       height: 90%; /* Fixed height to prevent layout shift */ | ||||
|     } | ||||
|     #status { | ||||
|       color: #0056b3; | ||||
|       font-size: 20px; | ||||
|       text-align: center; | ||||
|     } | ||||
|     #transcriptionContainer { | ||||
|       height: auto; /* Fixed height for approximately 3 lines of text */ | ||||
|       overflow-y: auto; | ||||
|       width: 100%; | ||||
|       padding: 10px; | ||||
|       box-sizing: border-box; | ||||
|       background-color: #f9f9f9; | ||||
|       border: 1px solid #ddd; | ||||
|       border-radius: 5px; | ||||
|     } | ||||
|     #transcription { | ||||
|       font-size: 18px; | ||||
|       line-height: 1.6; | ||||
|       color: #333; | ||||
|       word-wrap: break-word; | ||||
|     } | ||||
|     #fullTextContainer { | ||||
|       height: 150px; /* Fixed height to prevent layout shift */ | ||||
|       overflow-y: auto; | ||||
|       width: 100%; | ||||
|       padding: 10px; | ||||
|       box-sizing: border-box; | ||||
|       background-color: #f9f9f9; | ||||
|       border: 1px solid #ddd; | ||||
|       border-radius: 5px; | ||||
|     } | ||||
|     #fullText { | ||||
|       color: #4CAF50; | ||||
|       font-size: 18px; | ||||
|       font-weight: 600; | ||||
|       word-wrap: break-word; | ||||
|     } | ||||
|     .last-word { | ||||
|       color: #007bff; | ||||
|       font-weight: 600; | ||||
|     } | ||||
|     button { | ||||
|       padding: 12px 24px; | ||||
|       font-size: 16px; | ||||
|       cursor: pointer; | ||||
|       border: none; | ||||
|       border-radius: 5px; | ||||
|       margin: 5px; | ||||
|       transition: background-color 0.3s ease; | ||||
|       color: #fff; | ||||
|       background-color: #0056b3; | ||||
|       box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); | ||||
|     } | ||||
|     button:hover { | ||||
|       background-color: #007bff; | ||||
|     } | ||||
|     button:disabled { | ||||
|       background-color: #cccccc; | ||||
|       cursor: not-allowed; | ||||
|     } | ||||
|  | ||||
| @ -9,6 +9,8 @@ defmodule WhisperLive.AudioBuffer do | ||||
|  | ||||
|   def get_all(ref), do: GenServer.call(via(ref), :get_all) | ||||
|  | ||||
|   def clear(ref), do: GenServer.call(via(ref), :clear) | ||||
|  | ||||
|   def stop(ref), do: GenServer.stop(via(ref)) | ||||
|  | ||||
|   defp via(ref), do: {:via, Registry, {WhisperLive.AudioRegistry, ref}} | ||||
| @ -20,4 +22,6 @@ defmodule WhisperLive.AudioBuffer do | ||||
|   def handle_cast({:append, chunk}, state), do: {:noreply, [chunk | state]} | ||||
|  | ||||
|   def handle_call(:get_all, _from, state), do: {:reply, Enum.reverse(state), state} | ||||
|  | ||||
|   def handle_call(:clear, _from, _state), do: {:reply, :ok, []} | ||||
| end | ||||
|  | ||||
| @ -33,7 +33,7 @@ defmodule WhisperLive.Transcriber do | ||||
|  | ||||
|             case send_to_whisper(tmpfile) do | ||||
|                 {:ok, response} -> | ||||
|                 PubSub.broadcast(WhisperLive.PubSub, "transcription:#{ref}", {:transcription, response}) | ||||
|                 PubSub.broadcast(WhisperLive.PubSub, "transcription", {:transcription, response}) | ||||
|  | ||||
|                 {:error, reason} -> | ||||
|                 Logger.warning("Realtime transcription error: #{inspect(reason)}") | ||||
| @ -90,7 +90,7 @@ defmodule WhisperLive.Transcriber do | ||||
|     end | ||||
|  | ||||
|     defp send_to_whisper(filepath) do | ||||
|         url = "http://localhost:4000/infer" | ||||
|         url = "http://localhost:4000/tiny" | ||||
|         {:ok, file_bin} = File.read(filepath) | ||||
|         filename = Path.basename(filepath) | ||||
|  | ||||
| @ -108,9 +108,17 @@ defmodule WhisperLive.Transcriber do | ||||
|  | ||||
|         :httpc.request(:post, {url, headers, 'multipart/form-data; boundary=----ElixirBoundary', body}, [], []) | ||||
|         |> case do | ||||
|             {:ok, {{_, 200, _}, _headers, body}} -> {:ok, to_string(body)} | ||||
|             {:ok, {{_, status, _}, _, body}} -> {:error, {:http_error, status, to_string(body)}} | ||||
|             error -> {:error, error} | ||||
|             {:ok, {{_, 200, _}, _headers, body}} -> | ||||
|                 # Logger.info("en transcriber --------------------------\n   -> > #{IO.iodata_to_binary(body)}") | ||||
|                 # Phoenix.PubSub.broadcast(WhisperLive.PubSub, "transcription", {:transcription, "#{IO.iodata_to_binary(body)}"}) | ||||
|  | ||||
|                 {:ok, "#{IO.iodata_to_binary(body)}"} | ||||
|  | ||||
|             {:ok, {{_, status, _}, _, body}} -> | ||||
|                 {:error, {:http_error, status,"#{IO.iodata_to_binary(body)}"}} | ||||
|  | ||||
|             error -> | ||||
|                 {:error, error} | ||||
|         end | ||||
|     end | ||||
| end | ||||
|  | ||||
| @ -11,31 +11,13 @@ defmodule WhisperLiveWeb.AudioChannel do | ||||
|         {:ok, socket} | ||||
|     end | ||||
|  | ||||
|     def handle_in("audio_chunk", %{"data" => base64_audio, "sample_rate" => sample_rate}, socket) do | ||||
|         # 1. Decodificas el audio base64 | ||||
|         {:ok, bin} = Base.decode64(base64_audio) | ||||
|  | ||||
|         # 2. Guardas o procesas el chunk de audio | ||||
|         # Podrías escribirlo en un archivo temporal para enviar a Whisper | ||||
|         tmpfile = tmp_path("chunk_#{socket.assigns.ref}") | ||||
|         :ok = File.write!(tmpfile, encode_wav(bin, sample_rate)) | ||||
|  | ||||
|         # 3. Llamas a la transcripción del chunk (podría ser sync o async) | ||||
|         case send_to_whisper(tmpfile) do | ||||
|         {:ok, transcription} -> | ||||
|             # 4. Envías el texto parcial por PubSub o Push a LiveView/cliente | ||||
|             Phoenix.PubSub.broadcast(YourApp.PubSub, "transcription:#{socket.assigns.ref}", {:transcription, transcription}) | ||||
|  | ||||
|         {:error, reason} -> | ||||
|             Logger.error("Error en transcripción parcial: #{inspect(reason)}") | ||||
|         end | ||||
|  | ||||
|         File.rm(tmpfile) | ||||
|  | ||||
|     def handle_in("audio_chunk", %{"data" => data, "sample_rate" => rate}, socket) do | ||||
|         {:ok, binary} = Base.decode64(data) | ||||
|         AudioBuffer.append(socket_id(socket), {rate, binary}) | ||||
|         Logger.info("📦 Chunk recibido: #{byte_size(binary)} bytes, sample_rate: #{rate}") | ||||
|         {:noreply, socket} | ||||
|     end | ||||
|  | ||||
|  | ||||
|     def handle_in("stop_audio", _payload, socket) do | ||||
|         Logger.info("🛑 Grabación detenida por cliente") | ||||
|  | ||||
| @ -47,16 +29,8 @@ defmodule WhisperLiveWeb.AudioChannel do | ||||
|             filename = "recordings/recording_#{System.system_time(:millisecond)}.wav" | ||||
|             File.mkdir_p!("recordings") | ||||
|             File.write!(filename, encode_wav(merged, rate)) | ||||
|             Logger.info("💾 Audio guardado en #{filename}") | ||||
|  | ||||
|             # 🔁 Transcribir automáticamente | ||||
|             case send_to_whisper(filename) do | ||||
|             {:ok, response} -> | ||||
|                 Logger.info("📝 Transcripción recibida: #{response}") | ||||
|             {:error, reason} -> | ||||
|                 Logger.error("❌ Error al transcribir: #{inspect(reason)}") | ||||
|             end | ||||
|  | ||||
|             whisper_large(filename) | ||||
|             File.rm!(filename) | ||||
|         _ -> | ||||
|             Logger.warning("⚠️ No se recibieron chunks de audio") | ||||
|         end | ||||
| @ -93,18 +67,16 @@ defmodule WhisperLiveWeb.AudioChannel do | ||||
|         >> <> data | ||||
|     end | ||||
|  | ||||
|     defp send_to_whisper(filepath) do | ||||
|         url = "http://localhost:4000/infer" | ||||
|  | ||||
|     defp whisper_large(filepath) do | ||||
|         url = "http://localhost:4000/large" | ||||
|         {:ok, file_bin} = File.read(filepath) | ||||
|         filename = Path.basename(filepath) | ||||
|  | ||||
|         headers = [ | ||||
|         {'Content-Type', 'multipart/form-data; boundary=----ElixirBoundary'} | ||||
|             {'Content-Type', 'multipart/form-data; boundary=----ElixirBoundary'} | ||||
|         ] | ||||
|  | ||||
|         body = | ||||
|         [ | ||||
|         body = [ | ||||
|             "------ElixirBoundary\r\n", | ||||
|             "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n", | ||||
|             "Content-Type: audio/wav\r\n\r\n", | ||||
| @ -114,21 +86,17 @@ defmodule WhisperLiveWeb.AudioChannel do | ||||
|  | ||||
|         :httpc.request(:post, {url, headers, 'multipart/form-data; boundary=----ElixirBoundary', body}, [], []) | ||||
|         |> case do | ||||
|         {:ok, {{_, 200, _}, _headers, body}} -> | ||||
|             {:ok, to_string(body)} | ||||
|             {:ok, {{_, 200, _}, _headers, body}} -> | ||||
|                 # Logger.info("transcripcion mejorada --------------------------\n   -> > #{IO.iodata_to_binary(body)}") | ||||
|                 Phoenix.PubSub.broadcast(WhisperLive.PubSub, "transcription", {:transcription_m, "#{IO.iodata_to_binary(body)}"}) | ||||
|  | ||||
|         {:ok, {{_, status, _}, _, body}} -> | ||||
|             {:error, {:http_error, status, to_string(body)}} | ||||
|                 {:ok, "#{IO.iodata_to_binary(body)}"} | ||||
|  | ||||
|         error -> | ||||
|             {:error, error} | ||||
|             {:ok, {{_, status, _}, _, body}} -> | ||||
|                 {:error, {:http_error, status, IO.iodata_to_binary(body)}} | ||||
|  | ||||
|             error -> | ||||
|                 {:error, error} | ||||
|         end | ||||
|     end | ||||
|  | ||||
|     defp tmp_path(prefix) do | ||||
|         unique = :erlang.unique_integer([:positive]) |> Integer.to_string() | ||||
|         filename = prefix <> "_" <> unique <> ".wav" | ||||
|         Path.join(System.tmp_dir!(), filename) | ||||
|     end | ||||
|  | ||||
| end | ||||
|  | ||||
| @ -1,32 +1,5 @@ | ||||
| <header class="px-4 sm:px-6 lg:px-8"> | ||||
|   <div class="flex items-center justify-between border-b border-zinc-100 py-3 text-sm"> | ||||
|     <div class="flex items-center gap-4"> | ||||
|       <a href="/"> | ||||
|         <img src={~p"/images/logo.svg"} width="36" /> | ||||
|       </a> | ||||
|       <p class="bg-brand/5 text-brand rounded-full px-2 font-medium leading-6"> | ||||
|         v{Application.spec(:phoenix, :vsn)} | ||||
|       </p> | ||||
|     </div> | ||||
|     <div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900"> | ||||
|       <a href="https://twitter.com/elixirphoenix" class="hover:text-zinc-700"> | ||||
|         @elixirphoenix | ||||
|       </a> | ||||
|       <a href="https://github.com/phoenixframework/phoenix" class="hover:text-zinc-700"> | ||||
|         GitHub | ||||
|       </a> | ||||
|       <a | ||||
|         href="https://hexdocs.pm/phoenix/overview.html" | ||||
|         class="rounded-lg bg-zinc-100 px-2 py-1 hover:bg-zinc-200/80" | ||||
|       > | ||||
|         Get Started <span aria-hidden="true">→</span> | ||||
|       </a> | ||||
|     </div> | ||||
|   </div> | ||||
| </header> | ||||
| <main class="px-4 py-20 sm:px-6 lg:px-8"> | ||||
|   <div class="mx-auto max-w-2xl"> | ||||
|     <.flash_group flash={@flash} /> | ||||
| <main> | ||||
|   <div> | ||||
|     {@inner_content} | ||||
|   </div> | ||||
| </main> | ||||
|  | ||||
| @ -11,7 +11,7 @@ | ||||
|     <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> | ||||
|     </script> | ||||
|   </head> | ||||
|   <body class="bg-white"> | ||||
|   <body> | ||||
|     {@inner_content} | ||||
|   </body> | ||||
| </html> | ||||
|  | ||||
| @ -1,160 +1,201 @@ | ||||
| defmodule WhisperLiveWeb.Live.Recorder do | ||||
|   use WhisperLiveWeb, :live_view | ||||
|   alias Phoenix.PubSub | ||||
|     use WhisperLiveWeb, :live_view | ||||
|     alias Phoenix.PubSub | ||||
|  | ||||
|   def mount(_, _, socket) do | ||||
|     if connected?(socket), do: PubSub.subscribe(WhisperLive.PubSub, "transcription:#{socket_id(socket)}") | ||||
|     {:ok, assign(socket, transcription: "")} | ||||
|   end | ||||
|     def mount(_, _, socket) do | ||||
|         PubSub.subscribe(WhisperLive.PubSub, "transcription") | ||||
|  | ||||
|   def handle_info({:transcription, raw_json}, socket) do | ||||
|     new_text = | ||||
|       raw_json | ||||
|       |> Jason.decode!() | ||||
|       |> get_in(["chunks", Access.at(0), "text"]) | ||||
|         socket = | ||||
|             socket | ||||
|             |> assign(:transcription, "") | ||||
|             |> assign(:transcription_m, "") | ||||
|  | ||||
|     {:noreply, update(socket, :transcription, &(&1 <> " " <> new_text))} | ||||
|   end | ||||
|         {:ok, socket} | ||||
|     end | ||||
|  | ||||
|   def handle_event("start_recording", _params, socket) do | ||||
|     push_event(socket, "start-recording", %{}) | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|     def handle_info({:transcription, raw_json}, socket) do | ||||
|         IO.inspect(raw_json, label: "en vivo ---------------->\n") | ||||
|  | ||||
|   def handle_event("stop_recording", _params, socket) do | ||||
|     push_event(socket, "stop-recording", %{}) | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|         new_text = | ||||
|             raw_json | ||||
|             |> Jason.decode!() | ||||
|             |> get_in(["chunks", Access.at(0), "text"]) | ||||
|  | ||||
|   defp socket_id(socket), do: socket.transport_pid |> :erlang.pid_to_list() |> List.to_string() | ||||
|         old_text = socket.assigns.transcription | ||||
|  | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div id="recorder" data-hook="recorder"> | ||||
|       <button id="startButton" phx-click="start_recording">Start Recording</button> | ||||
|       <button id="stopButton" phx-click="stop_recording">Stop Recording</button> | ||||
|         # Sacar lo ya incluido al inicio | ||||
|         added_part = String.replace_prefix(new_text, old_text, "") | ||||
|  | ||||
|       <div id="transcriptionContainer"> | ||||
|         <div id="transcription" class="realtime"><%= @transcription %></div> | ||||
|       </div> | ||||
|       <div id="status" class="realtime"></div> | ||||
|         {:noreply, update(socket, :transcription, &(&1 <> added_part))} | ||||
|     end | ||||
|  | ||||
|       <script type="module"> | ||||
|         import { Socket } from "https://cdn.skypack.dev/phoenix" | ||||
|  | ||||
|         const startButton = document.getElementById("startButton") | ||||
|         const stopButton = document.getElementById("stopButton") | ||||
|         const statusDiv = document.getElementById("status") | ||||
|     def handle_info({:transcription_m, raw_json}, socket) do | ||||
|         IO.inspect(raw_json, label: "meojada ---------------->\n") | ||||
|  | ||||
|         let socket = null | ||||
|         let channel = null | ||||
|         let audioContext = null | ||||
|         let processor = null | ||||
|         let mediaStream = null | ||||
|         let buffer = [] | ||||
|         let sendInterval = null | ||||
|         new_text = | ||||
|             raw_json | ||||
|             |> Jason.decode!() | ||||
|             |> get_in(["chunks", Access.at(0), "text"]) | ||||
|         {:noreply, update(socket, :transcription_m, &(&1 <> " " <> new_text))} | ||||
|     end | ||||
|  | ||||
|         const sampleRate = 48000 | ||||
|     def handle_event("start_recording", _params, socket) do | ||||
|         push_event(socket, "start-recording", %{}) | ||||
|         {:noreply, assign(socket, transcription: "", transcription_m: "")} | ||||
|     end | ||||
|  | ||||
|         async function startRecording() { | ||||
|           startButton.disabled = true | ||||
|           stopButton.disabled = false | ||||
|           statusDiv.textContent = "🎙 Grabando..." | ||||
|  | ||||
|           socket = new Socket("ws://localhost:4004/socket") | ||||
|           socket.connect() | ||||
|           channel = socket.channel("audio:lobby") | ||||
|     def handle_event("stop_recording", _params, socket) do | ||||
|         push_event(socket, "stop-recording", %{}) | ||||
|         {:noreply, socket} | ||||
|     end | ||||
|  | ||||
|           await channel.join() | ||||
|             .receive("ok", () => { | ||||
|               console.log("✅ Canal conectado") | ||||
|               statusDiv.textContent = "✅ Canal conectado" | ||||
|             }) | ||||
|             .receive("error", () => { | ||||
|               console.error("❌ Error al conectar canal") | ||||
|               statusDiv.textContent = "❌ Error canal" | ||||
|             }) | ||||
|     defp socket_id(socket), do: socket.transport_pid |> :erlang.pid_to_list() |> List.to_string() | ||||
|  | ||||
|           try { | ||||
|             audioContext = new AudioContext({ sampleRate }) | ||||
|             mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }) | ||||
|           } catch (err) { | ||||
|             console.error("❌ Micrófono error:", err) | ||||
|             statusDiv.textContent = "❌ Error accediendo al micrófono" | ||||
|             return | ||||
|           } | ||||
|     def render(assigns) do | ||||
|         ~H""" | ||||
|         <div id="recorder" data-hook="recorder"> | ||||
|             <div class="flex space-x-2"> | ||||
|                 <button id="startButton" phx-click="start_recording" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"> | ||||
|                 Start Recording | ||||
|                 </button> | ||||
|                 <button id="stopButton" phx-click="stop_recording" class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"> | ||||
|                 Stop Recording | ||||
|                 </button> | ||||
|             </div> | ||||
|  | ||||
|           const source = audioContext.createMediaStreamSource(mediaStream) | ||||
|           processor = audioContext.createScriptProcessor(4096, 1, 1) | ||||
|           source.connect(processor) | ||||
|           processor.connect(audioContext.destination) | ||||
|             <div id="status" class="text-sm text-gray-600"></div> | ||||
|  | ||||
|           buffer = [] | ||||
|           processor.onaudioprocess = e => { | ||||
|             const input = e.inputBuffer.getChannelData(0) | ||||
|             const pcm = new Int16Array(input.length) | ||||
|             for (let i = 0; i < input.length; i++) { | ||||
|               let s = Math.max(-1, Math.min(1, input[i])) | ||||
|               pcm[i] = s < 0 ? s * 0x8000 : s * 0x7FFF | ||||
|             } | ||||
|             buffer.push(pcm) | ||||
|           } | ||||
|             <div id="transcriptionContainer" class="space-y-2"> | ||||
|                 <div class="p-2 bg-gray-100 rounded shadow"> | ||||
|                 <h2 class="text-sm font-semibold text-gray-700 mb-1">🟠 Transcripción en vivo</h2> | ||||
|                 <p id="transcription" class="text-orange-600 whitespace-pre-wrap"><%= @transcription %></p> | ||||
|             </div> | ||||
|  | ||||
|           sendInterval = setInterval(() => { | ||||
|             if (buffer.length === 0) return | ||||
|             const merged = flattenInt16(buffer) | ||||
|             buffer = [] | ||||
|             <%= if @transcription_m != "" do %> | ||||
|                 <div class="p-2 bg-gray-100 rounded shadow"> | ||||
|                     <h2 class="text-sm font-semibold text-gray-700 mb-1">✅ Transcripción mejorada</h2> | ||||
|                     <p class="text-green-600 whitespace-pre-wrap"><%= @transcription_m %></p> | ||||
|                 </div> | ||||
|             <% end %> | ||||
|             </div> | ||||
|             <script type="module"> | ||||
|             import { Socket } from "https://cdn.skypack.dev/phoenix" | ||||
|  | ||||
|             function encodeBase64(uint8Array) { | ||||
|               let binary = '' | ||||
|               const len = uint8Array.byteLength | ||||
|               for (let i = 0; i < len; i++) { | ||||
|                 binary += String.fromCharCode(uint8Array[i]) | ||||
|               } | ||||
|               return btoa(binary) | ||||
|             const startButton = document.getElementById("startButton") | ||||
|             const stopButton = document.getElementById("stopButton") | ||||
|             const statusDiv = document.getElementById("status") | ||||
|  | ||||
|             let socket = null | ||||
|             let channel = null | ||||
|             let audioContext = null | ||||
|             let processor = null | ||||
|             let mediaStream = null | ||||
|             let buffer = [] | ||||
|             let sendInterval = null | ||||
|  | ||||
|             const sampleRate = 48000 | ||||
|  | ||||
|             async function startRecording() { | ||||
|                 startButton.disabled = true | ||||
|                 stopButton.disabled = false | ||||
|                 statusDiv.textContent = "🎙 Grabando..." | ||||
|  | ||||
|                 socket = new Socket("ws://localhost:4004/socket") | ||||
|                 socket.connect() | ||||
|                 channel = socket.channel("audio:lobby") | ||||
|  | ||||
|                 await channel.join() | ||||
|                 .receive("ok", () => { | ||||
|                     console.log("✅ Canal conectado") | ||||
|                     statusDiv.textContent = "✅ Canal conectado" | ||||
|                 }) | ||||
|                 .receive("error", () => { | ||||
|                     console.error("❌ Error al conectar canal") | ||||
|                     statusDiv.textContent = "❌ Error canal" | ||||
|                 }) | ||||
|  | ||||
|                 try { | ||||
|                 audioContext = new AudioContext({ sampleRate }) | ||||
|                 mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }) | ||||
|                 } catch (err) { | ||||
|                 console.error("❌ Micrófono error:", err) | ||||
|                 statusDiv.textContent = "❌ Error accediendo al micrófono" | ||||
|                 return | ||||
|                 } | ||||
|  | ||||
|                 const source = audioContext.createMediaStreamSource(mediaStream) | ||||
|                 processor = audioContext.createScriptProcessor(4096, 1, 1) | ||||
|                 source.connect(processor) | ||||
|                 processor.connect(audioContext.destination) | ||||
|  | ||||
|                 buffer = [] | ||||
|                 processor.onaudioprocess = e => { | ||||
|                 const input = e.inputBuffer.getChannelData(0) | ||||
|                 const pcm = new Int16Array(input.length) | ||||
|                 for (let i = 0; i < input.length; i++) { | ||||
|                     let s = Math.max(-1, Math.min(1, input[i])) | ||||
|                     pcm[i] = s < 0 ? s * 0x8000 : s * 0x7FFF | ||||
|                 } | ||||
|                 buffer.push(pcm) | ||||
|                 } | ||||
|  | ||||
|                 sendInterval = setInterval(() => { | ||||
|                 if (buffer.length === 0) return | ||||
|                 const merged = flattenInt16(buffer) | ||||
|                 buffer = [] | ||||
|  | ||||
|                 function encodeBase64(uint8Array) { | ||||
|                     let binary = '' | ||||
|                     const len = uint8Array.byteLength | ||||
|                     for (let i = 0; i < len; i++) { | ||||
|                     binary += String.fromCharCode(uint8Array[i]) | ||||
|                     } | ||||
|                     return btoa(binary) | ||||
|                 } | ||||
|  | ||||
|                 const base64 = encodeBase64(new Uint8Array(merged.buffer)) | ||||
|                 channel.push("audio_chunk", { data: base64, sample_rate: sampleRate }) | ||||
|                 console.log("📤 Enviado chunk") | ||||
|                 }, 2000) | ||||
|             } | ||||
|  | ||||
|             const base64 = encodeBase64(new Uint8Array(merged.buffer)) | ||||
|             channel.push("audio_chunk", { data: base64, sample_rate: sampleRate }) | ||||
|             console.log("📤 Enviado chunk") | ||||
|           }, 2000) | ||||
|         } | ||||
|             function stopRecording() { | ||||
|                 stopButton.disabled = true | ||||
|                 startButton.disabled = false | ||||
|                 statusDiv.textContent = "🛑 Grabación detenida." | ||||
|  | ||||
|         function stopRecording() { | ||||
|           stopButton.disabled = true | ||||
|           startButton.disabled = false | ||||
|           statusDiv.textContent = "🛑 Grabación detenida." | ||||
|                 if (processor) processor.disconnect() | ||||
|                 if (audioContext) audioContext.close() | ||||
|                 if (mediaStream) mediaStream.getTracks().forEach(t => t.stop()) | ||||
|                 if (sendInterval) clearInterval(sendInterval) | ||||
|  | ||||
|           if (processor) processor.disconnect() | ||||
|           if (audioContext) audioContext.close() | ||||
|           if (mediaStream) mediaStream.getTracks().forEach(t => t.stop()) | ||||
|           if (sendInterval) clearInterval(sendInterval) | ||||
|                 if (channel) { | ||||
|                 channel.push("stop_audio") | ||||
|                 setTimeout(() => { | ||||
|                     channel.leave() | ||||
|                     socket.disconnect() | ||||
|                     console.log("🔌 Socket cerrado") | ||||
|                 }, 500) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|           if (channel) { | ||||
|             channel.push("stop_audio") | ||||
|             setTimeout(() => { | ||||
|               channel.leave() | ||||
|               socket.disconnect() | ||||
|               console.log("🔌 Socket cerrado") | ||||
|             }, 500) | ||||
|           } | ||||
|         } | ||||
|             function flattenInt16(buffers) { | ||||
|                 const length = buffers.reduce((acc, b) => acc + b.length, 0) | ||||
|                 const out = new Int16Array(length) | ||||
|                 let offset = 0 | ||||
|                 for (const b of buffers) { | ||||
|                 out.set(b, offset) | ||||
|                 offset += b.length | ||||
|                 } | ||||
|                 return out | ||||
|             } | ||||
|  | ||||
|         function flattenInt16(buffers) { | ||||
|           const length = buffers.reduce((acc, b) => acc + b.length, 0) | ||||
|           const out = new Int16Array(length) | ||||
|           let offset = 0 | ||||
|           for (const b of buffers) { | ||||
|             out.set(b, offset) | ||||
|             offset += b.length | ||||
|           } | ||||
|           return out | ||||
|         } | ||||
|  | ||||
|         startButton.onclick = startRecording | ||||
|         stopButton.onclick = stopRecording | ||||
|       </script> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|             startButton.onclick = startRecording | ||||
|             stopButton.onclick = stopRecording | ||||
|             </script> | ||||
|         </div> | ||||
|         """ | ||||
|     end | ||||
| end | ||||
|  | ||||
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								whisper_live/recordings/recording_1752678344186.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								whisper_live/recordings/recording_1752678344186.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
		Reference in New Issue
	
	Block a user