1 | /**
|
---|
2 | * @file Controller logic for asr.xsl.
|
---|
3 | * @author Carl Stephens
|
---|
4 | */
|
---|
5 |
|
---|
6 | /** @type {HTMLAudioElement} */
|
---|
7 | // @ts-ignore
|
---|
8 | const TRANSCRIPTION_AUDIO_ELEMENT = document.getElementById("transcriptionAudio");
|
---|
9 |
|
---|
10 | /** @type {HTMLSourceElement} */
|
---|
11 | // @ts-ignore
|
---|
12 | const TRANSCRIPTION_AUDIO_SOURCE_ELEMENT = document.getElementById("transcriptionAudioSource");
|
---|
13 |
|
---|
14 | /** @type {HTMLUListElement} */
|
---|
15 | // @ts-ignore
|
---|
16 | const TRANSCRIPTIONS_LIST = document.getElementById("transcriptionsList");
|
---|
17 |
|
---|
18 | /** @type {HTMLTemplateElement} */
|
---|
19 | // @ts-ignore
|
---|
20 | const TRANSCRIPTION_TEMPLATE = document.getElementById("transcriptionTemplate");
|
---|
21 |
|
---|
22 | /** @type {HTMLTemplateElement} */
|
---|
23 | // @ts-ignore
|
---|
24 | const ERROR_TEMPLATE = document.getElementById("errorTemplate");
|
---|
25 |
|
---|
26 | let cachedAudioFileList = new Map();
|
---|
27 |
|
---|
28 | /**
|
---|
29 | * @callback loadScriptCallback
|
---|
30 | * @param {Event} ev The event
|
---|
31 | * @returns {void}
|
---|
32 | */
|
---|
33 |
|
---|
34 | // Get the size of each character in our monospace font
|
---|
35 | var MONOSPACE_CHAR_SIZE;
|
---|
36 | window.addEventListener("load", function()
|
---|
37 | {
|
---|
38 | const monoCharSizeTestElement = document.querySelector(".monospace-font-size");
|
---|
39 | if (monoCharSizeTestElement == null || monoCharSizeTestElement.textContent == null) {
|
---|
40 | MONOSPACE_CHAR_SIZE = 8; // Slightly over-estimated guess for size 16 font.
|
---|
41 | return;
|
---|
42 | }
|
---|
43 |
|
---|
44 | MONOSPACE_CHAR_SIZE = monoCharSizeTestElement.clientWidth / monoCharSizeTestElement.textContent.length;
|
---|
45 | });
|
---|
46 |
|
---|
47 | /**
|
---|
48 | * Loads a remote script.
|
---|
49 | * Found at https://stackoverflow.com/questions/950087/how-do-i-include-a-javascript-file-in-another-javascript-file
|
---|
50 | *
|
---|
51 | * @param {string} url The URL from which to load the script.
|
---|
52 | */
|
---|
53 | function loadScript(url, callback = () => {})
|
---|
54 | {
|
---|
55 | var body = document.body;
|
---|
56 |
|
---|
57 | var script = document.createElement('script');
|
---|
58 | script.type = 'text/javascript';
|
---|
59 | script.src = url;
|
---|
60 |
|
---|
61 | script.addEventListener("load", callback);
|
---|
62 |
|
---|
63 | body.appendChild(script);
|
---|
64 | }
|
---|
65 |
|
---|
66 | var transcribeService;// = new TranscribeService();
|
---|
67 | loadScript("./interfaces/atea/js/asr/TranscribeService.js", () => {
|
---|
68 | transcribeService = new TranscribeService();
|
---|
69 | });
|
---|
70 |
|
---|
71 | // @ts-ignore
|
---|
72 | const AudioUploadComponent = Vue.createApp(
|
---|
73 | {
|
---|
74 | data()
|
---|
75 | {
|
---|
76 | return {
|
---|
77 | files: undefined,
|
---|
78 | canTranscribe: false,
|
---|
79 | isTranscribing: false
|
---|
80 | }
|
---|
81 | },
|
---|
82 | methods:
|
---|
83 | {
|
---|
84 | openFilePicker() {
|
---|
85 | document.getElementById("audioFileInput")?.click();
|
---|
86 | },
|
---|
87 | onFilesChanged()
|
---|
88 | {
|
---|
89 | /** @type {HTMLInputElement} */
|
---|
90 | // @ts-ignore | Object could be null
|
---|
91 | const audioFileInput = document.getElementById("audioFileInput");
|
---|
92 |
|
---|
93 | if (audioFileInput.files?.length != undefined && audioFileInput.files?.length > 0) {
|
---|
94 | this.canTranscribe = true;
|
---|
95 | this.files = audioFileInput?.files
|
---|
96 | }
|
---|
97 | else {
|
---|
98 | this.canTranscribe = false;
|
---|
99 | this.files = undefined;
|
---|
100 | }
|
---|
101 | },
|
---|
102 | async doTranscription() {
|
---|
103 | await getTranscriptions();
|
---|
104 | }
|
---|
105 | }
|
---|
106 | });
|
---|
107 | const AudioUploadVM = AudioUploadComponent.mount("#audioUploadContainer");
|
---|
108 |
|
---|
109 | /**
|
---|
110 | * When awaited, creates a delay for the given number of milliseconds.
|
---|
111 | *
|
---|
112 | * @param {Number} delayInms The number of milliseconds to delay for.
|
---|
113 | * @returns A promise that will resolve when the delay has finished.
|
---|
114 | */
|
---|
115 | function delay(delayInms)
|
---|
116 | {
|
---|
117 | return new Promise(
|
---|
118 | resolve =>
|
---|
119 | {
|
---|
120 | setTimeout(
|
---|
121 | () => {
|
---|
122 | resolve(2);
|
---|
123 | },
|
---|
124 | delayInms
|
---|
125 | );
|
---|
126 | }
|
---|
127 | );
|
---|
128 | }
|
---|
129 |
|
---|
130 | /**
|
---|
131 | * Gets the transcription of each submitted audio file.
|
---|
132 | */
|
---|
133 | async function getTranscriptions()
|
---|
134 | {
|
---|
135 | /** @type {FileList} */
|
---|
136 | const files = AudioUploadVM.files;
|
---|
137 |
|
---|
138 | AudioUploadVM.isTranscribing = true;
|
---|
139 | await delay(1000); // TODO: Remove - UI testing purposes only
|
---|
140 | // Cache the file list so that we can playback audio in the future
|
---|
141 | for (const file of files) {
|
---|
142 | cachedAudioFileList.set(file.name, file)
|
---|
143 | }
|
---|
144 |
|
---|
145 | // Transcribe each audio file in batches.
|
---|
146 | try
|
---|
147 | {
|
---|
148 | for await (const batch of transcribeService.batchTranscribeFiles(files))
|
---|
149 | {
|
---|
150 | for (const t of batch)
|
---|
151 | {
|
---|
152 | if (!t.success) {
|
---|
153 | insertError(t.file_name, t.log);
|
---|
154 | }
|
---|
155 | else {
|
---|
156 | insertTranscription(t);
|
---|
157 | }
|
---|
158 | }
|
---|
159 | }
|
---|
160 | }
|
---|
161 | catch (e)
|
---|
162 | {
|
---|
163 | console.error("Failed to transcribe files");
|
---|
164 | console.error(e);
|
---|
165 | insertError("all", e.statusMessage);
|
---|
166 | }
|
---|
167 |
|
---|
168 | AudioUploadVM.isTranscribing = false;
|
---|
169 | }
|
---|
170 |
|
---|
171 | /**
|
---|
172 | * Removes any transcriptions from the UI list.
|
---|
173 | */
|
---|
174 | function clearTranscriptions()
|
---|
175 | {
|
---|
176 | cachedAudioFileList = new Map();
|
---|
177 | while (TRANSCRIPTIONS_LIST.lastChild) {
|
---|
178 | TRANSCRIPTIONS_LIST.removeChild(TRANSCRIPTIONS_LIST.lastChild);
|
---|
179 | }
|
---|
180 | }
|
---|
181 |
|
---|
182 | /**
|
---|
183 | * Inserts a transcription object into the DOM.
|
---|
184 | * Adapted from https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template
|
---|
185 | *
|
---|
186 | * @param {TranscriptionModel} transcription The transcription to insert.
|
---|
187 | */
|
---|
188 | function insertTranscription(transcription)
|
---|
189 | {
|
---|
190 | // Create a new transcription row
|
---|
191 | /** @type {HTMLLIElement} */
|
---|
192 | // @ts-ignore
|
---|
193 | const clone = TRANSCRIPTION_TEMPLATE.content.firstElementChild.cloneNode(true);
|
---|
194 |
|
---|
195 | // @ts-ignore
|
---|
196 | clone.querySelector(".transcription__text").textContent = transcription.transcription;
|
---|
197 | // @ts-ignore
|
---|
198 | clone.querySelector(".transcription__file-name").textContent = transcription.file_name;
|
---|
199 |
|
---|
200 | // Hook up the remove button
|
---|
201 | /** @type {HTMLButtonElement} */
|
---|
202 | // @ts-ignore
|
---|
203 | const removeButton = clone.querySelector(".transcription__remove-button");
|
---|
204 | removeButton.addEventListener("click", onDeleteTranscription);
|
---|
205 |
|
---|
206 | loadAudioFile(transcription.file_name);
|
---|
207 |
|
---|
208 | // Prepare the audio slider
|
---|
209 | /** @type {HTMLInputElement} */
|
---|
210 | // @ts-ignore
|
---|
211 | const audioSlider = clone.querySelector(".audio-slider");
|
---|
212 | audioSlider.max = TRANSCRIPTION_AUDIO_ELEMENT.duration.toString();
|
---|
213 | audioSlider.step = "0.01";
|
---|
214 |
|
---|
215 | // Set the filename data property on every element in the clone's DOM node
|
---|
216 | recurseAddData(clone, "file-name", transcription.file_name);
|
---|
217 |
|
---|
218 | // Insert the entry. This could occur first (if you need to make calculations based off of the rendered element)
|
---|
219 | // But it is preferable to do it last in order to avoid multiple render passes.
|
---|
220 | TRANSCRIPTIONS_LIST.appendChild(clone);
|
---|
221 |
|
---|
222 | const TranscriptionWordListComponent = Vue.createApp(
|
---|
223 | {
|
---|
224 | data()
|
---|
225 | {
|
---|
226 | return {
|
---|
227 | lines: []
|
---|
228 | }
|
---|
229 | }
|
---|
230 | });
|
---|
231 | const TranscriptionWordListVM = TranscriptionWordListComponent.mount("#transcriptionWordList");
|
---|
232 |
|
---|
233 | transcription.transcription.replace('', "\u00A0"); // This helps with formatting, as whitespace is trimmed.
|
---|
234 |
|
---|
235 | // Get the amount of space that we have to align words within
|
---|
236 | /** @type {HTMLDivElement} */
|
---|
237 | // @ts-ignore
|
---|
238 | const transcriptionTextList = clone.querySelector(".transcription__word-list");
|
---|
239 | const charsPerLine = Math.floor(transcriptionTextList.clientWidth / MONOSPACE_CHAR_SIZE);
|
---|
240 |
|
---|
241 | // Insert words with correct line alignment
|
---|
242 | const tText = transcription.transcription;
|
---|
243 |
|
---|
244 | for (let i = 0; i < tText.length; i += 0)
|
---|
245 | {
|
---|
246 | /** @type {String} */
|
---|
247 | let slice;
|
---|
248 |
|
---|
249 | if (i + charsPerLine < tText.length)
|
---|
250 | {
|
---|
251 | slice = tText.slice(i, charsPerLine);
|
---|
252 |
|
---|
253 | if (tText.charAt(i) != ' ' && tText.charAt(i + 1) != ' ') {
|
---|
254 | let lastSpacePos = slice.lastIndexOf(' ');
|
---|
255 | let decrement = slice.length - lastSpacePos;
|
---|
256 | slice = slice.slice(0, lastSpacePos);
|
---|
257 | i -= decrement;
|
---|
258 | }
|
---|
259 | }
|
---|
260 | else
|
---|
261 | {
|
---|
262 | slice = tText.slice(i);
|
---|
263 | }
|
---|
264 |
|
---|
265 | TranscriptionWordListVM.lines.push({ words: slice.split(' ') });
|
---|
266 | i += charsPerLine;
|
---|
267 | }
|
---|
268 | }
|
---|
269 |
|
---|
270 | /**
|
---|
271 | * Loads an audio file.
|
---|
272 | * @param {String} requestedAudioFile The name of the requested audio file.
|
---|
273 | */
|
---|
274 | function loadAudioFile(requestedAudioFile)
|
---|
275 | {
|
---|
276 | const currentAudioFile = TRANSCRIPTION_AUDIO_SOURCE_ELEMENT.dataset.fileName;
|
---|
277 |
|
---|
278 | // Load the appropiate audio if necessary.
|
---|
279 | if (currentAudioFile != requestedAudioFile)
|
---|
280 | {
|
---|
281 | // If an audio file is already loaded we can revoke it.
|
---|
282 | if (currentAudioFile) {
|
---|
283 | URL.revokeObjectURL(currentAudioFile);
|
---|
284 | }
|
---|
285 |
|
---|
286 | const urlObject = URL.createObjectURL(cachedAudioFileList.get(requestedAudioFile));
|
---|
287 | TRANSCRIPTION_AUDIO_SOURCE_ELEMENT.src = urlObject;
|
---|
288 | TRANSCRIPTION_AUDIO_SOURCE_ELEMENT.dataset.fileName = requestedAudioFile;
|
---|
289 | TRANSCRIPTION_AUDIO_ELEMENT.load();
|
---|
290 | }
|
---|
291 | }
|
---|
292 |
|
---|
293 | /**
|
---|
294 | * Removes a transcription from the DOM.
|
---|
295 | *
|
---|
296 | * @param {MouseEvent} ev The mouse click event.
|
---|
297 | */
|
---|
298 | function onDeleteTranscription(ev)
|
---|
299 | {
|
---|
300 | if (ev == null || ev.target == null) {
|
---|
301 | return;
|
---|
302 | }
|
---|
303 |
|
---|
304 | const fileName = ev.target.dataset.fileName;
|
---|
305 | const child = document.querySelector(`.transcription__container[data-file-name='${fileName}']`);
|
---|
306 |
|
---|
307 | TRANSCRIPTIONS_LIST.removeChild(child);
|
---|
308 | cachedAudioFileList.delete(fileName);
|
---|
309 | }
|
---|
310 |
|
---|
311 | /**
|
---|
312 | * Recurses through the entire tree of a parent and adds the given data attribute.
|
---|
313 | *
|
---|
314 | * @param {Element} parent The parent node.
|
---|
315 | * @param {String} dataElementName The name of the data element. Must use hyphen notation, rather than camel case.
|
---|
316 | * @param {String} value The value of the data element.
|
---|
317 | */
|
---|
318 | function recurseAddData(parent, dataElementName, value)
|
---|
319 | {
|
---|
320 | parent.setAttribute("data-" + dataElementName, value);
|
---|
321 |
|
---|
322 | for (const child of parent.children) {
|
---|
323 | recurseAddData(child, dataElementName, value);
|
---|
324 | }
|
---|
325 | }
|
---|
326 |
|
---|
327 | /**
|
---|
328 | * Inserts a transcription error into the DOM.
|
---|
329 | *
|
---|
330 | * @param {String} fileName The file for which an error occured.
|
---|
331 | * @param {String | null} statusMessage An informative error message to display.
|
---|
332 | */
|
---|
333 | function insertError(fileName, statusMessage)
|
---|
334 | {
|
---|
335 | /** @type {HTMLLIElement} */
|
---|
336 | // @ts-ignore
|
---|
337 | const clone = ERROR_TEMPLATE.content.firstElementChild.cloneNode(true);
|
---|
338 |
|
---|
339 | /** @type {NodeListOf<HTMLSpanElement>} */
|
---|
340 | const spans = clone.querySelectorAll("span");
|
---|
341 | spans[0].textContent = statusMessage;
|
---|
342 | spans[1].textContent = fileName;
|
---|
343 |
|
---|
344 | TRANSCRIPTIONS_LIST.appendChild(clone);
|
---|
345 | }
|
---|