Index: main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/DirectInput.vue
===================================================================
--- main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/DirectInput.vue	(revision 35716)
+++ main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/DirectInput.vue	(revision 35716)
@@ -0,0 +1,68 @@
+<template>
+<div class="root">
+    <textarea class="text-input input-area" @input="onTextInput"
+        v-model="input" :placeholder="translations.get('DirectInput_InputPlaceholder')" />
+    <div class="text-container">
+        {{ output }}
+    </div>
+</div>
+</template>
+
+<style scoped lang="scss">
+.root {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 1em;
+}
+
+.input-area {
+    resize: none;
+    min-height: 4em;
+    overflow: hidden;
+}
+</style>
+
+<script>
+import { mapState } from "vuex";
+import MacronRestorationModule from "../js/MacronRestorationModule"
+
+const macroniser = new MacronRestorationModule();
+
+export default {
+    name: "DirectInput",
+    data() {
+        return {
+            input: "",
+            output: "",
+            transcribeTimeout: null
+        }
+    },
+    computed: {
+        ...mapState({
+            translations: state => state.translations
+        })
+    },
+    watch: {
+        input() {
+            if (this.transcribeTimeout !== null) {
+                clearTimeout(this.transcribeTimeout);
+            }
+
+            this.transcribeTimeout = setTimeout(this.updateMacronisedText, 1000);
+        }
+    },
+    methods: {
+        /**
+         * Handles input on an element, and grows its height so that the text is always visible.
+         * @param {InputEvent} event The input event.
+         */
+        onTextInput(event) {
+            event.target.height = event.target.scrollHeight;
+        },
+        async updateMacronisedText() {
+            const response = await macroniser.directMacronisation(this.input);
+            this.output = response.fragment2;
+        }
+    }
+}
+</script>
Index: main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/FileInput.vue
===================================================================
--- main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/FileInput.vue	(revision 35716)
+++ main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/FileInput.vue	(revision 35716)
@@ -0,0 +1,209 @@
+<template>
+    <div class="card">
+        <!-- Header containing info and actions for the transcription -->
+        <div class="transcription__header">
+            <button class="btn-fab" v-on:click="toggleAudio" type="button" :title="translations.get('TranscriptionItem_PlayButtonTooltip')">
+                <span class="material-icons mdi-l play-button" v-if="!isPlaying">play_arrow</span>
+                <span class="material-icons mdi-l play-button" v-if="isPlaying">pause</span>
+            </button>
+
+            <span>{{ translations.get("TranscriptionItem_FileName") }}: {{ transcription.fileName }}</span>
+
+            <div style="position: relative;">
+                <button class="btn-primary" @mouseover="showDownloadOptions = true" @mouseout="showDownloadOptions = false" type="button">
+                    <span class="material-icons">download</span>
+                    <span>{{ translations.get("TranscriptionItem_Download") }}</span>
+                </button>
+
+                <div class="download-popup card" :class="{ 'download-popup-show': showDownloadOptions }"
+                     @mouseover="showDownloadOptions = true" @mouseout="showDownloadOptions = false">
+                    <button @click="downloadAsText" type="button" class="btn-primary theme-flat">
+                        <span class="material-icons">text_snippet</span>
+                        <span>{{ translations.get("TranscriptionItem_DownloadAsText") }}</span>
+                    </button>
+
+                    <button @click="downloadAsJson" type="button" class="btn-primary theme-flat">
+                        <span class="material-icons">integration_instructions</span>
+                        <span>{{ translations.get("TranscriptionItem_DownloadAsJson") }}</span>
+                    </button>
+
+                    <button @click="downloadAsWebvtt" type="button" class="btn-primary theme-flat">
+                        <span class="material-icons">subtitles</span>
+                        <span>{{ translations.get("TranscriptionItem_DownloadAsWebvtt") }}</span>
+                    </button>
+                </div>
+            </div>
+
+            <button class="btn-primary theme-error" @click="remove" type="button">
+                <span class="material-icons">delete</span>
+                <span>{{ translations.get("TranscriptionItem_Remove") }}</span>
+            </button>
+        </div>
+
+        <div class="editor-controls">
+            <audio-time-bar v-model.number="currentPlaybackTime" :audio-length="audioLength" :isDisabled="playbackState.id != transcription.id" />
+        </div>
+
+        <hr />
+
+        <div class="editor-controls">
+            <TranscriptionItemEditor ref="editor" :transcription="transcription" style="margin-bottom: 1em" :enableEditing="enableEditing" />
+            <toggle-button v-model="enableEditing" :title="translations.get('TranscriptionItemEditor_ToggleEditTooltip')">
+                <span class="material-icons">edit</span>
+            </toggle-button>
+        </div>
+    </div>
+</template>
+
+<style scoped lang="scss">
+.transcription__header {
+    display: grid;
+    gap: 0.5em 0.5em;
+    grid-template-columns: auto 1fr auto auto;
+    align-items: center;
+}
+
+.download-popup {
+    display: flex;
+    flex-direction: column;
+    align-items: stretch;
+
+    position: absolute;
+    top: 97%;
+    z-index: 2;
+
+    padding: 2px;
+    margin: 0;
+    width: 14em;
+
+    transition-duration: var(--transition-duration);
+    visibility: hidden;
+    opacity: 0;
+}
+
+.download-popup-show {
+    visibility: visible;
+    opacity: 1;
+}
+
+.editor-controls {
+    display: grid;
+    align-items: flex-start;
+    grid-template-columns: 1fr auto;
+    margin: 0.5em 0 0.3em 0;
+    gap: 1em;
+}
+
+.rotate-180 {
+    transform: rotate(180deg);
+}
+</style>
+
+<script>
+import { mapState } from "vuex";
+import { saveAs } from "file-saver"
+import { TranscriptionViewModel } from "../main";
+import AudioPlayback from "../js/AudioPlaybackModule"
+import Util from "../js/Util"
+import AudioTimeBar from "./AudioTimeBar.vue"
+import TranscriptionItemEditor from "./TranscriptionItemEditor.vue"
+
+export default {
+    name: "TranscriptionItem",
+    components: {
+        AudioTimeBar,
+        TranscriptionItemEditor
+    },
+    props: {
+        transcription: TranscriptionViewModel
+    },
+    data() {
+        return {
+            enableEditing: false,
+            showDownloadOptions: false
+        }
+    },
+    computed: {
+        currentPlaybackTime: {
+            get() {
+                return this.$store.getters.transcriptionPlaybackTime(this.transcription.id);
+            },
+            set(value) {
+                this.$store.commit("playbackStateSetTime", { id: this.transcription.id, time: value });
+            }
+        },
+        audioLength() {
+            return this.$store.getters.transcriptionPlaybackLength(this.transcription.id);
+        },
+        isPlaying() {
+            return this.playbackState.isPlaying && this.playbackState.id === this.transcription.id;
+        },
+        ...mapState({
+            translations: state => state.translations,
+            playbackState: state => state.playbackState
+        })
+    },
+    methods: {
+        async toggleAudio() {
+            this.isPlaying ? AudioPlayback.pause() : await AudioPlayback.play(this.transcription.id, -1);
+        },
+        remove() {
+            this.$store.commit("rawTranscriptionRemove", this.transcription.id);
+        },
+        downloadAsText() {
+            const fileName = buildDownloadableFileName(this.transcription.fileName, "txt");
+
+            const blob = new Blob([ this.$refs.editor.words.map(w => w.word).join(" ") ], { type: "text/plain;charset=utf-8" });
+            saveAs(blob, fileName);
+        },
+        downloadAsJson() {
+            const fileName = buildDownloadableFileName(this.transcription.fileName, "json");
+            const toDownload = (({ fileName, transcription }) => ({ fileName, transcription }))(this.transcription);
+            toDownload.words = this.$refs.editor.words.map(w => (({ word, startTime, endTime }) => ({ word, startTime, endTime }))(w));
+
+            const blob = new Blob([ JSON.stringify(toDownload, null, 4) ], { type: "application/json;charset=utf-8" });
+            saveAs(blob, fileName);
+        },
+        downloadAsWebvtt() {
+            const fileName = buildDownloadableFileName(this.transcription.fileName, "vtt");
+            const toDownload = buildWebvttFileContents(this.transcription, this.$refs.editor);
+
+            const blob = new Blob([ toDownload ], { type: "text/vtt;charset=utf-8" });
+            saveAs(blob, fileName);
+        }
+    }
+}
+
+/**
+ * Builds a file name for a download.
+ * @param {String} transcriptionFileName The name of the transcription that will be downloaded.
+ * @param {String} extension The file extension of the download. Do not include a period.
+ * @returns {String} The file name.
+ */
+function buildDownloadableFileName(transcriptionFileName, extension) {
+    const extensionIndex = transcriptionFileName.lastIndexOf(".");
+    let fileName = transcriptionFileName.slice(0, extensionIndex);
+    fileName += "_transcription." + extension;
+
+    return fileName;
+}
+
+/**
+ * Builds a WebVTT file of the given transcription
+ * @param {TranscriptionViewModel} transcription The transcription.
+ * @returns {String} The WebVTT content.
+ */
+function buildWebvttFileContents(transcription, editor) {
+    let contents = "WEBVTT Transcription of " + transcription.fileName + "\n\n";
+
+    for (const word of editor.words) {
+        const startTime = Util.formatSecondsTimeString(word.startTime, true);
+        const endTime = Util.formatSecondsTimeString(word.endTime, true);
+
+        contents += startTime + " --> " + endTime + "\n";
+        contents += "- " + word.word + "\n\n";
+    }
+
+    return contents;
+}
+</script>
Index: main/trunk/model-interfaces-dev/atea/macron-restoration/src/js/MacronRestorationModule.js
===================================================================
--- main/trunk/model-interfaces-dev/atea/macron-restoration/src/js/MacronRestorationModule.js	(revision 35716)
+++ main/trunk/model-interfaces-dev/atea/macron-restoration/src/js/MacronRestorationModule.js	(revision 35716)
@@ -0,0 +1,177 @@
+/**
+ * @file Defines components that are used to interact with the transcription proxy servlet.
+ * @author Carl Stephens
+ * @module
+ */
+
+import { log } from "./Util"
+
+export class MacronRestorationError extends Error {
+    /**
+     * Initialises a new instance of the {@link MacronRestorationError} object.
+     *
+     * @param {Number | undefined} statusCode The HTTP status code of the error.
+     * @param {String | undefined} message The status message.
+     * @param {String | null} fileName The file on which the transcription failed.
+     */
+    constructor(message = undefined, fileName = null, statusCode = -1) {
+        super(message);
+
+        /** @type {String | null} The name of the file that the transcription error occured on. */
+        this.fileName = fileName;
+
+        /** @type {Number | undefined} The status code returned by the API when the erro was generated. */
+        this.statusCode = statusCode;
+    }
+}
+
+/**
+ * A service that performs requests to the macron restoration API.
+ */
+export default class MacronRestorationService {
+    constructor() {
+        /** @type {String} The URL to which query POST requests should be made. */
+        if (process.env.NODE_ENV !== "production") {
+            this.queryUrl = "//localhost:8383/macron-restoration/jsp/servlet/DirectInput";
+        }
+        else {
+            this.queryUrl = "/macron-restoration/jsp/servlet/DirectInput";
+        }
+    }
+
+    /**
+     * Performs a request to the macron restoration API to restore the given input text.
+     * @param {String} input The input text to macronise
+     * @returns The JSON response from the macron restoration API.
+     */
+    async directMacronisation(input) {
+        const that = this;
+
+        try {
+            const response = await fetch(
+                that.queryUrl,
+                {
+                    method: "POST",
+                    headers: {
+                        "Content-Type": "application/x-www-form-urlencoded"
+                    },
+                    body: `o=json&fragment=${input}`
+                }
+            );
+
+            if (!response.ok) {
+                log(`Macron Restoration API failed with status ${response.status} and message ${response.statusText}`, "error")
+                throw new MacronRestorationError(response.statusText, undefined, response.status);
+            }
+
+            return await response.json();
+        }
+        catch (e) {
+            log(`Macron restoration failed with reason ${e}`, "error");
+            throw new MacronRestorationError(undefined, "Unknown");
+        }
+    }
+
+    /**
+     * Performs chunked queries to transcribe the given audio files, returning the data in iterations.
+     * Data is chunked according to which ever occurs first:
+     * A maximum of three files per request, or;
+     * A maximum of 5 MiB before chunking.
+     *
+     * @param {FileList | File[]} files The files to upload
+     * @returns {AsyncGenerator<TranscriptionModel[]>} The transcribed audio files.
+     * @throws {TranscriptionError} When the transcription request fails to complete.
+     */
+    async* batchFileMacronisation(files) {
+        let filesToSubmit = [];
+        let fileCounter = 0;
+        let byteCounter = 0;
+
+        for (const file of files) {
+            if (fileCounter === this.MAX_BATCH_COUNT || byteCounter > this.BATCH_BYTE_LIMIT) { // 5 MiB
+                yield await this.fileMacronisation(filesToSubmit);
+                filesToSubmit = [];
+                byteCounter = 0;
+                fileCounter = 0;
+            }
+
+            filesToSubmit[fileCounter++] = file;
+            byteCounter += file.size;
+        }
+
+        if (filesToSubmit.length > 0) {
+            yield await this.fileMacronisation(filesToSubmit);
+        }
+    }
+
+    /**
+     * Performs a query to transcribe the given audio files.
+     *
+     * @param {File[]} files The files to upload.
+     * @returns {Promise<TranscriptionModel[]>} The transcribed audio file.
+     * @throws {TranscriptionError} When the transcription request fails to complete.
+     */
+    async fileMacronisation(files) {
+        if (process.env.NODE_ENV !== "production" || files.some(file => file.name === "akl_mi_pk_0002_offline.wav")) {
+            return await this.fileMacronisationTest(files);
+        }
+        else {
+            return await this.fileMacronisationActual(files);
+        }
+    }
+
+    async fileMacronisationTest(files) {
+        const objects = [];
+
+        for (const file of files) {
+            const object = JSON.parse(this.TEST_JSON);
+            object.file_name = file.name;
+            objects.push(object);
+        }
+
+        return objects;
+    }
+
+    /**
+     * Performs a query to transcribe the given audio files.
+     *
+     * @param {FileList | File[]} files The files to upload.
+     * @returns {Promise<TranscriptionModel[]>} The transcribed audio file.
+     * @throws {TranscriptionError} When the transcription request fails to complete.
+     */
+    async fileMacronisationActual(files) {
+        const that = this;
+        const formData = new FormData();
+
+        let audioFileKeys = "";
+        for (let i = 0; i < files.length; i++) {
+            const f = files[i];
+            const key = "audioFile" + i;
+
+            formData.append(key, f, f.name);
+            audioFileKeys += key + "|";
+        }
+        formData.append("audioFileKeys", audioFileKeys);
+
+        try {
+            const response = await fetch(
+                that.queryUrl,
+                {
+                    method: "POST",
+                    body: formData
+                }
+            );
+
+            if (!response.ok) {
+                log(`Transcription API failed with status ${response.status} and message ${response.statusText}`, "error")
+                throw new MacronRestorationError(response.statusText, undefined, response.status);
+            }
+
+            return await response.json();
+        }
+        catch (e) {
+            log(`Transcription failed with reason ${e}`, "error");
+            throw new MacronRestorationError(undefined, "Unknown");
+        }
+    }
+}
