timgremore
commited on
Commit
•
6ee6b5e
1
Parent(s):
101a164
chore: Move audio recording state to sidebar button
Browse files- assets/js/app.js +36 -0
- assets/js/pcm-processor.js +24 -0
- config/config.exs +1 -1
- lib/medicode/application.ex +0 -1
- lib/medicode/transcription_server.ex +61 -10
- lib/medicode/transcription_supervisor.ex +1 -1
- lib/medicode/transcriptions/transcription.ex +1 -1
- lib/medicode_web/components/components.ex +0 -29
- lib/medicode_web/components/sidebar_component.ex +34 -4
- lib/medicode_web/components/transcription_text_component.ex +2 -1
- lib/medicode_web/live/transcriptions_live/show.ex +56 -35
- lib/medicode_web/live/user_settings_live.ex +3 -2
- mix.exs +2 -1
- mix.lock +1 -0
assets/js/app.js
CHANGED
@@ -52,6 +52,42 @@ Hooks.ContentEditor = {
|
|
52 |
},
|
53 |
};
|
54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
55 |
let csrfToken = document
|
56 |
.querySelector("meta[name='csrf-token']")
|
57 |
.getAttribute("content");
|
|
|
52 |
},
|
53 |
};
|
54 |
|
55 |
+
let audioContext = null;
|
56 |
+
let pcmNode = null;
|
57 |
+
|
58 |
+
Hooks.AudioRecorder = {
|
59 |
+
mounted() {
|
60 |
+
this.handleEvent("start_audio_recording", async () => {
|
61 |
+
const audio = this.el;
|
62 |
+
const context = new AudioContext({ sampleRate: 16_000 });
|
63 |
+
|
64 |
+
await context.audioWorklet.addModule("/assets/pcm-processor.js");
|
65 |
+
|
66 |
+
const node = new AudioWorkletNode(context, "pcm-processor", {
|
67 |
+
processorOptions: { chunkSize: 16_000 },
|
68 |
+
});
|
69 |
+
|
70 |
+
node.port.onmessage = (event) => {
|
71 |
+
const audioData = new Float32Array(event.data);
|
72 |
+
this.pushEvent("audio_chunk", { data: event.data });
|
73 |
+
};
|
74 |
+
|
75 |
+
audioContext = context;
|
76 |
+
pcmNode = node;
|
77 |
+
|
78 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
79 |
+
const source = audioContext.createMediaStreamSource(stream);
|
80 |
+
|
81 |
+
source.connect(pcmNode);
|
82 |
+
pcmNode.connect(audioContext.destination); // Necessary but doesn't output sound to speakers
|
83 |
+
});
|
84 |
+
|
85 |
+
this.handleEvent("stop_audio_recording", async () => {
|
86 |
+
audioContext.close();
|
87 |
+
});
|
88 |
+
},
|
89 |
+
};
|
90 |
+
|
91 |
let csrfToken = document
|
92 |
.querySelector("meta[name='csrf-token']")
|
93 |
.getAttribute("content");
|
assets/js/pcm-processor.js
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
class PCMProcessor extends AudioWorkletProcessor {
|
2 |
+
constructor(options) {
|
3 |
+
super();
|
4 |
+
this.sampleBuffer = [];
|
5 |
+
this.chunkSize = options.processorOptions.chunkSize || 16_000;
|
6 |
+
}
|
7 |
+
|
8 |
+
process(inputs) {
|
9 |
+
const input = inputs[0];
|
10 |
+
if (input.length > 0) {
|
11 |
+
const inputData = input[0];
|
12 |
+
for (let i = 0; i < inputData.length; ++i) {
|
13 |
+
this.sampleBuffer.push(inputData[i]);
|
14 |
+
if (this.sampleBuffer.length >= this.chunkSize) {
|
15 |
+
this.port.postMessage(this.sampleBuffer.slice(0, this.chunkSize));
|
16 |
+
this.sampleBuffer = this.sampleBuffer.slice(this.chunkSize);
|
17 |
+
}
|
18 |
+
}
|
19 |
+
}
|
20 |
+
return true;
|
21 |
+
}
|
22 |
+
}
|
23 |
+
|
24 |
+
registerProcessor("pcm-processor", PCMProcessor);
|
config/config.exs
CHANGED
@@ -44,7 +44,7 @@ config :esbuild,
|
|
44 |
version: "0.17.11",
|
45 |
default: [
|
46 |
args:
|
47 |
-
~w(js/app.js js/storybook.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
|
48 |
cd: Path.expand("../assets", __DIR__),
|
49 |
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
50 |
]
|
|
|
44 |
version: "0.17.11",
|
45 |
default: [
|
46 |
args:
|
47 |
+
~w(js/app.js js/storybook.js js/pcm-processor.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
|
48 |
cd: Path.expand("../assets", __DIR__),
|
49 |
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
50 |
]
|
lib/medicode/application.ex
CHANGED
@@ -23,7 +23,6 @@ defmodule Medicode.Application do
|
|
23 |
transcription_spec(),
|
24 |
token_classification_spec(),
|
25 |
text_embedding_spec(),
|
26 |
-
{Registry, keys: :unique, name: :transcription_registry},
|
27 |
{
|
28 |
Medicode.TranscriptionSupervisor,
|
29 |
strategy: :one_for_one, max_restarts: 1
|
|
|
23 |
transcription_spec(),
|
24 |
token_classification_spec(),
|
25 |
text_embedding_spec(),
|
|
|
26 |
{
|
27 |
Medicode.TranscriptionSupervisor,
|
28 |
strategy: :one_for_one, max_restarts: 1
|
lib/medicode/transcription_server.ex
CHANGED
@@ -7,10 +7,13 @@ defmodule Medicode.TranscriptionServer do
|
|
7 |
alias Medicode.Transcriptions
|
8 |
alias Medicode.Transcriptions.Transcription
|
9 |
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
|
|
|
|
|
|
14 |
end
|
15 |
|
16 |
@doc """
|
@@ -38,7 +41,19 @@ defmodule Medicode.TranscriptionServer do
|
|
38 |
end
|
39 |
|
40 |
@impl GenServer
|
41 |
-
def handle_continue(:start, {:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
{:ok, transcription} =
|
43 |
Transcriptions.update_transcription(transcription, %{status: :transcribing})
|
44 |
|
@@ -50,12 +65,12 @@ defmodule Medicode.TranscriptionServer do
|
|
50 |
|
51 |
stream_transcription_and_search(transcription.filename)
|
52 |
|
53 |
-
{:noreply, {:
|
54 |
end
|
55 |
|
56 |
@impl GenServer
|
57 |
def handle_info({:chunk, result}, state) do
|
58 |
-
{:
|
59 |
|
60 |
%Transcription{id: id} = transcription
|
61 |
|
@@ -85,7 +100,7 @@ defmodule Medicode.TranscriptionServer do
|
|
85 |
|
86 |
@impl GenServer
|
87 |
def terminate(reason, state) do
|
88 |
-
{:
|
89 |
|
90 |
{:ok, transcription} =
|
91 |
Transcriptions.update_transcription(transcription, %{status: :finished})
|
@@ -101,6 +116,40 @@ defmodule Medicode.TranscriptionServer do
|
|
101 |
reason
|
102 |
end
|
103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
104 |
# Ideas for future exploration:
|
105 |
# - A potential improvement would be to not code each chunk of transcribed audio separately, but to instead gather
|
106 |
# complete sentences based on punctuation. We may want to suggest codes for the entire audio as a single piece as
|
@@ -128,6 +177,8 @@ defmodule Medicode.TranscriptionServer do
|
|
128 |
seconds |> round() |> Time.from_seconds_after_midnight() |> Time.to_string()
|
129 |
end
|
130 |
|
131 |
-
defp via_tuple(
|
132 |
-
|
|
|
|
|
133 |
end
|
|
|
7 |
alias Medicode.Transcriptions
|
8 |
alias Medicode.Transcriptions.Transcription
|
9 |
|
10 |
+
def start_link(%{
|
11 |
+
transcription: transcription,
|
12 |
+
transcription_id: transcription_id
|
13 |
+
}) do
|
14 |
+
GenServer.start_link(__MODULE__, %{transcription: transcription},
|
15 |
+
name: via_tuple(transcription_id)
|
16 |
+
)
|
17 |
end
|
18 |
|
19 |
@doc """
|
|
|
41 |
end
|
42 |
|
43 |
@impl GenServer
|
44 |
+
def handle_continue(:start, %{transcription: %{status: :recording}} = state) do
|
45 |
+
%{transcription: transcription} = state
|
46 |
+
|
47 |
+
Phoenix.PubSub.broadcast(
|
48 |
+
:medicode_pubsub,
|
49 |
+
"transcriptions:#{transcription.id}",
|
50 |
+
{:transcription_started, transcription.id}
|
51 |
+
)
|
52 |
+
|
53 |
+
{:noreply, %{transcription: transcription}}
|
54 |
+
end
|
55 |
+
|
56 |
+
def handle_continue(:start, %{transcription: transcription}) do
|
57 |
{:ok, transcription} =
|
58 |
Transcriptions.update_transcription(transcription, %{status: :transcribing})
|
59 |
|
|
|
65 |
|
66 |
stream_transcription_and_search(transcription.filename)
|
67 |
|
68 |
+
{:noreply, %{transcription: transcription}}
|
69 |
end
|
70 |
|
71 |
@impl GenServer
|
72 |
def handle_info({:chunk, result}, state) do
|
73 |
+
%{transcription: transcription} = state
|
74 |
|
75 |
%Transcription{id: id} = transcription
|
76 |
|
|
|
100 |
|
101 |
@impl GenServer
|
102 |
def terminate(reason, state) do
|
103 |
+
%{transcription: transcription} = state
|
104 |
|
105 |
{:ok, transcription} =
|
106 |
Transcriptions.update_transcription(transcription, %{status: :finished})
|
|
|
116 |
reason
|
117 |
end
|
118 |
|
119 |
+
@impl GenServer
|
120 |
+
# def handle_call({:recording, %{data: data}}, _from, state) do
|
121 |
+
def handle_cast({:recording, %{data: data}}, state) do
|
122 |
+
tensor =
|
123 |
+
Nx.tensor(data)
|
124 |
+
|> Nx.stack()
|
125 |
+
|> Nx.reshape({:auto, 1})
|
126 |
+
|> Nx.mean(axes: [1])
|
127 |
+
|
128 |
+
# audio transcription + semantic search
|
129 |
+
Medicode.TranscriptionServing
|
130 |
+
|> Nx.Serving.batched_run(tensor)
|
131 |
+
|> Enum.each(fn chunk ->
|
132 |
+
dbg(chunk)
|
133 |
+
|
134 |
+
result = %{
|
135 |
+
start_mark: format_timestamp(chunk.start_timestamp_seconds),
|
136 |
+
end_mark: format_timestamp(chunk.end_timestamp_seconds),
|
137 |
+
text: chunk.text
|
138 |
+
}
|
139 |
+
|
140 |
+
send(self(), {:chunk, result})
|
141 |
+
end)
|
142 |
+
|
143 |
+
{:noreply, state}
|
144 |
+
end
|
145 |
+
|
146 |
+
def process_recording_data(transcription_id, data) do
|
147 |
+
GenServer.cast(
|
148 |
+
{:via, :gproc, {:n, :l, {:transcription, transcription_id}}},
|
149 |
+
{:recording, %{data: data}}
|
150 |
+
)
|
151 |
+
end
|
152 |
+
|
153 |
# Ideas for future exploration:
|
154 |
# - A potential improvement would be to not code each chunk of transcribed audio separately, but to instead gather
|
155 |
# complete sentences based on punctuation. We may want to suggest codes for the entire audio as a single piece as
|
|
|
177 |
seconds |> round() |> Time.from_seconds_after_midnight() |> Time.to_string()
|
178 |
end
|
179 |
|
180 |
+
defp via_tuple(transcription_id),
|
181 |
+
# NOTE: gproc requires keys that are a tuple of {type, scope, key}
|
182 |
+
# See https://www.brianstorti.com/process-registry-in-elixir
|
183 |
+
do: {:via, :gproc, {:n, :l, {:transcription, transcription_id}}}
|
184 |
end
|
lib/medicode/transcription_supervisor.ex
CHANGED
@@ -17,7 +17,7 @@ defmodule Medicode.TranscriptionSupervisor do
|
|
17 |
def start_transcription(transcription) do
|
18 |
spec = {
|
19 |
Medicode.TranscriptionServer,
|
20 |
-
%{transcription: transcription,
|
21 |
}
|
22 |
|
23 |
DynamicSupervisor.start_child(__MODULE__, spec)
|
|
|
17 |
def start_transcription(transcription) do
|
18 |
spec = {
|
19 |
Medicode.TranscriptionServer,
|
20 |
+
%{transcription: transcription, transcription_id: transcription.id}
|
21 |
}
|
22 |
|
23 |
DynamicSupervisor.start_child(__MODULE__, spec)
|
lib/medicode/transcriptions/transcription.ex
CHANGED
@@ -13,7 +13,7 @@ defmodule Medicode.Transcriptions.Transcription do
|
|
13 |
field :recording_length_in_seconds, :integer
|
14 |
|
15 |
field :status, Ecto.Enum,
|
16 |
-
values: [new: 0, waiting: 1, transcribing: 2, finished: 3, failed: 4]
|
17 |
|
18 |
belongs_to :user, Medicode.Accounts.User
|
19 |
|
|
|
13 |
field :recording_length_in_seconds, :integer
|
14 |
|
15 |
field :status, Ecto.Enum,
|
16 |
+
values: [new: 0, waiting: 1, transcribing: 2, finished: 3, failed: 4, recording: 5]
|
17 |
|
18 |
belongs_to :user, Medicode.Accounts.User
|
19 |
|
lib/medicode_web/components/components.ex
CHANGED
@@ -102,8 +102,6 @@ defmodule MedicodeWeb.Components do
|
|
102 |
class="mr-[17px]"
|
103 |
/>
|
104 |
|
105 |
-
<.record_button_in_heading status={@transcription.status} />
|
106 |
-
|
107 |
<div class="px-[14px] py-3 flex items-center gap-3 bg-brand rounded-lg text-white mr-4 overflow-hidden">
|
108 |
<img src={~p"/images/document.svg"} width="20" />
|
109 |
<p
|
@@ -165,33 +163,6 @@ defmodule MedicodeWeb.Components do
|
|
165 |
"""
|
166 |
end
|
167 |
|
168 |
-
# Renders the record button within the result_heading component
|
169 |
-
defp record_button_in_heading(assigns) when assigns.status == :streaming_audio do
|
170 |
-
~H"""
|
171 |
-
<button
|
172 |
-
disabled
|
173 |
-
phx-click="toggle_recording"
|
174 |
-
title="Not available"
|
175 |
-
class="cursor-not-allowed mr-6 px-4 py-3 bg-red-200 rounded-lg"
|
176 |
-
>
|
177 |
-
<.icon name="hero-microphone" class="animate-pulse" />
|
178 |
-
</button>
|
179 |
-
"""
|
180 |
-
end
|
181 |
-
|
182 |
-
defp record_button_in_heading(assigns) do
|
183 |
-
~H"""
|
184 |
-
<button
|
185 |
-
disabled
|
186 |
-
phx-click="toggle_recording"
|
187 |
-
title="Not available"
|
188 |
-
class="cursor-not-allowed mr-6 px-4 py-3 bg-emerald-200 rounded-lg"
|
189 |
-
>
|
190 |
-
<.icon name="hero-microphone" />
|
191 |
-
</button>
|
192 |
-
"""
|
193 |
-
end
|
194 |
-
|
195 |
# Formats keywords for display in the result_heading component
|
196 |
defp format_keywords(keyword_predictions) do
|
197 |
keyword_predictions
|
|
|
102 |
class="mr-[17px]"
|
103 |
/>
|
104 |
|
|
|
|
|
105 |
<div class="px-[14px] py-3 flex items-center gap-3 bg-brand rounded-lg text-white mr-4 overflow-hidden">
|
106 |
<img src={~p"/images/document.svg"} width="20" />
|
107 |
<p
|
|
|
163 |
"""
|
164 |
end
|
165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
166 |
# Formats keywords for display in the result_heading component
|
167 |
defp format_keywords(keyword_predictions) do
|
168 |
keyword_predictions
|
lib/medicode_web/components/sidebar_component.ex
CHANGED
@@ -22,6 +22,7 @@ defmodule MedicodeWeb.Components.SidebarComponent do
|
|
22 |
|
23 |
socket =
|
24 |
socket
|
|
|
25 |
|> assign(:current_user, assigns.current_user)
|
26 |
|> assign(:transcriptions, list_transcriptions(assigns.current_user))
|
27 |
|
@@ -49,14 +50,18 @@ defmodule MedicodeWeb.Components.SidebarComponent do
|
|
49 |
<.icon name="hero-document-plus" />
|
50 |
<span>Upload Audio File</span>
|
51 |
</.link>
|
52 |
-
|
|
|
|
|
|
|
|
|
53 |
navigate={~p"/transcriptions/new"}
|
54 |
class="text-[0.8125rem] flex justify-center gap-2 w-full text-white font-semibold hover:text-slate-300 px-3 py-2 bg-emerald-600 rounded-lg"
|
55 |
>
|
56 |
-
<.icon name="hero-
|
|
|
57 |
<span>Record Patient Notes</span>
|
58 |
-
|
59 |
-
<p class="text-xs leading-normal tracking-[0.2em] font-semibold uppercase">Today</p>
|
60 |
<div class="flex flex-col gap-4">
|
61 |
<.link
|
62 |
:for={transcription <- @transcriptions}
|
@@ -119,6 +124,31 @@ defmodule MedicodeWeb.Components.SidebarComponent do
|
|
119 |
"""
|
120 |
end
|
121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
122 |
@impl Phoenix.LiveView
|
123 |
def handle_info({:transcription_created, _transcription_id}, socket) do
|
124 |
transcriptions = list_transcriptions(socket.assigns.current_user)
|
|
|
22 |
|
23 |
socket =
|
24 |
socket
|
25 |
+
|> assign(:status, nil)
|
26 |
|> assign(:current_user, assigns.current_user)
|
27 |
|> assign(:transcriptions, list_transcriptions(assigns.current_user))
|
28 |
|
|
|
50 |
<.icon name="hero-document-plus" />
|
51 |
<span>Upload Audio File</span>
|
52 |
</.link>
|
53 |
+
<button
|
54 |
+
id="audio-recorder-button"
|
55 |
+
phx-hook="AudioRecorder"
|
56 |
+
phx-click="record_transcription"
|
57 |
+
phx-target={@myself}
|
58 |
navigate={~p"/transcriptions/new"}
|
59 |
class="text-[0.8125rem] flex justify-center gap-2 w-full text-white font-semibold hover:text-slate-300 px-3 py-2 bg-emerald-600 rounded-lg"
|
60 |
>
|
61 |
+
<.icon :if={@status == :streaming_audio} name="hero-speaker-wave" />
|
62 |
+
<.icon :if={@status != :streaming_audio} name="hero-microphone" />
|
63 |
<span>Record Patient Notes</span>
|
64 |
+
</button>
|
|
|
65 |
<div class="flex flex-col gap-4">
|
66 |
<.link
|
67 |
:for={transcription <- @transcriptions}
|
|
|
124 |
"""
|
125 |
end
|
126 |
|
127 |
+
@impl Phoenix.LiveView
|
128 |
+
def handle_event("record_transcription", _params, socket) do
|
129 |
+
socket =
|
130 |
+
if socket.assigns.status == :streaming_audio do
|
131 |
+
socket
|
132 |
+
|> push_event("stop_audio_recording", %{})
|
133 |
+
|> assign(:status, nil)
|
134 |
+
else
|
135 |
+
{:ok, new_transcription} =
|
136 |
+
Transcriptions.create_transcription(%{
|
137 |
+
user_id: socket.assigns.current_user.id,
|
138 |
+
filename: "Untitled",
|
139 |
+
status: :recording
|
140 |
+
})
|
141 |
+
|
142 |
+
Medicode.TranscriptionSupervisor.start_transcription(new_transcription)
|
143 |
+
|
144 |
+
socket
|
145 |
+
|> assign(:status, :streaming_audio)
|
146 |
+
|> push_patch(to: ~p"/transcriptions/#{new_transcription.id}")
|
147 |
+
end
|
148 |
+
|
149 |
+
{:noreply, socket}
|
150 |
+
end
|
151 |
+
|
152 |
@impl Phoenix.LiveView
|
153 |
def handle_info({:transcription_created, _transcription_id}, socket) do
|
154 |
transcriptions = list_transcriptions(socket.assigns.current_user)
|
lib/medicode_web/components/transcription_text_component.ex
CHANGED
@@ -45,7 +45,8 @@ defmodule MedicodeWeb.Components.TranscriptionTextComponent do
|
|
45 |
# NOTE: This assumes that if a process matches by transcription chunk ID that
|
46 |
# the chunk is in the process of loading. Specific state inspection could happen
|
47 |
# with the :sys module (ie :sys.get_state/1)
|
48 |
-
case Registry.lookup(:
|
|
|
49 |
[] -> false
|
50 |
[{_pid, _}] -> true
|
51 |
end
|
|
|
45 |
# NOTE: This assumes that if a process matches by transcription chunk ID that
|
46 |
# the chunk is in the process of loading. Specific state inspection could happen
|
47 |
# with the :sys module (ie :sys.get_state/1)
|
48 |
+
# case Registry.lookup({:via, :gproc, {:n, :l, {:transcription, transcription_id}}}) do
|
49 |
+
case [] do
|
50 |
[] -> false
|
51 |
[{_pid, _}] -> true
|
52 |
end
|
lib/medicode_web/live/transcriptions_live/show.ex
CHANGED
@@ -1,22 +1,36 @@
|
|
1 |
defmodule MedicodeWeb.TranscriptionsLive.Show do
|
2 |
use MedicodeWeb, :live_view
|
3 |
|
4 |
-
alias Medicode.Audio.RecordingPipeline
|
5 |
alias Medicode.Transcriptions
|
6 |
|
7 |
alias MedicodeWeb.Components.TranscriptionTextComponent
|
8 |
alias MedicodeWeb.Components.TranscriptionTextCodingsComponent
|
9 |
|
10 |
@impl Phoenix.LiveView
|
11 |
-
def mount(
|
12 |
-
transcription = get_transcription(params["id"])
|
13 |
-
|
14 |
timezone = get_connect_params(socket)["timezone"] || "Etc/UTC"
|
15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
16 |
if is_nil(transcription), do: raise(Medicode.Fallback)
|
17 |
|
18 |
if connected?(socket) do
|
19 |
-
Phoenix.PubSub.subscribe(:medicode_pubsub, "medicode:#{session["current_user"].id}")
|
20 |
Phoenix.PubSub.subscribe(:medicode_pubsub, "transcriptions:#{transcription.id}")
|
21 |
end
|
22 |
|
@@ -26,24 +40,24 @@ defmodule MedicodeWeb.TranscriptionsLive.Show do
|
|
26 |
finalized_codes = Transcriptions.list_transcription_finalized_codes(transcription.id)
|
27 |
|
28 |
initial_state = %{
|
29 |
-
current_recording_id: 0,
|
30 |
-
uploaded_file_name: nil,
|
31 |
-
status: :pending,
|
32 |
-
audio_pipeline: nil,
|
33 |
summary_keywords: summary_keywords,
|
34 |
transcription: transcription,
|
35 |
-
|
36 |
-
finalized_codes: finalized_codes,
|
37 |
-
timezone: timezone
|
38 |
}
|
39 |
|
40 |
socket =
|
41 |
socket
|
42 |
|> assign(initial_state)
|
43 |
-
|>
|
44 |
-
|> stream(:chunk_ids, transcription_chunk_ids)
|
45 |
|
46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
47 |
end
|
48 |
|
49 |
@impl Phoenix.LiveView
|
@@ -59,6 +73,7 @@ defmodule MedicodeWeb.TranscriptionsLive.Show do
|
|
59 |
<main class="flex-1 pl-16 pr-16 pt-[25px]">
|
60 |
<div class="flex flex-col h-full mx-auto max-w-5xl">
|
61 |
<.result_heading
|
|
|
62 |
id="transcription-name"
|
63 |
target={self()}
|
64 |
transcription={@transcription}
|
@@ -68,7 +83,7 @@ defmodule MedicodeWeb.TranscriptionsLive.Show do
|
|
68 |
/>
|
69 |
|
70 |
<img
|
71 |
-
:if={@transcription.status == :transcribing}
|
72 |
src={~p"/images/loading.svg"}
|
73 |
width="36"
|
74 |
class="m-8 animate-spin"
|
@@ -112,23 +127,39 @@ defmodule MedicodeWeb.TranscriptionsLive.Show do
|
|
112 |
|
113 |
def handle_event("toggle_recording", _params, socket) do
|
114 |
socket =
|
115 |
-
if
|
116 |
-
pipeline = RecordingPipeline.start_pipeline(self())
|
117 |
-
|
118 |
socket
|
119 |
-
|>
|
120 |
-
|> assign(:
|
121 |
else
|
122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
123 |
|
124 |
socket
|
125 |
-
|> assign(:status, :
|
126 |
-
|>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
127 |
end
|
128 |
|
129 |
{:noreply, socket}
|
130 |
end
|
131 |
|
|
|
|
|
|
|
|
|
|
|
132 |
def handle_event("rename_transcription", %{"value" => new_name}, socket) do
|
133 |
new_name_trimmed = String.trim(new_name)
|
134 |
|
@@ -150,17 +181,7 @@ defmodule MedicodeWeb.TranscriptionsLive.Show do
|
|
150 |
end
|
151 |
end
|
152 |
|
153 |
-
@impl
|
154 |
-
def handle_info({:transcription_created, _transcription_id}, socket) do
|
155 |
-
transcriptions = list_transcriptions(socket.assigns.current_user)
|
156 |
-
|
157 |
-
socket =
|
158 |
-
socket
|
159 |
-
|> assign(:transcriptions, transcriptions)
|
160 |
-
|
161 |
-
{:noreply, socket}
|
162 |
-
end
|
163 |
-
|
164 |
def handle_info({:transcription_updated, %{id: chunk_id}}, socket) do
|
165 |
transcription = get_transcription(socket.assigns.transcription.id)
|
166 |
|
|
|
1 |
defmodule MedicodeWeb.TranscriptionsLive.Show do
|
2 |
use MedicodeWeb, :live_view
|
3 |
|
|
|
4 |
alias Medicode.Transcriptions
|
5 |
|
6 |
alias MedicodeWeb.Components.TranscriptionTextComponent
|
7 |
alias MedicodeWeb.Components.TranscriptionTextCodingsComponent
|
8 |
|
9 |
@impl Phoenix.LiveView
|
10 |
+
def mount(_params, session, socket) do
|
|
|
|
|
11 |
timezone = get_connect_params(socket)["timezone"] || "Etc/UTC"
|
12 |
|
13 |
+
socket =
|
14 |
+
socket
|
15 |
+
|> assign(:status, nil)
|
16 |
+
|> assign(:current_user, Medicode.Accounts.get_user!(session["current_user"].id))
|
17 |
+
|> assign(:timezone, timezone)
|
18 |
+
|> stream_configure(:chunk_ids, dom_id: &"transcription-chunk-#{&1.id}")
|
19 |
+
|
20 |
+
{:ok, socket}
|
21 |
+
end
|
22 |
+
|
23 |
+
@impl Phoenix.LiveView
|
24 |
+
def handle_params(params, _url, socket) do
|
25 |
+
if connected?(socket) do
|
26 |
+
Phoenix.PubSub.subscribe(:medicode_pubsub, "medicode:#{socket.assigns.current_user.id}")
|
27 |
+
end
|
28 |
+
|
29 |
+
transcription = get_transcription(params["id"])
|
30 |
+
|
31 |
if is_nil(transcription), do: raise(Medicode.Fallback)
|
32 |
|
33 |
if connected?(socket) do
|
|
|
34 |
Phoenix.PubSub.subscribe(:medicode_pubsub, "transcriptions:#{transcription.id}")
|
35 |
end
|
36 |
|
|
|
40 |
finalized_codes = Transcriptions.list_transcription_finalized_codes(transcription.id)
|
41 |
|
42 |
initial_state = %{
|
|
|
|
|
|
|
|
|
43 |
summary_keywords: summary_keywords,
|
44 |
transcription: transcription,
|
45 |
+
finalized_codes: finalized_codes
|
|
|
|
|
46 |
}
|
47 |
|
48 |
socket =
|
49 |
socket
|
50 |
|> assign(initial_state)
|
51 |
+
|> stream(:chunk_ids, transcription_chunk_ids, reset: true)
|
|
|
52 |
|
53 |
+
socket =
|
54 |
+
if transcription.status == :recording do
|
55 |
+
push_event(socket, "start_audio_recording", %{})
|
56 |
+
else
|
57 |
+
socket
|
58 |
+
end
|
59 |
+
|
60 |
+
{:noreply, socket}
|
61 |
end
|
62 |
|
63 |
@impl Phoenix.LiveView
|
|
|
73 |
<main class="flex-1 pl-16 pr-16 pt-[25px]">
|
74 |
<div class="flex flex-col h-full mx-auto max-w-5xl">
|
75 |
<.result_heading
|
76 |
+
:if={@transcription}
|
77 |
id="transcription-name"
|
78 |
target={self()}
|
79 |
transcription={@transcription}
|
|
|
83 |
/>
|
84 |
|
85 |
<img
|
86 |
+
:if={@transcription && @transcription.status == :transcribing}
|
87 |
src={~p"/images/loading.svg"}
|
88 |
width="36"
|
89 |
class="m-8 animate-spin"
|
|
|
127 |
|
128 |
def handle_event("toggle_recording", _params, socket) do
|
129 |
socket =
|
130 |
+
if socket.assigns.status == :streaming_audio do
|
|
|
|
|
131 |
socket
|
132 |
+
|> push_event("stop_audio_recording", %{})
|
133 |
+
|> assign(:status, nil)
|
134 |
else
|
135 |
+
{:ok, new_transcription} =
|
136 |
+
Transcriptions.create_transcription(%{
|
137 |
+
user_id: socket.assigns.current_user.id,
|
138 |
+
filename: "Untitled"
|
139 |
+
})
|
140 |
+
|
141 |
+
Medicode.TranscriptionSupervisor.start_transcription(new_transcription, true)
|
142 |
|
143 |
socket
|
144 |
+
|> assign(:status, :streaming_audio)
|
145 |
+
|> push_event("start_audio_recording", %{})
|
146 |
+
|> push_patch(to: "/transcriptions/#{new_transcription.id}")
|
147 |
+
|
148 |
+
#
|
149 |
+
# socket
|
150 |
+
# |> assign(:transcription, transcription)
|
151 |
+
# |> assign(:summary_keywords, [])
|
152 |
+
# |> stream(:chunk_ids, [], reset: true)
|
153 |
end
|
154 |
|
155 |
{:noreply, socket}
|
156 |
end
|
157 |
|
158 |
+
def handle_event("audio_chunk", %{"data" => data}, socket) do
|
159 |
+
Medicode.TranscriptionServer.process_recording_data(socket.assigns.transcription.id, data)
|
160 |
+
{:noreply, socket}
|
161 |
+
end
|
162 |
+
|
163 |
def handle_event("rename_transcription", %{"value" => new_name}, socket) do
|
164 |
new_name_trimmed = String.trim(new_name)
|
165 |
|
|
|
181 |
end
|
182 |
end
|
183 |
|
184 |
+
@impl Phoenix.LiveView
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
185 |
def handle_info({:transcription_updated, %{id: chunk_id}}, socket) do
|
186 |
transcription = get_transcription(socket.assigns.transcription.id)
|
187 |
|
lib/medicode_web/live/user_settings_live.ex
CHANGED
@@ -8,9 +8,10 @@ defmodule MedicodeWeb.UserSettingsLive do
|
|
8 |
def render(assigns) do
|
9 |
~H"""
|
10 |
<div class="min-h-[calc(100vh-56px)] flex gap-5">
|
11 |
-
|
|
|
|
|
12 |
current_user={@current_user}
|
13 |
-
transcriptions={@transcriptions}
|
14 |
/>
|
15 |
|
16 |
<div class="w-full flex justify-center">
|
|
|
8 |
def render(assigns) do
|
9 |
~H"""
|
10 |
<div class="min-h-[calc(100vh-56px)] flex gap-5">
|
11 |
+
<.live_component
|
12 |
+
id="sidebar"
|
13 |
+
module={MedicodeWeb.Components.SidebarComponent}
|
14 |
current_user={@current_user}
|
|
|
15 |
/>
|
16 |
|
17 |
<div class="w-full flex justify-center">
|
mix.exs
CHANGED
@@ -73,7 +73,8 @@ defmodule Medicode.MixProject do
|
|
73 |
{:sentry, "~> 8.0"},
|
74 |
{:ecto_psql_extras, "~> 0.6"},
|
75 |
{:circular_buffer, "~> 0.4.0"},
|
76 |
-
{:tzdata, "~> 1.1"}
|
|
|
77 |
# {:membrane_portaudio_plugin, "~> 0.18.0"}
|
78 |
]
|
79 |
end
|
|
|
73 |
{:sentry, "~> 8.0"},
|
74 |
{:ecto_psql_extras, "~> 0.6"},
|
75 |
{:circular_buffer, "~> 0.4.0"},
|
76 |
+
{:tzdata, "~> 1.1"},
|
77 |
+
{:gproc, "~> 1.0.0"}
|
78 |
# {:membrane_portaudio_plugin, "~> 0.18.0"}
|
79 |
]
|
80 |
end
|
mix.lock
CHANGED
@@ -40,6 +40,7 @@
|
|
40 |
"floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"},
|
41 |
"fss": {:hex, :fss, "0.1.1", "9db2344dbbb5d555ce442ac7c2f82dd975b605b50d169314a20f08ed21e08642", [:mix], [], "hexpm", "78ad5955c7919c3764065b21144913df7515d52e228c09427a004afe9c1a16b0"},
|
42 |
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
|
|
|
43 |
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
|
44 |
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
|
45 |
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
|
|
40 |
"floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"},
|
41 |
"fss": {:hex, :fss, "0.1.1", "9db2344dbbb5d555ce442ac7c2f82dd975b605b50d169314a20f08ed21e08642", [:mix], [], "hexpm", "78ad5955c7919c3764065b21144913df7515d52e228c09427a004afe9c1a16b0"},
|
42 |
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
|
43 |
+
"gproc": {:hex, :gproc, "1.0.0", "aa9ec57f6c9ff065b16d96924168d7c7157cd1fd457680efe4b1274f456fa500", [:rebar3], [], "hexpm", "109f253c2787de8a371a51179d4973230cbec6239ee673fa12216a5ce7e4f902"},
|
44 |
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
|
45 |
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
|
46 |
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|