Spaces:
Paused
Paused
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>TranStudio</title> | |
| <link rel="stylesheet" href="/static/css/buefy.min.css"> | |
| <link rel="stylesheet" href="/static/css/materialdesignicons.min.css"> | |
| <script> | |
| window.process = { env: { NODE_ENV: 'production' } }; | |
| </script> | |
| <script src="/static/js/vue.global.prod.js"></script> | |
| <script src="/static/js/buefy.min.js"></script> | |
| <style> | |
| .segments-container { | |
| max-height: 300px; | |
| overflow-y: auto; | |
| border: 1px solid #ddd; | |
| padding: 10px; | |
| border-radius: 4px; | |
| } | |
| .segment-box { | |
| padding: 10px; | |
| margin: 5px 0; | |
| border: 1px solid #eee; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .segment-box:hover { | |
| background-color: #f5f5f5; | |
| } | |
| .segment-box.is-active { | |
| border-color: #3273dc; | |
| background-color: #e8f0fe; | |
| } | |
| .segment-time { | |
| font-size: 0.8em; | |
| color: #666; | |
| margin-bottom: 5px; | |
| } | |
| .segment-text { | |
| margin: 5px 0; | |
| } | |
| .segment-actions { | |
| text-align: right; | |
| } | |
| .audio-player { | |
| position: fixed; | |
| bottom: 0; | |
| left: 0; | |
| right: 0; | |
| background: white; | |
| padding: 1rem; | |
| box-shadow: 0 -2px 5px rgba(0,0,0,0.1); | |
| z-index: 100; | |
| margin-top: 2rem; | |
| } | |
| .waveform-container { | |
| margin: 1rem 0; | |
| padding: 0 1rem; | |
| } | |
| .waveform { | |
| position: relative; | |
| height: 10vh; | |
| background: #f0f0f0; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| } | |
| .progress { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| height: 100%; | |
| background: rgba(50, 115, 220, 0.3); | |
| pointer-events: none; | |
| } | |
| .player-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| margin-bottom: 0.5rem; | |
| } | |
| .segment-marker { | |
| position: absolute; | |
| height: 100%; | |
| background: rgba(50, 115, 220, 0.2); | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| border-left: 1px solid #3273dc; | |
| border-right: 1px solid #3273dc; | |
| } | |
| .segment-marker:hover { | |
| background: rgba(50, 115, 220, 0.4); | |
| } | |
| .segment-tooltip { | |
| position: absolute; | |
| bottom: 100%; | |
| background: #333; | |
| color: white; | |
| padding: 0.25rem 0.5rem; | |
| border-radius: 3px; | |
| font-size: 0.8rem; | |
| white-space: nowrap; | |
| display: none; | |
| } | |
| .segment-marker:hover .segment-tooltip { | |
| display: block; | |
| } | |
| .current-time { | |
| text-align: center; | |
| font-size: 0.9rem; | |
| color: #666; | |
| margin-top: 0.5rem; | |
| } | |
| .control-btn { | |
| /* padding: 0.5rem; */ | |
| transition: all 0.2s; | |
| } | |
| .control-btn:hover { | |
| transform: scale(1.1); | |
| } | |
| .selection-info { | |
| margin: 10px 0; | |
| display: flex; | |
| align-items: center; | |
| } | |
| .slider-tick { | |
| width: 2px; | |
| height: 12px; | |
| background-color: currentColor; | |
| border-radius: 1px; | |
| } | |
| /* Customize the range slider track */ | |
| .b-slider.is-info .b-slider-track { | |
| background: rgba(50, 115, 220, 0.3); | |
| } | |
| /* Style for the selected range */ | |
| .b-slider.is-info .b-slider-fill { | |
| background: #3273dc; | |
| } | |
| .navbar-end { | |
| margin-top: 10vh; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app" class="container"> | |
| <b-navbar> | |
| <template #start> | |
| <b-navbar-item @click="toggleSidebar"> | |
| <b-icon :icon="showSidebar ? 'menu-open' : 'menu'"></b-icon> | |
| </b-navbar-item> | |
| <b-navbar-item tag="router-link" :to="{ path: '/' }"> | |
| <a href="/">TranStudio</a> | |
| </b-navbar-item> | |
| </template> | |
| <template #end> | |
| <!-- <b-navbar-dropdown :label="isAuthenticated ? : 'Account'"> --> | |
| <template v-if="isAuthenticated"> | |
| <b-navbar-item v-if="isAdmin" @click="showAdminPanel = true"> | |
| <b-icon icon="shield-account"></b-icon> | |
| <span class="ml-1">Admin</span> | |
| </b-navbar-item> | |
| <b-navbar-item @click="logout">Logout {{ username }}</b-navbar-item> | |
| </template> | |
| <template v-else> | |
| <b-navbar-item @click="showLoginModal = true">Login</b-navbar-item> | |
| <b-navbar-item @click="showSignupModal = true">Sign Up</b-navbar-item> | |
| </template> | |
| <!-- </b-navbar-dropdown> --> | |
| </template> | |
| </b-navbar> | |
| <div class="columns mb-6 pb-6"> | |
| <!-- Sidebar --> | |
| <div class="column is-one-quarter" v-if="showSidebar"> | |
| <b-menu> | |
| <b-menu-list :label="`Previous Transcriptions (${transcriptions.length})`"> | |
| <b-menu-item v-for="transcription in transcriptions" | |
| class="is-flex is-justify-content-space-between is-align-items-center" | |
| :key="transcription.id" | |
| @click="loadTranscription(transcription)" :label="transcription.name"> | |
| <div class="is-flex is-justify-content-space-between is-align-items-center" style="width: 100%"> | |
| <span> | |
| {{transcription?.audio_file}} | |
| </span> | |
| <b-button | |
| type="is-danger" | |
| size="is-small" | |
| icon-left="delete" | |
| @click.stop="deleteTranscription(transcription.id)"> | |
| </b-button> | |
| </div> | |
| </b-menu-item> | |
| </b-menu-list> | |
| </b-menu> | |
| </div> | |
| <!-- Main Content --> | |
| <div class="column"> | |
| <!-- <b-field label="Select AI Model"> | |
| <b-select v-model="selectedModel"> | |
| <option value="whisper-large-v3-turbo">Groq - Whisper Large v3 Turbo</option> | |
| <option value="openai-whisper-large-v3-turbo">Huggingface - OpenAI Whisper Large v3 Turbo</option> | |
| </b-select> | |
| </b-field> --> | |
| <b-field label="Format"> | |
| <b-select v-model="responseFormat"> | |
| <option value="verbose_json">Verbose (with timestamps)</option> | |
| <option value="json">Standard</option> | |
| <option value="text">Simple</option> | |
| </b-select> | |
| </b-field> | |
| <b-field label="Language"> | |
| <b-select v-model="selectedLanguage"> | |
| <option value="auto">Auto Detect</option> | |
| <option value="en">English</option> | |
| <option value="es">Spanish</option> | |
| <option value="ar">Arabic</option> | |
| <option value="fr">French</option> | |
| <option value="de">German</option> | |
| <option value="it">Italian</option> | |
| </b-select> | |
| </b-field> | |
| <b-field label="Temperature"> | |
| <b-slider v-model="temperature" :min="0" :max="1" :step="0.1"></b-slider> | |
| <span class="tag is-primary">{{ temperature }}</span> | |
| </b-field> | |
| <b-field label="Chunk Size (in minutes)"> | |
| <b-slider v-model="chunkSize" :min="5" :max="30" :step="1"></b-slider> | |
| <span class="tag is-primary">{{ chunkSize }}</span> | |
| </b-field> | |
| <b-field label="Overlap (in seconds)"> | |
| <b-slider v-model="overlap" :min="2" :max="60" :step="1"></b-slider> | |
| <span class="tag is-primary">{{ overlap }}</span> | |
| </b-field> | |
| <b-field label="Selection" v-if="totalDuration"> | |
| <b-slider v-model="selection" :step="1" type="is-info" :tooltip="false"> | |
| <!-- <template v-for="n in totalDuration" :key="n"> | |
| <b-slider-tick :value="n">{{ n }}</b-slider-tick> | |
| </template> --> | |
| </b-slider> | |
| </b-field> | |
| <div class="selection-info" v-if="totalDuration"> | |
| <span class="tag is-info"> | |
| Start: {{ formatTime(selection[0] * totalDuration / 100) }} | |
| </span> | |
| <span class="tag is-info ml-2"> | |
| End: {{ formatTime(selection[1] * totalDuration / 100) }} | |
| </span> | |
| </div> | |
| <b-field label="System Prompt (Optional)"> | |
| <b-input v-model="systemPrompt" maxlength="256" type="textarea"></b-input> | |
| </b-field> | |
| <b-upload v-model="audioFile" accept="audio/*" @change="handleAudioUpload" multiple> | |
| <a class="button is-primary"> | |
| <b-icon icon="upload"></b-icon> | |
| <span>Click to upload</span> | |
| </a> | |
| </b-upload> | |
| <b-field v-if="audioFile.length"> | |
| <span class="file-name"> | |
| {{ audioFile[0].name }} | |
| </span> | |
| </b-field> | |
| </b-field> | |
| <!-- <div v-if="audioUrl" class="audio-controls mb-4"> | |
| <audio ref="audioPlayer" :src="audioUrl"></audio> | |
| <b-button @click="togglePlayPause"> | |
| {{ isPlaying ? 'Pause' : 'Play' }} | |
| </b-button> | |
| </div> --> | |
| <b-field class="mt-2" label="Transcription"> | |
| <b-input v-model="transcriptionText" type="textarea" rows="5"></b-input> | |
| </b-field> | |
| <b-field class="mt-2" label="Segments" v-if="segments.length && responseFormat === 'verbose_json'"> | |
| <div class="segments-container"> | |
| <div v-for="(segment, index) in segments" :key="segment.id" | |
| class="segment-box" | |
| :class="{'is-active': activeSegment === index}" | |
| @click="playSegment(segment)"> | |
| <div class="segment-time"> | |
| {{ formatTime(segment.start) }} - {{ formatTime(segment.end) }} | |
| </div> | |
| <div class="segment-text"> | |
| <b-input v-model="segment.text" type="textarea" rows="2"></b-input> | |
| </div> | |
| <div class="segment-actions"> | |
| <b-button type="is-info" size="is-small" @click.stop="copySegmentText(segment.text)"> | |
| <b-icon icon="content-copy"></b-icon> | |
| </b-button> | |
| <b-button type="is-danger" size="is-small" @click.stop="deleteSegment(index)"> | |
| <b-icon icon="delete"></b-icon> | |
| </b-button> | |
| </div> | |
| </div> | |
| </div> | |
| </b-field> | |
| <b-button type="is-primary" class="mr-2" @click="processTranscription" :disabled="!audioUrl" :loading="isProcessing">Process</b-button> | |
| <b-button type="is-success" class="mr-2" @click="saveTranscription" :disabled="!transcriptionText" :loading="isProcessing">Save</b-button> | |
| <b-button class="control-btn" @click="togglePlayer" :disabled="!audioUrl"> | |
| <b-icon :icon="showPlayer ? 'menu-down' : 'menu-up'"></b-icon> | |
| </b-button> | |
| </div> | |
| </div><!-- .columns --> | |
| <div class="columns mt-2"> | |
| <div class="audio-player" v-if="showPlayer"> | |
| <div class="player-controls" v-if="audioUrl"> | |
| <b-button class="control-btn" @click="togglePlay"> | |
| <b-icon :icon="isPlaying ? 'pause' : 'play'"></b-icon> | |
| </b-button> | |
| <b-button class="control-btn" @click="stopAudio"> | |
| <b-icon icon="stop"></b-icon> | |
| </b-button> | |
| <b-slider v-model="volume" :min="0" :max="1" :step="0.1" @input="updateVolume" size="is-small"> | |
| <template #indicator> | |
| <span class="tag is-primary is-small">{{ volume.toFixed(1) }}</span> | |
| </template> | |
| </b-slider> | |
| <b-button class="control-btn" @click="toggleMute"> | |
| <b-icon :icon="isMuted ? 'volume-off' : 'volume-high'"></b-icon> | |
| </b-button> | |
| <b-button class="control-btn" @click="togglePlayer"> | |
| <b-icon :icon="showPlayer ? 'menu-down' : 'menu-up'"></b-icon> | |
| </b-button> | |
| </div><!-- .player-controls --> | |
| <div class="waveform-container" v-if="segments.length"> | |
| <div class="waveform" ref="waveform"> | |
| <div v-for="(segment, index) in segments" | |
| :key="segment.id" | |
| class="segment-marker" | |
| :style="{ left: `${(segment.start / totalDuration) * 100}%`, width: `${((segment.end - segment.start) / totalDuration) * 100}%` }" | |
| @click="playSegment(segment)"> | |
| <div class="segment-tooltip"> | |
| {{ formatTime(segment.start) }} - {{ formatTime(segment.end) }} | |
| </div> | |
| </div> | |
| <div class="progress" :style="{ width: `${(currentTime / totalDuration) * 100}%` }"></div> | |
| </div> | |
| </div><!-- .waveform-container --> | |
| <div class="current-time"> | |
| {{ formatTime(currentTime) }} / {{ formatTime(totalDuration) }} | |
| </div> | |
| </div><!-- .audio-player --> | |
| </div> | |
| <!-- Login Modal --> | |
| <b-modal v-model="showLoginModal" has-modal-card trap-focus> | |
| <div class="modal-card"> | |
| <header class="modal-card-head"> | |
| <p class="modal-card-title">Login</p> | |
| </header> | |
| <section class="modal-card-body"> | |
| <b-field label="Username"> | |
| <b-input type="text" v-model="loginForm.username" placeholder="Enter your username"></b-input> | |
| </b-field> | |
| <b-field label="Password"> | |
| <b-input type="password" v-model="loginForm.password" password-reveal></b-input> | |
| </b-field> | |
| </section> | |
| <footer class="modal-card-foot"> | |
| <b-button type="is-primary" @click="handleLogin" :loading="isLoading">Login</b-button> | |
| <b-button @click="showLoginModal = false">Cancel</b-button> | |
| </footer> | |
| </div> | |
| </b-modal> | |
| <!-- Signup Modal --> | |
| <b-modal v-model="showSignupModal" has-modal-card trap-focus> | |
| <div class="modal-card"> | |
| <header class="modal-card-head"> | |
| <p class="modal-card-title">Sign Up</p> | |
| </header> | |
| <section class="modal-card-body"> | |
| <b-field label="Username"> | |
| <b-input v-model="signupForm.username" placeholder="Enter username"></b-input> | |
| </b-field> | |
| <b-field label="Email"> | |
| <b-input type="email" v-model="signupForm.email" placeholder="Enter your email"></b-input> | |
| </b-field> | |
| <b-field label="Password"> | |
| <b-input type="password" v-model="signupForm.password" password-reveal></b-input> | |
| </b-field> | |
| <b-field label="Confirm Password"> | |
| <b-input type="password" v-model="signupForm.confirmPassword" password-reveal></b-input> | |
| </b-field> | |
| </section> | |
| <footer class="modal-card-foot"> | |
| <b-button type="is-primary" @click="handleSignup" :loading="isLoading">Sign Up</b-button> | |
| <b-button @click="showSignupModal = false">Cancel</b-button> | |
| </footer> | |
| </div> | |
| </b-modal> | |
| <!-- Admin Panel Modal --> | |
| <b-modal v-model="showAdminPanel" has-modal-card trap-focus :width="640"> | |
| <div class="modal-card"> | |
| <header class="modal-card-head"> | |
| <p class="modal-card-title">User Management</p> | |
| </header> | |
| <section class="modal-card-body"> | |
| <div class="block"> | |
| <b-field grouped> | |
| <b-input placeholder="Search users..." v-model="userSearchQuery" expanded></b-input> | |
| <b-button type="is-primary" icon-left="magnify">Search</b-button> | |
| </b-field> | |
| </div> | |
| <b-table | |
| :data="filteredUsers" | |
| :loading="loadingUsers" | |
| :paginated="true" | |
| :per-page="5" | |
| :mobile-cards="true"> | |
| <b-table-column field="id" label="ID" width="40" numeric v-slot="props"> | |
| {{ props.row.id }} | |
| </b-table-column> | |
| <b-table-column field="username" label="Username" v-slot="props"> | |
| {{ props.row.username }} | |
| </b-table-column> | |
| <b-table-column field="email" label="Email" v-slot="props"> | |
| {{ props.row.email }} | |
| </b-table-column> | |
| <b-table-column field="is_admin" label="Admin" v-slot="props"> | |
| <b-icon | |
| :icon="props.row.is_admin ? 'check' : 'close'" | |
| :type="props.row.is_admin ? 'is-success' : 'is-danger'"> | |
| </b-icon> | |
| </b-table-column> | |
| <b-table-column field="disabled" label="Status" v-slot="props"> | |
| <b-tag :type="props.row.disabled ? 'is-danger' : 'is-success'"> | |
| {{ props.row.disabled ? 'Disabled' : 'Active' }} | |
| </b-tag> | |
| </b-table-column> | |
| <b-table-column label="Actions" v-slot="props"> | |
| <div class="buttons"> | |
| <b-button | |
| size="is-small" | |
| :type="props.row.disabled ? 'is-success' : 'is-warning'" | |
| :icon-left="props.row.disabled ? 'account-check' : 'account-cancel'" | |
| @click="toggleUserStatus(props.row)" | |
| :disabled="props.row.is_admin && props.row.id === currentUser.id"> | |
| {{ props.row.disabled ? 'Enable' : 'Disable' }} | |
| </b-button> | |
| <b-button | |
| size="is-small" | |
| type="is-danger" | |
| icon-left="delete" | |
| @click="deleteUser(props.row)" | |
| :disabled="props.row.id === currentUser.id"> | |
| Delete | |
| </b-button> | |
| </div> | |
| </b-table-column> | |
| <template #empty> | |
| <div class="has-text-centered">No users found</div> | |
| </template> | |
| </b-table> | |
| </section> | |
| <footer class="modal-card-foot"> | |
| <b-button @click="refreshUsers" type="is-info" icon-left="refresh">Refresh</b-button> | |
| <b-button @click="showAdminPanel = false">Close</b-button> | |
| </footer> | |
| </div> | |
| </b-modal> | |
| </div><!-- #app --> | |
| <script src="/static/js/howler.min.js"></script> | |
| <script> | |
| const { createApp, ref, onMounted } = Vue; | |
| const app = createApp({ | |
| data() { | |
| return { | |
| responseFormat: 'verbose_json', | |
| temperature: 0, | |
| chunkSize: 10, | |
| overlap: 5, | |
| selection: [1,100], | |
| systemPrompt: '', | |
| isAuthenticated: false, | |
| username: '', | |
| token: '', | |
| isLoading: false, | |
| audioFile: [], | |
| segments: [], | |
| transcriptionText: '', | |
| selectedLanguage: 'auto', | |
| transcriptions: [], | |
| showSidebar: true, | |
| showPlayer: false, | |
| showApiKeyModal: false, | |
| audioUrl: null, | |
| isPlaying: false, | |
| audioPlayer: null, | |
| isProcessing: false, | |
| howl: null, | |
| activeSegment: null, | |
| isPlayingSegment: false, | |
| isMuted: false, | |
| volume: 0.5, | |
| currentTime: 0, | |
| totalDuration: 0, | |
| seekInterval: null, | |
| waveformData: null, | |
| waveformCanvas: null, | |
| ctx: null, | |
| showLoginModal: false, | |
| loginForm: { | |
| username: '', | |
| password: '', | |
| }, | |
| showSignupModal: false, | |
| signupForm: { | |
| username: '', | |
| email: '', | |
| password: '', | |
| confirmPassword: '' | |
| }, | |
| // Admin panel data | |
| showAdminPanel: false, | |
| isAdmin: false, | |
| currentUser: null, | |
| users: [], | |
| loadingUsers: false, | |
| userSearchQuery: '', | |
| // processingProgress: 0, | |
| // totalChunks: 0, | |
| // showVolumeSlider: false | |
| } | |
| }, | |
| methods: { | |
| async login() { | |
| this.isAuthenticated = true; | |
| }, | |
| logout() { | |
| this.token = ''; | |
| this.username = ''; | |
| this.isAuthenticated = false; | |
| this.isAdmin = false; | |
| this.currentUser = null; | |
| this.users = []; | |
| this.transcriptions = []; | |
| localStorage.removeItem('token'); | |
| // Reset UI state | |
| this.audioUrl = null; | |
| this.audioFile = []; | |
| this.segments = []; | |
| this.transcriptionText = ''; | |
| // Show notification | |
| this.$buefy.toast.open({ | |
| message: 'Successfully logged out!', | |
| type: 'is-success' | |
| }); | |
| // Redirect to home page if not already there | |
| if (window.location.pathname !== '/') { | |
| window.location.href = '/'; | |
| } else { | |
| // window.location.reload(); | |
| } | |
| }, | |
| async handleAudioUpload(event) { | |
| if (!event.target.files || !event.target.files.length) return; | |
| const file = event.target.files[0]; | |
| const fileUrl = URL.createObjectURL(file); | |
| fileExt = file.name.split('.').splice(-1); | |
| this.howl = new Howl({ | |
| src: [fileUrl], | |
| format: this.fileExt, // This should be an array | |
| html5: true, | |
| onend: () => { | |
| this.isPlayingSegment = false; | |
| this.activeSegment = null; | |
| } | |
| }); | |
| this.audioUrl = fileUrl; | |
| this.audioFile = [file]; | |
| }, | |
| initializeAudio() { | |
| if (this.howl) { | |
| this.howl.unload(); | |
| } | |
| const fileExt = this.audioFile[0].name.split('.').splice(-1); | |
| this.howl = new Howl({ | |
| src: [this.audioUrl], | |
| format: fileExt, | |
| html5: true, | |
| volume: this.volume, | |
| sprite: this.createSprites(), | |
| onplay: () => { | |
| this.isPlaying = true; | |
| this.startSeekUpdate(); | |
| }, | |
| onpause: () => { | |
| this.isPlaying = false; | |
| this.stopSeekUpdate(); | |
| }, | |
| onstop: () => { | |
| this.isPlaying = false; | |
| this.currentTime = 0; | |
| this.stopSeekUpdate(); | |
| }, | |
| onend: () => { | |
| this.isPlaying = false; | |
| this.currentTime = 0; | |
| this.stopSeekUpdate(); | |
| }, | |
| onload: () => { | |
| this.totalDuration = this.howl.duration(); | |
| /*/ Set selection to the length of the audio file | |
| this.selection = []; | |
| for (let i = 0; i < this.totalDuration; i += this.chunkSize) { | |
| this.selection.push(i); | |
| }*/ | |
| } | |
| }); | |
| }, | |
| async initializeWaveform() { | |
| if (!this.audioUrl) return; | |
| // Load audio file and decode it | |
| const response = await fetch(this.audioUrl); | |
| const arrayBuffer = await response.arrayBuffer(); | |
| const audioContext = new AudioContext(); | |
| const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); | |
| // Get waveform data | |
| const channelData = audioBuffer.getChannelData(0); | |
| this.waveformData = this.processWaveformData(channelData); | |
| // Draw waveform | |
| this.$nextTick(() => { | |
| this.drawWaveform(); | |
| }); | |
| }, | |
| processWaveformData(data) { | |
| const step = Math.ceil(data.length / 1000); | |
| const waveform = []; | |
| for (let i = 0; i < data.length; i += step) { | |
| const slice = data.slice(i, i + step); | |
| // Use reducer instead of spread operator for large arrays | |
| const max = slice.reduce((a, b) => Math.max(a, b), -Infinity); | |
| const min = slice.reduce((a, b) => Math.min(a, b), Infinity); | |
| waveform.push({ max, min }); | |
| } | |
| return waveform; | |
| }, | |
| drawWaveform() { | |
| if (this.$refs.waveform){ | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const width = this.$refs.waveform.offsetWidth; | |
| const height = 100; | |
| canvas.width = width; | |
| canvas.height = height; | |
| this.$refs.waveform.style.backgroundImage = `url(${canvas.toDataURL()})`; | |
| ctx.fillStyle = '#3273dc'; | |
| this.waveformData.forEach((point, i) => { | |
| const x = (i / this.waveformData.length) * width; | |
| const y = (1 - point.max) * height / 2; | |
| const h = (point.max - point.min) * height / 2; | |
| ctx.fillRect(x, y, 1, h); | |
| }); | |
| } | |
| }, | |
| createSprites() { | |
| const sprites = {}; | |
| this.segments.forEach((segment, index) => { | |
| sprites[`segment_${index}`] = [segment.start * 1000, (segment.end - segment.start) * 1000]; | |
| }); | |
| return sprites; | |
| }, | |
| togglePlay() { | |
| if (this.isPlaying) { | |
| this.howl.pause(); | |
| } else { | |
| this.howl.play(); | |
| } | |
| }, | |
| stopAudio() { | |
| this.howl.stop(); | |
| }, | |
| updateVolume() { | |
| if (this.volume <= 0) { | |
| this.isMuted = true; | |
| this.howl.mute(true); | |
| } else if (this.isMuted && this.volume > 0) { | |
| this.isMuted = false; | |
| this.howl.mute(false); | |
| } | |
| this.howl.volume(this.volume); | |
| }, | |
| toggleVolumeSlider() { | |
| this.showVolumeSlider = !this.showVolumeSlider; | |
| }, | |
| toggleMute() { | |
| this.isMuted = !this.isMuted; | |
| this.howl.mute(this.isMuted); | |
| if (!this.isMuted && this.volume === 0) { | |
| this.volume = 0.5; | |
| } | |
| }, | |
| playSegment(segment) { | |
| const index = this.segments.indexOf(segment); | |
| if (index !== -1) { | |
| this.howl.play(`segment_${index}`); | |
| } | |
| }, | |
| startSeekUpdate() { | |
| this.seekInterval = setInterval(() => { | |
| this.currentTime = this.howl.seek() || 0; | |
| }, 100); | |
| }, | |
| stopSeekUpdate() { | |
| clearInterval(this.seekInterval); | |
| }, | |
| formatTime(seconds) { | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.floor(seconds % 60); | |
| return `${mins}:${secs < 10 ? '0' : ''}${secs}`; | |
| }, | |
| togglePlayPause() { | |
| if (this.audioPlayer.paused) { | |
| this.audioPlayer.play(); | |
| this.isPlaying = true; | |
| } else { | |
| this.audioPlayer.pause(); | |
| this.isPlaying = false; | |
| } | |
| }, | |
| playSegment(segment) { | |
| if (!this.howl) return; | |
| if (this.activeSegment === segment.id && this.isPlayingSegment) { | |
| this.howl.pause(); | |
| this.isPlayingSegment = false; | |
| return; | |
| } | |
| this.howl.seek(segment.start); | |
| this.howl.play(); | |
| this.activeSegment = segment.id; | |
| this.isPlayingSegment = true; | |
| this.howl.once('end', () => { | |
| this.isPlayingSegment = false; | |
| this.activeSegment = null; | |
| }); | |
| }, | |
| copySegmentText(text) { | |
| navigator.clipboard.writeText(text).then(() => { | |
| this.$buefy.toast.open({ | |
| message: 'Text copied to clipboard', | |
| type: 'is-success', | |
| position: 'is-bottom-right' | |
| }); | |
| }).catch(() => { | |
| this.$buefy.toast.open({ | |
| message: 'Failed to copy text', | |
| type: 'is-danger', | |
| position: 'is-bottom-right' | |
| }); | |
| }); | |
| }, | |
| deleteSegment(index) { | |
| this.segments.splice(index, 1); | |
| this.updateTranscriptionText(); | |
| }, | |
| updateTranscriptionText() { | |
| this.transcriptionText = this.segments | |
| .map(segment => segment.text) | |
| .join(' '); | |
| }, | |
| formatTime(seconds) { | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.floor(seconds % 60); | |
| return `${mins}:${secs < 10 ? '0' : ''}${secs}`; | |
| }, | |
| async processTranscription() { | |
| const formData = new FormData(); | |
| formData.append('file', this.audioFile[0]); | |
| formData.append('response_format', this.responseFormat); | |
| formData.append('temperature', this.temperature.toString()); | |
| formData.append('chunk_size', parseInt(this.chunkSize * 60).toString()); | |
| formData.append('overlap', this.overlap.toString()); | |
| formData.append('prompt', this.systemPrompt.toString()); | |
| // Add selection timestamps | |
| const startTime = (this.selection[0] * this.totalDuration / 100).toFixed(2); | |
| const endTime = (this.selection[1] * this.totalDuration / 100).toFixed(2); | |
| formData.append('start_time', startTime); | |
| formData.append('end_time', endTime); | |
| if (this.selectedLanguage !== 'auto') { | |
| formData.append('language', this.selectedLanguage); | |
| } | |
| try { | |
| this.isProcessing = true; | |
| /*/ Monitor the progress of the upload for user feedback (need to be updated for reading back response) | |
| const xhr = new XMLHttpRequest(); | |
| xhr.upload.onloadstart = function (event) { | |
| console.log('Upload started'); | |
| }; | |
| xhr.upload.onprogress = function (event) { | |
| if (event.lengthComputable) { | |
| const percentComplete = (event.loaded / event.total) * 100; | |
| console.log(`Upload progress: ${percentComplete.toFixed(2)}%`); | |
| } | |
| }; | |
| xhr.upload.onload = function () { | |
| console.log('Upload complete'); | |
| }; | |
| xhr.onerror = function () { | |
| console.error('Error uploading file'); | |
| }; | |
| xhr.open('POST', '/api/upload', true); | |
| xhr.send(formData);*/ | |
| const response = await fetch('/api/upload', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${this.token}` | |
| }, | |
| body: formData | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json(); | |
| throw new Error(errorData.detail || 'Transcription failed'); | |
| } | |
| const result = await response.json(); | |
| // Check for backend validation errors | |
| if (result.metadata?.errors?.length) { | |
| console.error('Chunk processing errors:', result.metadata.errors); | |
| this.$buefy.snackbar.open({ | |
| message: `${result.metadata.errors.length} chunks failed to process`, | |
| type: 'is-warning', | |
| position: 'is-bottom-right' | |
| }); | |
| } | |
| if (this.responseFormat === 'verbose_json') { | |
| // Validate segments | |
| if (!result.segments) { | |
| throw new Error('Server returned invalid segments format'); | |
| } | |
| this.segments = result.segments; | |
| this.updateTranscriptionText(); | |
| } else { | |
| this.transcriptionText = result.text || result; | |
| } | |
| // this.totalChunks = result.metadata?.total_chunks || 0; | |
| // this.processingProgress = ((processedChunks / this.totalChunks) * 100).toFixed(1); | |
| } catch (error) { | |
| console.error('Transcription error:', error); | |
| this.$buefy.toast.open({ | |
| message: `Error: ${error.message}`, | |
| type: 'is-danger', | |
| position: 'is-bottom-right' | |
| }); | |
| } | |
| this.isProcessing = false; | |
| this.showPlayer = true; | |
| }, | |
| async loadTranscriptions() { | |
| try { | |
| // Check if token exists | |
| if (!this.token) { | |
| this.token = localStorage.getItem('token'); | |
| if (!this.token) { | |
| // console.log('No authentication token found'); | |
| return; | |
| } | |
| } | |
| const response = await fetch('/api/transcriptions', { | |
| headers: { | |
| 'Authorization': `Bearer ${this.token}` | |
| } | |
| }); | |
| if (response.status === 401) { | |
| // Token expired or invalid | |
| console.log('Authentication token expired or invalid'); | |
| this.logout(); | |
| this.$buefy.toast.open({ | |
| message: 'Your session has expired. Please login again.', | |
| type: 'is-warning' | |
| }); | |
| return; | |
| } | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! Status: ${response.status}`); | |
| } | |
| this.transcriptions = await response.json(); | |
| } catch (error) { | |
| console.error('Error loading transcriptions:', error); | |
| this.$buefy.toast.open({ | |
| message: `Failed to load transcriptions: ${error.message}`, | |
| type: 'is-danger' | |
| }); | |
| } | |
| }, | |
| async loadTranscription(transcription) { | |
| try { | |
| const response = await fetch(`/api/transcriptions/${transcription.id}`, { | |
| headers: { | |
| 'Authorization': `Bearer ${this.token}` | |
| } | |
| }); | |
| const data = await response.json(); | |
| // Set the audio file for playback | |
| this.transcriptionText = data['text']; | |
| this.segments = data['segments']; | |
| // this.audioFile = data['audio_file']; | |
| if (data.audio_file) { | |
| // this.audioUrl = `/uploads/${data['audio_file']}`; | |
| // this.audioFile.push(data['audio_file']); | |
| // this.initializeAudio(); | |
| } | |
| } catch (error) { | |
| console.error('Error loading transcription:', error); | |
| } | |
| }, | |
| async saveTranscription() { | |
| if (this.audioFile.length == 0){ | |
| this.$buefy.toast.open({ | |
| message: 'Please upload an audio file first', | |
| type: 'is-warning', | |
| position: 'is-bottom-right' | |
| }); | |
| return; | |
| } | |
| try { | |
| this.isProcessing = true; | |
| const response = await fetch('/api/save-transcription', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${this.token}` | |
| }, | |
| body: JSON.stringify({ | |
| text: this.transcriptionText, | |
| segments: this.segments, | |
| audio_file: this.audioFile[0].name | |
| }) | |
| }); | |
| if (!response.ok) throw new Error('Failed to save transcription'); | |
| await this.loadTranscriptions(); | |
| this.$buefy.toast.open({ | |
| message: 'Transcription saved successfully', | |
| type: 'is-success', | |
| position: 'is-bottom-right' | |
| }); | |
| } catch (error) { | |
| console.error('Error saving transcription:', error); | |
| this.$buefy.toast.open({ | |
| message: `Error: ${error.message}`, | |
| type: 'is-danger', | |
| position: 'is-bottom-right' | |
| }); | |
| } | |
| this.isProcessing = false; | |
| }, | |
| toggleSidebar() { | |
| this.showSidebar = !this.showSidebar; | |
| }, | |
| togglePlayer() { | |
| this.showPlayer = !this.showPlayer; | |
| }, | |
| async deleteTranscription(id) { | |
| try { | |
| // Show confirmation dialog | |
| this.$buefy.dialog.confirm({ | |
| title: 'Delete Transcription', | |
| message: 'Are you sure you want to delete this transcription? This action cannot be undone.', | |
| confirmText: 'Delete', | |
| type: 'is-danger', | |
| hasIcon: true, | |
| onConfirm: async () => { | |
| const response = await fetch(`/api/transcriptions/${id}`, { | |
| method: 'DELETE', | |
| headers: { | |
| 'Authorization': `Bearer ${this.token}` | |
| } | |
| }); | |
| if (!response.ok) throw new Error('Failed to delete transcription'); | |
| // Remove from local list | |
| this.transcriptions = this.transcriptions.filter(t => t.id !== id); | |
| // Show success message | |
| this.$buefy.toast.open({ | |
| message: 'Transcription deleted successfully', | |
| type: 'is-success', | |
| position: 'is-bottom-right' | |
| }); | |
| } | |
| }); | |
| } catch (error) { | |
| console.error('Error deleting transcription:', error); | |
| this.$buefy.toast.open({ | |
| message: `Error: ${error.message}`, | |
| type: 'is-danger', | |
| position: 'is-bottom-right' | |
| }); | |
| } | |
| }, | |
| async handleLogin() { | |
| try { | |
| this.isLoading = true; | |
| const formData = new FormData(); | |
| formData.append('username', this.loginForm.username); // Make sure this matches the backend expectation | |
| formData.append('password', this.loginForm.password); | |
| const response = await fetch('/token', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json(); | |
| throw new Error(errorData.detail || 'Login failed'); | |
| } | |
| const data = await response.json(); | |
| this.token = data.access_token; | |
| localStorage.setItem('token', data.access_token); | |
| // Get user info | |
| const userResponse = await fetch('/api/me', { | |
| headers: { | |
| 'Authorization': `Bearer ${this.token}` | |
| } | |
| }); | |
| if (!userResponse.ok) { | |
| throw new Error('Failed to get user information'); | |
| } | |
| const userData = await userResponse.json(); | |
| this.username = userData.username; | |
| this.isAuthenticated = true; | |
| this.showLoginModal = false; | |
| this.$buefy.toast.open({ | |
| message: 'Successfully logged in!', | |
| type: 'is-success' | |
| }); | |
| // Load user data after successful login | |
| this.loadTranscriptions(); | |
| } catch (error) { | |
| this.$buefy.toast.open({ | |
| message: `Error: ${error.message}`, | |
| type: 'is-danger' | |
| }); | |
| } finally { | |
| this.isLoading = false; | |
| } | |
| }, | |
| async handleSignup() { | |
| try { | |
| this.isLoading = true; | |
| if (this.signupForm.password !== this.signupForm.confirmPassword) { | |
| throw new Error('Passwords do not match'); | |
| } | |
| const response = await fetch('/api/signup', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| username: this.signupForm.username, | |
| email: this.signupForm.email, | |
| password: this.signupForm.password | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json(); | |
| throw new Error(error.detail || 'Signup failed'); | |
| } | |
| this.showSignupModal = false; | |
| this.$buefy.toast.open({ | |
| message: 'Account created successfully! Please login.', | |
| type: 'is-success' | |
| }); | |
| // Clear form | |
| this.signupForm = { | |
| username: '', | |
| email: '', | |
| password: '', | |
| confirmPassword: '' | |
| }; | |
| // Show login modal | |
| this.showLoginModal = true; | |
| } catch (error) { | |
| this.$buefy.toast.open({ | |
| message: `Error: ${error.message}`, | |
| type: 'is-danger' | |
| }); | |
| } finally { | |
| this.isLoading = false; | |
| } | |
| }, | |
| async checkAuth() { | |
| const token = localStorage.getItem('token'); | |
| if (token) { | |
| try { | |
| this.token = token; | |
| const response = await fetch('/api/me', { | |
| headers: { | |
| 'Authorization': `Bearer ${token}` | |
| } | |
| }); | |
| if (response.ok) { | |
| const userData = await response.json(); | |
| this.username = userData.username; | |
| this.isAuthenticated = true; | |
| this.isAdmin = userData.is_admin; | |
| this.currentUser = userData; | |
| // If user is admin, load users list | |
| if (this.isAdmin) { | |
| this.loadUsers(); | |
| } | |
| } else { | |
| // Token invalid or expired | |
| this.logout(); | |
| } | |
| } catch (error) { | |
| console.error('Auth check failed:', error); | |
| this.logout(); | |
| } | |
| } | |
| }, | |
| // Add these methods to the methods section | |
| async loadUsers() { | |
| if (!this.isAdmin) return; | |
| try { | |
| this.loadingUsers = true; | |
| const response = await fetch('/api/users', { | |
| headers: { | |
| 'Authorization': `Bearer ${this.token}` | |
| } | |
| }); | |
| if (!response.ok) { | |
| throw new Error('Failed to load users'); | |
| } | |
| this.users = await response.json(); | |
| } catch (error) { | |
| console.error('Error loading users:', error); | |
| this.$buefy.toast.open({ | |
| message: `Error: ${error.message}`, | |
| type: 'is-danger' | |
| }); | |
| } finally { | |
| this.loadingUsers = false; | |
| } | |
| }, | |
| async toggleUserStatus(user) { | |
| try { | |
| // Don't allow admins to disable themselves | |
| if (user.is_admin && user.id === this.currentUser.id && !user.disabled) { | |
| this.$buefy.toast.open({ | |
| message: 'Admins cannot disable their own accounts', | |
| type: 'is-warning' | |
| }); | |
| return; | |
| } | |
| const action = user.disabled ? 'enable' : 'disable'; | |
| const response = await fetch(`/api/users/${user.id}/${action}`, { | |
| method: 'PUT', | |
| headers: { | |
| 'Authorization': `Bearer ${this.token}`, | |
| 'Content-Type': 'application/json' | |
| } | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json(); | |
| throw new Error(errorData.detail || `Failed to ${action} user`); | |
| } | |
| // Update local user data | |
| user.disabled = !user.disabled; | |
| this.$buefy.toast.open({ | |
| message: `User ${user.username} ${action}d successfully`, | |
| type: 'is-success' | |
| }); | |
| } catch (error) { | |
| console.error(`Error ${user.disabled ? 'enabling' : 'disabling'} user:`, error); | |
| this.$buefy.toast.open({ | |
| message: `Error: ${error.message}`, | |
| type: 'is-danger' | |
| }); | |
| } | |
| }, | |
| async deleteUser(user) { | |
| try { | |
| // Don't allow admins to delete themselves | |
| if (user.id === this.currentUser.id) { | |
| this.$buefy.toast.open({ | |
| message: 'You cannot delete your own account', | |
| type: 'is-warning' | |
| }); | |
| return; | |
| } | |
| // Show confirmation dialog | |
| this.$buefy.dialog.confirm({ | |
| title: 'Delete User', | |
| message: `Are you sure you want to delete user "${user.username}"? This action cannot be undone.`, | |
| confirmText: 'Delete', | |
| type: 'is-danger', | |
| hasIcon: true, | |
| onConfirm: async () => { | |
| const response = await fetch(`/api/users/${user.id}`, { | |
| method: 'DELETE', | |
| headers: { | |
| 'Authorization': `Bearer ${this.token}` | |
| } | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json(); | |
| throw new Error(errorData.detail || 'Failed to delete user'); | |
| } | |
| // Remove from local list | |
| this.users = this.users.filter(u => u.id !== user.id); | |
| this.$buefy.toast.open({ | |
| message: `User ${user.username} deleted successfully`, | |
| type: 'is-success' | |
| }); | |
| } | |
| }); | |
| } catch (error) { | |
| console.error('Error deleting user:', error); | |
| this.$buefy.toast.open({ | |
| message: `Error: ${error.message}`, | |
| type: 'is-danger' | |
| }); | |
| } | |
| }, | |
| refreshUsers() { | |
| this.loadUsers(); | |
| }, | |
| }, // methods | |
| // Add this to the Vue app definition, after methods | |
| computed: { | |
| filteredUsers() { | |
| if (!this.userSearchQuery) { | |
| return this.users; | |
| } | |
| const query = this.userSearchQuery.toLowerCase(); | |
| return this.users.filter(user => | |
| user.username.toLowerCase().includes(query) || | |
| user.email.toLowerCase().includes(query) | |
| ); | |
| } | |
| }, | |
| mounted() { | |
| this.checkAuth(); | |
| this.loadTranscriptions(); | |
| }, | |
| watch: { | |
| audioUrl() { | |
| if (this.audioUrl) { | |
| this.initializeAudio(); | |
| this.initializeWaveform(); | |
| } | |
| }, | |
| showAdminPanel(newVal) { | |
| if (newVal && this.isAdmin) { | |
| this.loadUsers(); | |
| } | |
| } | |
| }, | |
| beforeUnmount() { | |
| if (this.howl) { | |
| this.howl.unload(); | |
| } | |
| this.stopSeekUpdate(); | |
| } | |
| }); | |
| app.use(Buefy.default); | |
| app.mount('#app'); | |
| </script> | |
| </body> | |
| </html> |