bitsnaps commited on
Commit
f088509
·
verified ·
1 Parent(s): 0705212

Upload 16 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ static/fonts/materialdesignicons-webfont.ttf filter=lfs diff=lfs merge=lfs -text
37
+ static/fonts/materialdesignicons-webfont.woff filter=lfs diff=lfs merge=lfs -text
38
+ static/fonts/materialdesignicons-webfont.woff2 filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -27,5 +27,5 @@ ENV PORT=8000
27
  # Expose the port the app runs on
28
  EXPOSE 8000
29
 
30
- # Command to run the application
31
  CMD gunicorn main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:$PORT
 
27
  # Expose the port the app runs on
28
  EXPOSE 8000
29
 
30
+ # Command to run the application using uvicorn directly
31
  CMD gunicorn main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:$PORT
README.md CHANGED
@@ -1,10 +1,41 @@
1
- ---
2
- title: TranStudio
3
- emoji: 🚀
4
- colorFrom: indigo
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TranStudio
2
+
3
+ TranStudio is a web-based application for generating and managing audio transcriptions. It provides an intuitive interface for uploading audio files, generating transcriptions, and managing transcribed content with advanced playback controls.
4
+
5
+ ## Features
6
+
7
+ - **Audio Upload**: Support for uploading audio files for transcription
8
+ - **Real-time Transcription**: Process audio files and generate text transcriptions
9
+ - **Interactive Audio Player**:
10
+ - Waveform visualization
11
+ - Play/pause controls
12
+ - Volume control with mute option
13
+ - Segment-based playback
14
+ - Time formatting and seeking
15
+ - **Transcription Management**:
16
+ - Save and load transcriptions
17
+ - Copy text to clipboard
18
+ - Delete segments
19
+ - View transcription history
20
+ - **Advanced Options**:
21
+ - Language selection
22
+ - Response format customization
23
+ - Temperature control
24
+ - Chunk size and overlap settings
25
+
26
+ ## Technology Stack
27
+
28
+ - **Backend**: FastAPI (Python)
29
+ - **Frontend**: Vue.js with Buefy UI components
30
+ - **Audio Processing**:
31
+ - Howler.js for audio playback
32
+ - SoundFile for audio processing
33
+ - Custom waveform visualization
34
+
35
+ ## Setup
36
+
37
+ 1. Clone the repository
38
+ 2. Install dependencies:
39
+ ```bash
40
+ pip install -r requirements.txt
41
+ ```
index.html ADDED
@@ -0,0 +1,1406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TranStudio</title>
7
+ <link rel="stylesheet" href="/static/css/buefy.min.css">
8
+ <link rel="stylesheet" href="/static/css/materialdesignicons.min.css">
9
+ <script>
10
+ window.process = { env: { NODE_ENV: 'production' } };
11
+ </script>
12
+ <script src="/static/js/vue.global.prod.js"></script>
13
+ <script src="/static/js/buefy.min.js"></script>
14
+ <style>
15
+ .segments-container {
16
+ max-height: 300px;
17
+ overflow-y: auto;
18
+ border: 1px solid #ddd;
19
+ padding: 10px;
20
+ border-radius: 4px;
21
+ }
22
+
23
+ .segment-box {
24
+ padding: 10px;
25
+ margin: 5px 0;
26
+ border: 1px solid #eee;
27
+ border-radius: 4px;
28
+ cursor: pointer;
29
+ transition: all 0.2s;
30
+ }
31
+
32
+ .segment-box:hover {
33
+ background-color: #f5f5f5;
34
+ }
35
+
36
+ .segment-box.is-active {
37
+ border-color: #3273dc;
38
+ background-color: #e8f0fe;
39
+ }
40
+
41
+ .segment-time {
42
+ font-size: 0.8em;
43
+ color: #666;
44
+ margin-bottom: 5px;
45
+ }
46
+
47
+ .segment-text {
48
+ margin: 5px 0;
49
+ }
50
+
51
+ .segment-actions {
52
+ text-align: right;
53
+ }
54
+ .audio-player {
55
+ position: fixed;
56
+ bottom: 0;
57
+ left: 0;
58
+ right: 0;
59
+ background: white;
60
+ padding: 1rem;
61
+ box-shadow: 0 -2px 5px rgba(0,0,0,0.1);
62
+ z-index: 100;
63
+ margin-top: 2rem;
64
+ }
65
+
66
+ .waveform-container {
67
+ margin: 1rem 0;
68
+ padding: 0 1rem;
69
+ }
70
+
71
+ .waveform {
72
+ position: relative;
73
+ height: 10vh;
74
+ background: #f0f0f0;
75
+ border-radius: 4px;
76
+ overflow: hidden;
77
+ }
78
+
79
+ .progress {
80
+ position: absolute;
81
+ top: 0;
82
+ left: 0;
83
+ height: 100%;
84
+ background: rgba(50, 115, 220, 0.3);
85
+ pointer-events: none;
86
+ }
87
+
88
+ .player-controls {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 0.5rem;
92
+ margin-bottom: 0.5rem;
93
+ }
94
+
95
+ .segment-marker {
96
+ position: absolute;
97
+ height: 100%;
98
+ background: rgba(50, 115, 220, 0.2);
99
+ cursor: pointer;
100
+ transition: background 0.2s;
101
+ border-left: 1px solid #3273dc;
102
+ border-right: 1px solid #3273dc;
103
+ }
104
+
105
+ .segment-marker:hover {
106
+ background: rgba(50, 115, 220, 0.4);
107
+ }
108
+
109
+ .segment-tooltip {
110
+ position: absolute;
111
+ bottom: 100%;
112
+ background: #333;
113
+ color: white;
114
+ padding: 0.25rem 0.5rem;
115
+ border-radius: 3px;
116
+ font-size: 0.8rem;
117
+ white-space: nowrap;
118
+ display: none;
119
+ }
120
+
121
+ .segment-marker:hover .segment-tooltip {
122
+ display: block;
123
+ }
124
+
125
+ .current-time {
126
+ text-align: center;
127
+ font-size: 0.9rem;
128
+ color: #666;
129
+ margin-top: 0.5rem;
130
+ }
131
+ .control-btn {
132
+ /* padding: 0.5rem; */
133
+ transition: all 0.2s;
134
+ }
135
+
136
+ .control-btn:hover {
137
+ transform: scale(1.1);
138
+ }
139
+ .selection-info {
140
+ margin: 10px 0;
141
+ display: flex;
142
+ align-items: center;
143
+ }
144
+
145
+ .slider-tick {
146
+ width: 2px;
147
+ height: 12px;
148
+ background-color: currentColor;
149
+ border-radius: 1px;
150
+ }
151
+
152
+ /* Customize the range slider track */
153
+ .b-slider.is-info .b-slider-track {
154
+ background: rgba(50, 115, 220, 0.3);
155
+ }
156
+
157
+ /* Style for the selected range */
158
+ .b-slider.is-info .b-slider-fill {
159
+ background: #3273dc;
160
+ }
161
+ </style>
162
+ </head>
163
+ <body>
164
+ <div id="app" class="container">
165
+ <b-navbar>
166
+ <template #start>
167
+ <b-navbar-item @click="toggleSidebar">
168
+ <b-icon :icon="showSidebar ? 'menu-open' : 'menu'"></b-icon>
169
+ </b-navbar-item>
170
+ <b-navbar-item tag="router-link" :to="{ path: '/' }">
171
+ <a href="/">TranStudio</a>
172
+ </b-navbar-item>
173
+ </template>
174
+ <template #end>
175
+ <!-- <b-navbar-dropdown :label="isAuthenticated ? : 'Account'"> -->
176
+ <template v-if="isAuthenticated">
177
+ <b-navbar-item v-if="isAdmin" @click="showAdminPanel = true">
178
+ <b-icon icon="shield-account"></b-icon>
179
+ <span class="ml-1">Admin</span>
180
+ </b-navbar-item>
181
+ <b-navbar-item @click="logout">Logout {{ username }}</b-navbar-item>
182
+ </template>
183
+ <template v-else>
184
+ <b-navbar-item @click="showLoginModal = true">Login</b-navbar-item>
185
+ <b-navbar-item @click="showSignupModal = true">Sign Up</b-navbar-item>
186
+ </template>
187
+ <!-- </b-navbar-dropdown> -->
188
+ </template>
189
+ </b-navbar>
190
+
191
+ <div class="columns mb-6 pb-6">
192
+ <!-- Sidebar -->
193
+ <div class="column is-one-quarter" v-if="showSidebar">
194
+ <b-menu>
195
+
196
+ <b-menu-list :label="`Previous Transcriptions (${transcriptions.length})`">
197
+ <b-menu-item v-for="transcription in transcriptions"
198
+ class="is-flex is-justify-content-space-between is-align-items-center"
199
+ :key="transcription.id"
200
+ @click="loadTranscription(transcription)" :label="transcription.name">
201
+
202
+ <div class="is-flex is-justify-content-space-between is-align-items-center" style="width: 100%">
203
+ <span>
204
+ {{transcription?.audio_file}}
205
+ </span>
206
+ <b-button
207
+ type="is-danger"
208
+ size="is-small"
209
+ icon-left="delete"
210
+ @click.stop="deleteTranscription(transcription.id)">
211
+ </b-button>
212
+ </div>
213
+ </b-menu-item>
214
+ </b-menu-list>
215
+ </b-menu>
216
+ </div>
217
+
218
+ <!-- Main Content -->
219
+ <div class="column">
220
+ <!-- <b-field label="Select AI Model">
221
+ <b-select v-model="selectedModel">
222
+ <option value="whisper-large-v3-turbo">Groq - Whisper Large v3 Turbo</option>
223
+ <option value="openai-whisper-large-v3-turbo">Huggingface - OpenAI Whisper Large v3 Turbo</option>
224
+ </b-select>
225
+ </b-field> -->
226
+
227
+ <b-field label="Format">
228
+ <b-select v-model="responseFormat">
229
+ <option value="verbose_json">Verbose (with timestamps)</option>
230
+ <option value="json">Standard</option>
231
+ <option value="text">Simple</option>
232
+ </b-select>
233
+ </b-field>
234
+
235
+ <b-field label="Language">
236
+ <b-select v-model="selectedLanguage">
237
+ <option value="auto">Auto Detect</option>
238
+ <option value="en">English</option>
239
+ <option value="es">Spanish</option>
240
+ <option value="ar">Arabic</option>
241
+ <option value="fr">French</option>
242
+ <option value="de">German</option>
243
+ <option value="it">Italian</option>
244
+ </b-select>
245
+ </b-field>
246
+
247
+ <b-field label="Temperature">
248
+ <b-slider v-model="temperature" :min="0" :max="1" :step="0.1"></b-slider>
249
+ <span class="tag is-primary">{{ temperature }}</span>
250
+ </b-field>
251
+
252
+ <b-field label="Chunk Size (in minutes)">
253
+ <b-slider v-model="chunkSize" :min="5" :max="30" :step="1"></b-slider>
254
+ <span class="tag is-primary">{{ chunkSize }}</span>
255
+ </b-field>
256
+
257
+ <b-field label="Overlap (in seconds)">
258
+ <b-slider v-model="overlap" :min="2" :max="60" :step="1"></b-slider>
259
+ <span class="tag is-primary">{{ overlap }}</span>
260
+ </b-field>
261
+
262
+ <b-field label="Selection" v-if="totalDuration">
263
+ <b-slider v-model="selection" :step="1" type="is-info" :tooltip="false">
264
+ <!-- <template v-for="n in totalDuration" :key="n">
265
+ <b-slider-tick :value="n">{{ n }}</b-slider-tick>
266
+ </template> -->
267
+ </b-slider>
268
+ </b-field>
269
+
270
+ <div class="selection-info" v-if="totalDuration">
271
+ <span class="tag is-info">
272
+ Start: {{ formatTime(selection[0] * totalDuration / 100) }}
273
+ </span>
274
+ <span class="tag is-info ml-2">
275
+ End: {{ formatTime(selection[1] * totalDuration / 100) }}
276
+ </span>
277
+ </div>
278
+
279
+ <b-field label="System Prompt (Optional)">
280
+ <b-input v-model="systemPrompt" maxlength="256" type="textarea"></b-input>
281
+ </b-field>
282
+
283
+ <b-upload v-model="audioFile" accept="audio/*" @change="handleAudioUpload" multiple>
284
+ <a class="button is-primary">
285
+ <b-icon icon="upload"></b-icon>
286
+ <span>Click to upload</span>
287
+ </a>
288
+ </b-upload>
289
+
290
+ <b-field v-if="audioFile.length">
291
+ <span class="file-name">
292
+ {{ audioFile[0].name }}
293
+ </span>
294
+ </b-field>
295
+ </b-field>
296
+
297
+ <!-- <div v-if="audioUrl" class="audio-controls mb-4">
298
+ <audio ref="audioPlayer" :src="audioUrl"></audio>
299
+ <b-button @click="togglePlayPause">
300
+ {{ isPlaying ? 'Pause' : 'Play' }}
301
+ </b-button>
302
+ </div> -->
303
+
304
+ <b-field class="mt-2" label="Transcription">
305
+ <b-input v-model="transcriptionText" type="textarea" rows="5"></b-input>
306
+ </b-field>
307
+
308
+ <b-field class="mt-2" label="Segments" v-if="segments.length && responseFormat === 'verbose_json'">
309
+ <div class="segments-container">
310
+ <div v-for="(segment, index) in segments" :key="segment.id"
311
+ class="segment-box"
312
+ :class="{'is-active': activeSegment === index}"
313
+ @click="playSegment(segment)">
314
+ <div class="segment-time">
315
+ {{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}
316
+ </div>
317
+ <div class="segment-text">
318
+ <b-input v-model="segment.text" type="textarea" rows="2"></b-input>
319
+ </div>
320
+ <div class="segment-actions">
321
+ <b-button type="is-info" size="is-small" @click.stop="copySegmentText(segment.text)">
322
+ <b-icon icon="content-copy"></b-icon>
323
+ </b-button>
324
+ <b-button type="is-danger" size="is-small" @click.stop="deleteSegment(index)">
325
+ <b-icon icon="delete"></b-icon>
326
+ </b-button>
327
+ </div>
328
+ </div>
329
+ </div>
330
+ </b-field>
331
+
332
+ <b-button type="is-primary" class="mr-2" @click="processTranscription" :disabled="!audioUrl" :loading="isProcessing">Process</b-button>
333
+ <b-button type="is-success" class="mr-2" @click="saveTranscription" :disabled="!transcriptionText" :loading="isProcessing">Save</b-button>
334
+ <b-button class="control-btn" @click="togglePlayer" :disabled="!audioUrl">
335
+ <b-icon :icon="showPlayer ? 'menu-down' : 'menu-up'"></b-icon>
336
+ </b-button>
337
+ </div>
338
+
339
+ </div><!-- .columns -->
340
+
341
+ <div class="columns mt-2">
342
+ <div class="audio-player" v-if="showPlayer">
343
+ <div class="player-controls" v-if="audioUrl">
344
+ <b-button class="control-btn" @click="togglePlay">
345
+ <b-icon :icon="isPlaying ? 'pause' : 'play'"></b-icon>
346
+ </b-button>
347
+ <b-button class="control-btn" @click="stopAudio">
348
+ <b-icon icon="stop"></b-icon>
349
+ </b-button>
350
+ <b-slider v-model="volume" :min="0" :max="1" :step="0.1" @input="updateVolume" size="is-small">
351
+ <template #indicator>
352
+ <span class="tag is-primary is-small">{{ volume.toFixed(1) }}</span>
353
+ </template>
354
+ </b-slider>
355
+ <b-button class="control-btn" @click="toggleMute">
356
+ <b-icon :icon="isMuted ? 'volume-off' : 'volume-high'"></b-icon>
357
+ </b-button>
358
+ <b-button class="control-btn" @click="togglePlayer">
359
+ <b-icon :icon="showPlayer ? 'menu-down' : 'menu-up'"></b-icon>
360
+ </b-button>
361
+ </div><!-- .player-controls -->
362
+
363
+ <div class="waveform-container" v-if="segments.length">
364
+ <div class="waveform" ref="waveform">
365
+ <div v-for="(segment, index) in segments"
366
+ :key="segment.id"
367
+ class="segment-marker"
368
+ :style="{ left: `${(segment.start / totalDuration) * 100}%`, width: `${((segment.end - segment.start) / totalDuration) * 100}%` }"
369
+ @click="playSegment(segment)">
370
+ <div class="segment-tooltip">
371
+ {{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}
372
+ </div>
373
+ </div>
374
+ <div class="progress" :style="{ width: `${(currentTime / totalDuration) * 100}%` }"></div>
375
+ </div>
376
+ </div><!-- .waveform-container -->
377
+
378
+ <div class="current-time">
379
+ {{ formatTime(currentTime) }} / {{ formatTime(totalDuration) }}
380
+ </div>
381
+
382
+ </div><!-- .audio-player -->
383
+ </div>
384
+
385
+
386
+ <!-- Login Modal -->
387
+ <b-modal v-model="showLoginModal" has-modal-card trap-focus>
388
+ <div class="modal-card">
389
+ <header class="modal-card-head">
390
+ <p class="modal-card-title">Login</p>
391
+ </header>
392
+ <section class="modal-card-body">
393
+ <b-field label="Username">
394
+ <b-input type="text" v-model="loginForm.username" placeholder="Enter your username"></b-input>
395
+ </b-field>
396
+ <b-field label="Password">
397
+ <b-input type="password" v-model="loginForm.password" password-reveal></b-input>
398
+ </b-field>
399
+ </section>
400
+ <footer class="modal-card-foot">
401
+ <b-button type="is-primary" @click="handleLogin" :loading="isLoading">Login</b-button>
402
+ <b-button @click="showLoginModal = false">Cancel</b-button>
403
+ </footer>
404
+ </div>
405
+ </b-modal>
406
+
407
+ <!-- Signup Modal -->
408
+ <b-modal v-model="showSignupModal" has-modal-card trap-focus>
409
+ <div class="modal-card">
410
+ <header class="modal-card-head">
411
+ <p class="modal-card-title">Sign Up</p>
412
+ </header>
413
+ <section class="modal-card-body">
414
+ <b-field label="Username">
415
+ <b-input v-model="signupForm.username" placeholder="Enter username"></b-input>
416
+ </b-field>
417
+ <b-field label="Email">
418
+ <b-input type="email" v-model="signupForm.email" placeholder="Enter your email"></b-input>
419
+ </b-field>
420
+ <b-field label="Password">
421
+ <b-input type="password" v-model="signupForm.password" password-reveal></b-input>
422
+ </b-field>
423
+ <b-field label="Confirm Password">
424
+ <b-input type="password" v-model="signupForm.confirmPassword" password-reveal></b-input>
425
+ </b-field>
426
+ </section>
427
+ <footer class="modal-card-foot">
428
+ <b-button type="is-primary" @click="handleSignup" :loading="isLoading">Sign Up</b-button>
429
+ <b-button @click="showSignupModal = false">Cancel</b-button>
430
+ </footer>
431
+ </div>
432
+ </b-modal>
433
+
434
+ <!-- Admin Panel Modal -->
435
+ <b-modal v-model="showAdminPanel" has-modal-card trap-focus :width="640">
436
+ <div class="modal-card">
437
+ <header class="modal-card-head">
438
+ <p class="modal-card-title">User Management</p>
439
+ </header>
440
+ <section class="modal-card-body">
441
+ <div class="block">
442
+ <b-field grouped>
443
+ <b-input placeholder="Search users..." v-model="userSearchQuery" expanded></b-input>
444
+ <b-button type="is-primary" icon-left="magnify">Search</b-button>
445
+ </b-field>
446
+ </div>
447
+
448
+ <b-table
449
+ :data="filteredUsers"
450
+ :loading="loadingUsers"
451
+ :paginated="true"
452
+ :per-page="5"
453
+ :mobile-cards="true">
454
+
455
+ <b-table-column field="id" label="ID" width="40" numeric v-slot="props">
456
+ {{ props.row.id }}
457
+ </b-table-column>
458
+
459
+ <b-table-column field="username" label="Username" v-slot="props">
460
+ {{ props.row.username }}
461
+ </b-table-column>
462
+
463
+ <b-table-column field="email" label="Email" v-slot="props">
464
+ {{ props.row.email }}
465
+ </b-table-column>
466
+
467
+ <b-table-column field="is_admin" label="Admin" v-slot="props">
468
+ <b-icon
469
+ :icon="props.row.is_admin ? 'check' : 'close'"
470
+ :type="props.row.is_admin ? 'is-success' : 'is-danger'">
471
+ </b-icon>
472
+ </b-table-column>
473
+
474
+ <b-table-column field="disabled" label="Status" v-slot="props">
475
+ <b-tag :type="props.row.disabled ? 'is-danger' : 'is-success'">
476
+ {{ props.row.disabled ? 'Disabled' : 'Active' }}
477
+ </b-tag>
478
+ </b-table-column>
479
+
480
+ <b-table-column label="Actions" v-slot="props">
481
+ <div class="buttons">
482
+ <b-button
483
+ size="is-small"
484
+ :type="props.row.disabled ? 'is-success' : 'is-warning'"
485
+ :icon-left="props.row.disabled ? 'account-check' : 'account-cancel'"
486
+ @click="toggleUserStatus(props.row)"
487
+ :disabled="props.row.is_admin && props.row.id === currentUser.id">
488
+ {{ props.row.disabled ? 'Enable' : 'Disable' }}
489
+ </b-button>
490
+ <b-button
491
+ size="is-small"
492
+ type="is-danger"
493
+ icon-left="delete"
494
+ @click="deleteUser(props.row)"
495
+ :disabled="props.row.id === currentUser.id">
496
+ Delete
497
+ </b-button>
498
+ </div>
499
+ </b-table-column>
500
+
501
+ <template #empty>
502
+ <div class="has-text-centered">No users found</div>
503
+ </template>
504
+ </b-table>
505
+ </section>
506
+ <footer class="modal-card-foot">
507
+ <b-button @click="refreshUsers" type="is-info" icon-left="refresh">Refresh</b-button>
508
+ <b-button @click="showAdminPanel = false">Close</b-button>
509
+ </footer>
510
+ </div>
511
+ </b-modal>
512
+
513
+ </div><!-- #app -->
514
+
515
+ <script src="/static/js/howler.min.js"></script>
516
+ <script>
517
+ const { createApp, ref, onMounted } = Vue;
518
+ const app = createApp({
519
+ data() {
520
+ return {
521
+ responseFormat: 'verbose_json',
522
+ temperature: 0,
523
+ chunkSize: 10,
524
+ overlap: 5,
525
+ selection: [1,100],
526
+ systemPrompt: '',
527
+ isAuthenticated: false,
528
+ username: '',
529
+ token: '',
530
+ isLoading: false,
531
+ audioFile: [],
532
+ segments: [],
533
+ transcriptionText: '',
534
+ selectedLanguage: 'auto',
535
+ transcriptions: [],
536
+ showSidebar: true,
537
+ showPlayer: false,
538
+ showApiKeyModal: false,
539
+ audioUrl: null,
540
+ isPlaying: false,
541
+ audioPlayer: null,
542
+ isProcessing: false,
543
+ howl: null,
544
+ activeSegment: null,
545
+ isPlayingSegment: false,
546
+ isMuted: false,
547
+ volume: 0.5,
548
+ currentTime: 0,
549
+ totalDuration: 0,
550
+ seekInterval: null,
551
+ waveformData: null,
552
+ waveformCanvas: null,
553
+ ctx: null,
554
+ showLoginModal: false,
555
+ loginForm: {
556
+ username: '',
557
+ password: '',
558
+ },
559
+ showSignupModal: false,
560
+ signupForm: {
561
+ username: '',
562
+ email: '',
563
+ password: '',
564
+ confirmPassword: ''
565
+ },
566
+ // Admin panel data
567
+ showAdminPanel: false,
568
+ isAdmin: false,
569
+ currentUser: null,
570
+ users: [],
571
+ loadingUsers: false,
572
+ userSearchQuery: '',
573
+ // processingProgress: 0,
574
+ // totalChunks: 0,
575
+ // showVolumeSlider: false
576
+ }
577
+ },
578
+
579
+ methods: {
580
+ async login() {
581
+ this.isAuthenticated = true;
582
+ },
583
+
584
+ logout() {
585
+ this.token = '';
586
+ this.username = '';
587
+ this.isAuthenticated = false;
588
+ this.isAdmin = false;
589
+ this.currentUser = null;
590
+ this.users = [];
591
+ this.transcriptions = [];
592
+ localStorage.removeItem('token');
593
+
594
+ // Reset UI state
595
+ this.audioUrl = null;
596
+ this.audioFile = [];
597
+ this.segments = [];
598
+ this.transcriptionText = '';
599
+
600
+ // Show notification
601
+ this.$buefy.toast.open({
602
+ message: 'Successfully logged out!',
603
+ type: 'is-success'
604
+ });
605
+
606
+ // Redirect to home page if not already there
607
+ if (window.location.pathname !== '/') {
608
+ window.location.href = '/';
609
+ } else {
610
+ // window.location.reload();
611
+ }
612
+ },
613
+
614
+ async handleAudioUpload(event) {
615
+ if (!event.target.files || !event.target.files.length) return;
616
+
617
+ const file = event.target.files[0];
618
+ const fileUrl = URL.createObjectURL(file);
619
+ fileExt = file.name.split('.').splice(-1);
620
+
621
+ this.howl = new Howl({
622
+ src: [fileUrl],
623
+ format: this.fileExt, // This should be an array
624
+ html5: true,
625
+ onend: () => {
626
+ this.isPlayingSegment = false;
627
+ this.activeSegment = null;
628
+ }
629
+ });
630
+
631
+ this.audioUrl = fileUrl;
632
+ this.audioFile = [file];
633
+ },
634
+
635
+ initializeAudio() {
636
+ if (this.howl) {
637
+ this.howl.unload();
638
+ }
639
+
640
+ const fileExt = this.audioFile[0].name.split('.').splice(-1);
641
+ this.howl = new Howl({
642
+ src: [this.audioUrl],
643
+ format: fileExt,
644
+ html5: true,
645
+ volume: this.volume,
646
+ sprite: this.createSprites(),
647
+ onplay: () => {
648
+ this.isPlaying = true;
649
+ this.startSeekUpdate();
650
+ },
651
+ onpause: () => {
652
+ this.isPlaying = false;
653
+ this.stopSeekUpdate();
654
+ },
655
+ onstop: () => {
656
+ this.isPlaying = false;
657
+ this.currentTime = 0;
658
+ this.stopSeekUpdate();
659
+ },
660
+ onend: () => {
661
+ this.isPlaying = false;
662
+ this.currentTime = 0;
663
+ this.stopSeekUpdate();
664
+ },
665
+ onload: () => {
666
+ this.totalDuration = this.howl.duration();
667
+ /*/ Set selection to the length of the audio file
668
+ this.selection = [];
669
+ for (let i = 0; i < this.totalDuration; i += this.chunkSize) {
670
+ this.selection.push(i);
671
+ }*/
672
+ }
673
+ });
674
+ },
675
+
676
+ async initializeWaveform() {
677
+ if (!this.audioUrl) return;
678
+
679
+ // Load audio file and decode it
680
+ const response = await fetch(this.audioUrl);
681
+ const arrayBuffer = await response.arrayBuffer();
682
+ const audioContext = new AudioContext();
683
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
684
+
685
+ // Get waveform data
686
+ const channelData = audioBuffer.getChannelData(0);
687
+ this.waveformData = this.processWaveformData(channelData);
688
+
689
+ // Draw waveform
690
+ this.$nextTick(() => {
691
+ this.drawWaveform();
692
+ });
693
+ },
694
+
695
+ processWaveformData(data) {
696
+ const step = Math.ceil(data.length / 1000);
697
+ const waveform = [];
698
+ for (let i = 0; i < data.length; i += step) {
699
+ const slice = data.slice(i, i + step);
700
+ // Use reducer instead of spread operator for large arrays
701
+ const max = slice.reduce((a, b) => Math.max(a, b), -Infinity);
702
+ const min = slice.reduce((a, b) => Math.min(a, b), Infinity);
703
+ waveform.push({ max, min });
704
+ }
705
+ return waveform;
706
+ },
707
+
708
+ drawWaveform() {
709
+ if (this.$refs.waveform){
710
+ const canvas = document.createElement('canvas');
711
+ const ctx = canvas.getContext('2d');
712
+ const width = this.$refs.waveform.offsetWidth;
713
+ const height = 100;
714
+
715
+ canvas.width = width;
716
+ canvas.height = height;
717
+ this.$refs.waveform.style.backgroundImage = `url(${canvas.toDataURL()})`;
718
+
719
+ ctx.fillStyle = '#3273dc';
720
+ this.waveformData.forEach((point, i) => {
721
+ const x = (i / this.waveformData.length) * width;
722
+ const y = (1 - point.max) * height / 2;
723
+ const h = (point.max - point.min) * height / 2;
724
+ ctx.fillRect(x, y, 1, h);
725
+ });
726
+ }
727
+ },
728
+
729
+ createSprites() {
730
+ const sprites = {};
731
+ this.segments.forEach((segment, index) => {
732
+ sprites[`segment_${index}`] = [segment.start * 1000, (segment.end - segment.start) * 1000];
733
+ });
734
+ return sprites;
735
+ },
736
+
737
+ togglePlay() {
738
+ if (this.isPlaying) {
739
+ this.howl.pause();
740
+ } else {
741
+ this.howl.play();
742
+ }
743
+ },
744
+
745
+ stopAudio() {
746
+ this.howl.stop();
747
+ },
748
+
749
+ updateVolume() {
750
+ if (this.volume <= 0) {
751
+ this.isMuted = true;
752
+ this.howl.mute(true);
753
+ } else if (this.isMuted && this.volume > 0) {
754
+ this.isMuted = false;
755
+ this.howl.mute(false);
756
+ }
757
+ this.howl.volume(this.volume);
758
+ },
759
+
760
+ toggleVolumeSlider() {
761
+ this.showVolumeSlider = !this.showVolumeSlider;
762
+ },
763
+
764
+ toggleMute() {
765
+ this.isMuted = !this.isMuted;
766
+ this.howl.mute(this.isMuted);
767
+ if (!this.isMuted && this.volume === 0) {
768
+ this.volume = 0.5;
769
+ }
770
+ },
771
+
772
+ playSegment(segment) {
773
+ const index = this.segments.indexOf(segment);
774
+ if (index !== -1) {
775
+ this.howl.play(`segment_${index}`);
776
+ }
777
+ },
778
+
779
+ startSeekUpdate() {
780
+ this.seekInterval = setInterval(() => {
781
+ this.currentTime = this.howl.seek() || 0;
782
+ }, 100);
783
+ },
784
+
785
+ stopSeekUpdate() {
786
+ clearInterval(this.seekInterval);
787
+ },
788
+
789
+ formatTime(seconds) {
790
+ const mins = Math.floor(seconds / 60);
791
+ const secs = Math.floor(seconds % 60);
792
+ return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
793
+ },
794
+
795
+ togglePlayPause() {
796
+ if (this.audioPlayer.paused) {
797
+ this.audioPlayer.play();
798
+ this.isPlaying = true;
799
+ } else {
800
+ this.audioPlayer.pause();
801
+ this.isPlaying = false;
802
+ }
803
+ },
804
+
805
+ playSegment(segment) {
806
+ if (!this.howl) return;
807
+
808
+ if (this.activeSegment === segment.id && this.isPlayingSegment) {
809
+ this.howl.pause();
810
+ this.isPlayingSegment = false;
811
+ return;
812
+ }
813
+
814
+ this.howl.seek(segment.start);
815
+ this.howl.play();
816
+ this.activeSegment = segment.id;
817
+ this.isPlayingSegment = true;
818
+
819
+ this.howl.once('end', () => {
820
+ this.isPlayingSegment = false;
821
+ this.activeSegment = null;
822
+ });
823
+ },
824
+
825
+ copySegmentText(text) {
826
+ navigator.clipboard.writeText(text).then(() => {
827
+ this.$buefy.toast.open({
828
+ message: 'Text copied to clipboard',
829
+ type: 'is-success',
830
+ position: 'is-bottom-right'
831
+ });
832
+ }).catch(() => {
833
+ this.$buefy.toast.open({
834
+ message: 'Failed to copy text',
835
+ type: 'is-danger',
836
+ position: 'is-bottom-right'
837
+ });
838
+ });
839
+ },
840
+
841
+ deleteSegment(index) {
842
+ this.segments.splice(index, 1);
843
+ this.updateTranscriptionText();
844
+ },
845
+
846
+ updateTranscriptionText() {
847
+ this.transcriptionText = this.segments
848
+ .map(segment => segment.text)
849
+ .join(' ');
850
+ },
851
+
852
+ formatTime(seconds) {
853
+ const mins = Math.floor(seconds / 60);
854
+ const secs = Math.floor(seconds % 60);
855
+ return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
856
+ },
857
+
858
+ async processTranscription() {
859
+ const formData = new FormData();
860
+ formData.append('file', this.audioFile[0]);
861
+ formData.append('response_format', this.responseFormat);
862
+ formData.append('temperature', this.temperature.toString());
863
+ formData.append('chunk_size', parseInt(this.chunkSize * 60).toString());
864
+ formData.append('overlap', this.overlap.toString());
865
+ formData.append('prompt', this.systemPrompt.toString());
866
+
867
+ // Add selection timestamps
868
+ const startTime = (this.selection[0] * this.totalDuration / 100).toFixed(2);
869
+ const endTime = (this.selection[1] * this.totalDuration / 100).toFixed(2);
870
+ formData.append('start_time', startTime);
871
+ formData.append('end_time', endTime);
872
+
873
+ if (this.selectedLanguage !== 'auto') {
874
+ formData.append('language', this.selectedLanguage);
875
+ }
876
+ try {
877
+ this.isProcessing = true;
878
+
879
+ /*/ Monitor the progress of the upload for user feedback (need to be updated for reading back response)
880
+ const xhr = new XMLHttpRequest();
881
+ xhr.upload.onloadstart = function (event) {
882
+ console.log('Upload started');
883
+ };
884
+
885
+ xhr.upload.onprogress = function (event) {
886
+ if (event.lengthComputable) {
887
+ const percentComplete = (event.loaded / event.total) * 100;
888
+ console.log(`Upload progress: ${percentComplete.toFixed(2)}%`);
889
+ }
890
+ };
891
+ xhr.upload.onload = function () {
892
+ console.log('Upload complete');
893
+ };
894
+ xhr.onerror = function () {
895
+ console.error('Error uploading file');
896
+ };
897
+
898
+ xhr.open('POST', '/api/upload', true);
899
+ xhr.send(formData);*/
900
+
901
+ const response = await fetch('/api/upload', {
902
+ method: 'POST',
903
+ headers: {
904
+ 'Authorization': `Bearer ${this.token}`
905
+ },
906
+ body: formData
907
+ });
908
+ if (!response.ok) {
909
+ const errorData = await response.json();
910
+ throw new Error(errorData.detail || 'Transcription failed');
911
+ }
912
+ const result = await response.json();
913
+
914
+ // Check for backend validation errors
915
+ if (result.metadata?.errors?.length) {
916
+ console.error('Chunk processing errors:', result.metadata.errors);
917
+ this.$buefy.snackbar.open({
918
+ message: `${result.metadata.errors.length} chunks failed to process`,
919
+ type: 'is-warning',
920
+ position: 'is-bottom-right'
921
+ });
922
+ }
923
+
924
+ if (this.responseFormat === 'verbose_json') {
925
+
926
+ // Validate segments
927
+ if (!result.segments) {
928
+ throw new Error('Server returned invalid segments format');
929
+ }
930
+
931
+ this.segments = result.segments;
932
+ this.updateTranscriptionText();
933
+ } else {
934
+ this.transcriptionText = result.text || result;
935
+ }
936
+
937
+ // this.totalChunks = result.metadata?.total_chunks || 0;
938
+ // this.processingProgress = ((processedChunks / this.totalChunks) * 100).toFixed(1);
939
+ } catch (error) {
940
+ console.error('Transcription error:', error);
941
+ this.$buefy.toast.open({
942
+ message: `Error: ${error.message}`,
943
+ type: 'is-danger',
944
+ position: 'is-bottom-right'
945
+ });
946
+ }
947
+ this.isProcessing = false;
948
+ this.showPlayer = true;
949
+ },
950
+
951
+ async loadTranscriptions() {
952
+ try {
953
+ // Check if token exists
954
+ if (!this.token) {
955
+ this.token = localStorage.getItem('token');
956
+ if (!this.token) {
957
+ // console.log('No authentication token found');
958
+ return;
959
+ }
960
+ }
961
+
962
+ const response = await fetch('/api/transcriptions', {
963
+ headers: {
964
+ 'Authorization': `Bearer ${this.token}`
965
+ }
966
+ });
967
+
968
+ if (response.status === 401) {
969
+ // Token expired or invalid
970
+ console.log('Authentication token expired or invalid');
971
+ this.logout();
972
+ this.$buefy.toast.open({
973
+ message: 'Your session has expired. Please login again.',
974
+ type: 'is-warning'
975
+ });
976
+ return;
977
+ }
978
+
979
+ if (!response.ok) {
980
+ throw new Error(`HTTP error! Status: ${response.status}`);
981
+ }
982
+
983
+ this.transcriptions = await response.json();
984
+ } catch (error) {
985
+ console.error('Error loading transcriptions:', error);
986
+ this.$buefy.toast.open({
987
+ message: `Failed to load transcriptions: ${error.message}`,
988
+ type: 'is-danger'
989
+ });
990
+ }
991
+ },
992
+
993
+ async loadTranscription(transcription) {
994
+ try {
995
+ const response = await fetch(`/api/transcriptions/${transcription.id}`, {
996
+ headers: {
997
+ 'Authorization': `Bearer ${this.token}`
998
+ }
999
+ });
1000
+ const data = await response.json();
1001
+ // Set the audio file for playback
1002
+ this.transcriptionText = data['text'];
1003
+ this.segments = data['segments'];
1004
+ // this.audioFile = data['audio_file'];
1005
+ if (data.audio_file) {
1006
+ // this.audioUrl = `/uploads/${data['audio_file']}`;
1007
+ // this.audioFile.push(data['audio_file']);
1008
+ // this.initializeAudio();
1009
+ }
1010
+ } catch (error) {
1011
+ console.error('Error loading transcription:', error);
1012
+ }
1013
+ },
1014
+
1015
+ async saveTranscription() {
1016
+ if (this.audioFile.length == 0){
1017
+ this.$buefy.toast.open({
1018
+ message: 'Please upload an audio file first',
1019
+ type: 'is-warning',
1020
+ position: 'is-bottom-right'
1021
+ });
1022
+ return;
1023
+ }
1024
+ try {
1025
+ this.isProcessing = true;
1026
+ const response = await fetch('/api/save-transcription', {
1027
+ method: 'POST',
1028
+ headers: {
1029
+ 'Content-Type': 'application/json',
1030
+ 'Authorization': `Bearer ${this.token}`
1031
+ },
1032
+ body: JSON.stringify({
1033
+ text: this.transcriptionText,
1034
+ segments: this.segments,
1035
+ audio_file: this.audioFile[0].name
1036
+ })
1037
+ });
1038
+
1039
+ if (!response.ok) throw new Error('Failed to save transcription');
1040
+
1041
+ await this.loadTranscriptions();
1042
+ this.$buefy.toast.open({
1043
+ message: 'Transcription saved successfully',
1044
+ type: 'is-success',
1045
+ position: 'is-bottom-right'
1046
+ });
1047
+ } catch (error) {
1048
+ console.error('Error saving transcription:', error);
1049
+ this.$buefy.toast.open({
1050
+ message: `Error: ${error.message}`,
1051
+ type: 'is-danger',
1052
+ position: 'is-bottom-right'
1053
+ });
1054
+ }
1055
+ this.isProcessing = false;
1056
+ },
1057
+
1058
+ toggleSidebar() {
1059
+ this.showSidebar = !this.showSidebar;
1060
+ },
1061
+ togglePlayer() {
1062
+ this.showPlayer = !this.showPlayer;
1063
+ },
1064
+ async deleteTranscription(id) {
1065
+ try {
1066
+ // Show confirmation dialog
1067
+ this.$buefy.dialog.confirm({
1068
+ title: 'Delete Transcription',
1069
+ message: 'Are you sure you want to delete this transcription? This action cannot be undone.',
1070
+ confirmText: 'Delete',
1071
+ type: 'is-danger',
1072
+ hasIcon: true,
1073
+ onConfirm: async () => {
1074
+ const response = await fetch(`/api/transcriptions/${id}`, {
1075
+ method: 'DELETE',
1076
+ headers: {
1077
+ 'Authorization': `Bearer ${this.token}`
1078
+ }
1079
+ });
1080
+
1081
+ if (!response.ok) throw new Error('Failed to delete transcription');
1082
+
1083
+ // Remove from local list
1084
+ this.transcriptions = this.transcriptions.filter(t => t.id !== id);
1085
+
1086
+ // Show success message
1087
+ this.$buefy.toast.open({
1088
+ message: 'Transcription deleted successfully',
1089
+ type: 'is-success',
1090
+ position: 'is-bottom-right'
1091
+ });
1092
+ }
1093
+ });
1094
+ } catch (error) {
1095
+ console.error('Error deleting transcription:', error);
1096
+ this.$buefy.toast.open({
1097
+ message: `Error: ${error.message}`,
1098
+ type: 'is-danger',
1099
+ position: 'is-bottom-right'
1100
+ });
1101
+ }
1102
+ },
1103
+
1104
+ async handleLogin() {
1105
+ try {
1106
+ this.isLoading = true;
1107
+ const formData = new FormData();
1108
+ formData.append('username', this.loginForm.username); // Make sure this matches the backend expectation
1109
+ formData.append('password', this.loginForm.password);
1110
+
1111
+ const response = await fetch('/token', {
1112
+ method: 'POST',
1113
+ body: formData
1114
+ });
1115
+
1116
+ if (!response.ok) {
1117
+ const errorData = await response.json();
1118
+ throw new Error(errorData.detail || 'Login failed');
1119
+ }
1120
+
1121
+ const data = await response.json();
1122
+ this.token = data.access_token;
1123
+ localStorage.setItem('token', data.access_token);
1124
+
1125
+ // Get user info
1126
+ const userResponse = await fetch('/api/me', {
1127
+ headers: {
1128
+ 'Authorization': `Bearer ${this.token}`
1129
+ }
1130
+ });
1131
+
1132
+ if (!userResponse.ok) {
1133
+ throw new Error('Failed to get user information');
1134
+ }
1135
+
1136
+ const userData = await userResponse.json();
1137
+ this.username = userData.username;
1138
+ this.isAuthenticated = true;
1139
+
1140
+ this.showLoginModal = false;
1141
+ this.$buefy.toast.open({
1142
+ message: 'Successfully logged in!',
1143
+ type: 'is-success'
1144
+ });
1145
+
1146
+ // Load user data after successful login
1147
+ this.loadTranscriptions();
1148
+ } catch (error) {
1149
+ this.$buefy.toast.open({
1150
+ message: `Error: ${error.message}`,
1151
+ type: 'is-danger'
1152
+ });
1153
+ } finally {
1154
+ this.isLoading = false;
1155
+ }
1156
+ },
1157
+
1158
+ async handleSignup() {
1159
+ try {
1160
+ this.isLoading = true;
1161
+ if (this.signupForm.password !== this.signupForm.confirmPassword) {
1162
+ throw new Error('Passwords do not match');
1163
+ }
1164
+
1165
+ const response = await fetch('/api/signup', {
1166
+ method: 'POST',
1167
+ headers: {
1168
+ 'Content-Type': 'application/json'
1169
+ },
1170
+ body: JSON.stringify({
1171
+ username: this.signupForm.username,
1172
+ email: this.signupForm.email,
1173
+ password: this.signupForm.password
1174
+ })
1175
+ });
1176
+
1177
+ if (!response.ok) {
1178
+ const error = await response.json();
1179
+ throw new Error(error.detail || 'Signup failed');
1180
+ }
1181
+
1182
+ this.showSignupModal = false;
1183
+ this.$buefy.toast.open({
1184
+ message: 'Account created successfully! Please login.',
1185
+ type: 'is-success'
1186
+ });
1187
+
1188
+ // Clear form
1189
+ this.signupForm = {
1190
+ username: '',
1191
+ email: '',
1192
+ password: '',
1193
+ confirmPassword: ''
1194
+ };
1195
+
1196
+ // Show login modal
1197
+ this.showLoginModal = true;
1198
+ } catch (error) {
1199
+ this.$buefy.toast.open({
1200
+ message: `Error: ${error.message}`,
1201
+ type: 'is-danger'
1202
+ });
1203
+ } finally {
1204
+ this.isLoading = false;
1205
+ }
1206
+ },
1207
+
1208
+ async checkAuth() {
1209
+ const token = localStorage.getItem('token');
1210
+ if (token) {
1211
+ try {
1212
+ this.token = token;
1213
+ const response = await fetch('/api/me', {
1214
+ headers: {
1215
+ 'Authorization': `Bearer ${token}`
1216
+ }
1217
+ });
1218
+
1219
+ if (response.ok) {
1220
+ const userData = await response.json();
1221
+ this.username = userData.username;
1222
+ this.isAuthenticated = true;
1223
+ this.isAdmin = userData.is_admin;
1224
+ this.currentUser = userData;
1225
+
1226
+ // If user is admin, load users list
1227
+ if (this.isAdmin) {
1228
+ this.loadUsers();
1229
+ }
1230
+ } else {
1231
+ // Token invalid or expired
1232
+ this.logout();
1233
+ }
1234
+ } catch (error) {
1235
+ console.error('Auth check failed:', error);
1236
+ this.logout();
1237
+ }
1238
+ }
1239
+ },
1240
+
1241
+ // Add these methods to the methods section
1242
+ async loadUsers() {
1243
+ if (!this.isAdmin) return;
1244
+
1245
+ try {
1246
+ this.loadingUsers = true;
1247
+ const response = await fetch('/api/users', {
1248
+ headers: {
1249
+ 'Authorization': `Bearer ${this.token}`
1250
+ }
1251
+ });
1252
+
1253
+ if (!response.ok) {
1254
+ throw new Error('Failed to load users');
1255
+ }
1256
+
1257
+ this.users = await response.json();
1258
+ } catch (error) {
1259
+ console.error('Error loading users:', error);
1260
+ this.$buefy.toast.open({
1261
+ message: `Error: ${error.message}`,
1262
+ type: 'is-danger'
1263
+ });
1264
+ } finally {
1265
+ this.loadingUsers = false;
1266
+ }
1267
+ },
1268
+
1269
+ async toggleUserStatus(user) {
1270
+ try {
1271
+ // Don't allow admins to disable themselves
1272
+ if (user.is_admin && user.id === this.currentUser.id && !user.disabled) {
1273
+ this.$buefy.toast.open({
1274
+ message: 'Admins cannot disable their own accounts',
1275
+ type: 'is-warning'
1276
+ });
1277
+ return;
1278
+ }
1279
+
1280
+ const action = user.disabled ? 'enable' : 'disable';
1281
+ const response = await fetch(`/api/users/${user.id}/${action}`, {
1282
+ method: 'PUT',
1283
+ headers: {
1284
+ 'Authorization': `Bearer ${this.token}`,
1285
+ 'Content-Type': 'application/json'
1286
+ }
1287
+ });
1288
+
1289
+ if (!response.ok) {
1290
+ const errorData = await response.json();
1291
+ throw new Error(errorData.detail || `Failed to ${action} user`);
1292
+ }
1293
+
1294
+ // Update local user data
1295
+ user.disabled = !user.disabled;
1296
+
1297
+ this.$buefy.toast.open({
1298
+ message: `User ${user.username} ${action}d successfully`,
1299
+ type: 'is-success'
1300
+ });
1301
+ } catch (error) {
1302
+ console.error(`Error ${user.disabled ? 'enabling' : 'disabling'} user:`, error);
1303
+ this.$buefy.toast.open({
1304
+ message: `Error: ${error.message}`,
1305
+ type: 'is-danger'
1306
+ });
1307
+ }
1308
+ },
1309
+
1310
+ async deleteUser(user) {
1311
+ try {
1312
+ // Don't allow admins to delete themselves
1313
+ if (user.id === this.currentUser.id) {
1314
+ this.$buefy.toast.open({
1315
+ message: 'You cannot delete your own account',
1316
+ type: 'is-warning'
1317
+ });
1318
+ return;
1319
+ }
1320
+
1321
+ // Show confirmation dialog
1322
+ this.$buefy.dialog.confirm({
1323
+ title: 'Delete User',
1324
+ message: `Are you sure you want to delete user "${user.username}"? This action cannot be undone.`,
1325
+ confirmText: 'Delete',
1326
+ type: 'is-danger',
1327
+ hasIcon: true,
1328
+ onConfirm: async () => {
1329
+ const response = await fetch(`/api/users/${user.id}`, {
1330
+ method: 'DELETE',
1331
+ headers: {
1332
+ 'Authorization': `Bearer ${this.token}`
1333
+ }
1334
+ });
1335
+
1336
+ if (!response.ok) {
1337
+ const errorData = await response.json();
1338
+ throw new Error(errorData.detail || 'Failed to delete user');
1339
+ }
1340
+
1341
+ // Remove from local list
1342
+ this.users = this.users.filter(u => u.id !== user.id);
1343
+
1344
+ this.$buefy.toast.open({
1345
+ message: `User ${user.username} deleted successfully`,
1346
+ type: 'is-success'
1347
+ });
1348
+ }
1349
+ });
1350
+ } catch (error) {
1351
+ console.error('Error deleting user:', error);
1352
+ this.$buefy.toast.open({
1353
+ message: `Error: ${error.message}`,
1354
+ type: 'is-danger'
1355
+ });
1356
+ }
1357
+ },
1358
+
1359
+ refreshUsers() {
1360
+ this.loadUsers();
1361
+ },
1362
+ }, // methods
1363
+ // Add this to the Vue app definition, after methods
1364
+ computed: {
1365
+ filteredUsers() {
1366
+ if (!this.userSearchQuery) {
1367
+ return this.users;
1368
+ }
1369
+
1370
+ const query = this.userSearchQuery.toLowerCase();
1371
+ return this.users.filter(user =>
1372
+ user.username.toLowerCase().includes(query) ||
1373
+ user.email.toLowerCase().includes(query)
1374
+ );
1375
+ }
1376
+ },
1377
+ mounted() {
1378
+ this.checkAuth();
1379
+ this.loadTranscriptions();
1380
+ },
1381
+ watch: {
1382
+ audioUrl() {
1383
+ if (this.audioUrl) {
1384
+ this.initializeAudio();
1385
+ this.initializeWaveform();
1386
+ }
1387
+ },
1388
+ showAdminPanel(newVal) {
1389
+ if (newVal && this.isAdmin) {
1390
+ this.loadUsers();
1391
+ }
1392
+ }
1393
+ },
1394
+ beforeUnmount() {
1395
+ if (this.howl) {
1396
+ this.howl.unload();
1397
+ }
1398
+ this.stopSeekUpdate();
1399
+ }
1400
+ });
1401
+
1402
+ app.use(Buefy.default);
1403
+ app.mount('#app');
1404
+ </script>
1405
+ </body>
1406
+ </html>
main.py ADDED
@@ -0,0 +1,877 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, io, uuid, json, wave
2
+ import aiohttp
3
+ import aiofiles
4
+ import asyncio
5
+ import sqlite3
6
+ import bcrypt
7
+ import tempfile
8
+ from fastapi import FastAPI, Request, Query, Form, UploadFile, File, HTTPException, Depends
9
+ from fastapi.responses import HTMLResponse, JSONResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
13
+ from datetime import datetime, timedelta
14
+ from jose import JWTError, jwt
15
+ from pydantic import BaseModel, EmailStr
16
+ from pydantic import confloat
17
+ # from pydub import AudioSegment # rely on "ffmpeg" package from the system and "ffmpeg-python" library
18
+ from pydub.silence import split_on_silence
19
+ import soundfile as sf
20
+ import numpy as np
21
+ from scipy import signal
22
+ from scipy.io import wavfile
23
+ from typing import Optional, List, Dict, Union
24
+ from dotenv import load_dotenv
25
+ from sqlalchemy import create_engine, Column, Integer, String, Boolean, ForeignKey
26
+ from sqlalchemy.ext.declarative import declarative_base
27
+ from sqlalchemy.orm import sessionmaker, Session
28
+
29
+ load_dotenv()
30
+
31
+ # Constants
32
+ MAX_DURATION = 3600 * 2 # 2 hours
33
+ #CHUNK_SIZE = 1024 * 1024 # 1MB chunks
34
+ MAX_FILE_SIZE = 25 * 1024 * 1024 # 25MB
35
+ system_prompt = """
36
+ You are a helpful assistant. Your task is to correct
37
+ any spelling discrepancies in the transcribed text. Only add necessary
38
+ punctuation such as periods, commas, and capitalization, and use only the
39
+ context provided.
40
+ """
41
+ names = []
42
+ extra_prompt = f"""
43
+ Make sure that the names of : {','.join(names)}
44
+ the following products are spelled correctly
45
+ """
46
+
47
+ # Authentication & Security
48
+ SECRET_KEY = os.getenv("SECRET_KEY", None) # Change in production
49
+ ALGORITHM = "HS256"
50
+ ACCESS_TOKEN_EXPIRE_MINUTES = 30
51
+
52
+ # Database setup
53
+ DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower() # Default to SQLite if not specified
54
+ DATABASE_URL = os.getenv("DATABASE_URL")
55
+ EMPTY_DB = os.getenv("EMPTY_DB", "false").lower() == "true" # Get EMPTY_DB flag
56
+
57
+ # Configure engine based on database type
58
+ if DB_TYPE == "postgresql":
59
+ if DATABASE_URL and DATABASE_URL.startswith("postgres://"):
60
+ DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)
61
+ # PostgreSQL configuration for production
62
+ engine = create_engine(
63
+ DATABASE_URL,
64
+ pool_size=5,
65
+ max_overflow=10,
66
+ pool_timeout=30,
67
+ pool_recycle=1800, # Recycle connections after 30 minutes
68
+ )
69
+ elif DB_TYPE == "sqlite":
70
+ # SQLite configuration for development
71
+ engine = create_engine(
72
+ DATABASE_URL,
73
+ connect_args={"check_same_thread": False}
74
+ )
75
+ else:
76
+ raise ValueError("Unsupported database type. Use 'sqlite' or 'postgresql'.")
77
+
78
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
79
+ Base = declarative_base()
80
+
81
+ # SQLAlchemy Models
82
+ # SQLAlchemy Models
83
+ class UserModel(Base):
84
+ __tablename__ = "users"
85
+
86
+ id = Column(Integer, primary_key=True, index=True)
87
+ username = Column(String, unique=True, index=True)
88
+ email = Column(String, unique=True, index=True)
89
+ hashed_password = Column(String)
90
+ disabled = Column(Boolean, default=True) # Default to disabled
91
+ is_admin = Column(Boolean, default=False) # Add admin field
92
+
93
+ # Create tables
94
+ Base.metadata.create_all(bind=engine)
95
+
96
+ # Dependency to get DB session
97
+ def get_db():
98
+ db = SessionLocal()
99
+ try:
100
+ yield db
101
+ finally:
102
+ db.close()
103
+
104
+ # Add these models
105
+ class UserBase(BaseModel):
106
+ username: str
107
+ email: EmailStr
108
+
109
+ class UserCreate(UserBase):
110
+ password: str
111
+
112
+ class User(UserBase):
113
+ id: int
114
+ disabled: bool = True # Default to disabled
115
+ is_admin: bool = False # Add admin field
116
+
117
+ class Config:
118
+ orm_mode = True
119
+
120
+ class Token(BaseModel):
121
+ access_token: str
122
+ token_type: str
123
+
124
+ class TokenData(BaseModel):
125
+ username: str | None = None
126
+
127
+ # Add this with other SQLAlchemy models
128
+ class TranscriptionModel(Base):
129
+ __tablename__ = "transcriptions"
130
+
131
+ id = Column(String, primary_key=True, index=True)
132
+ name = Column(String)
133
+ text = Column(String)
134
+ segments = Column(String) # Store JSON as string
135
+ audio_file = Column(String)
136
+ user_id = Column(Integer, ForeignKey("users.id"))
137
+ created_at = Column(String, default=lambda: datetime.utcnow().isoformat())
138
+
139
+ # Add this with other Pydantic models
140
+ class TranscriptionBase(BaseModel):
141
+ name: str
142
+ text: str
143
+ segments: Optional[List[Dict[str, Union[str, float]]]] = []
144
+ audio_file: Optional[str] = None
145
+
146
+ class TranscriptionCreate(TranscriptionBase):
147
+ pass
148
+
149
+ class Transcription(TranscriptionBase):
150
+ id: str
151
+ user_id: int
152
+ created_at: str
153
+
154
+ class Config:
155
+ orm_mode = True
156
+
157
+ # Add these utilities
158
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
159
+
160
+ # App
161
+ app = FastAPI()
162
+
163
+ CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",")
164
+
165
+ # Configure CORS
166
+ app.add_middleware(
167
+ CORSMiddleware,
168
+ allow_origins=CORS_ORIGINS,
169
+ allow_credentials=True,
170
+ allow_methods=["*"],
171
+ allow_headers=["*"],
172
+ expose_headers=["*"]
173
+ )
174
+
175
+ # Create necessary directories
176
+ os.makedirs("uploads", exist_ok=True)
177
+ # os.makedirs("transcriptions", exist_ok=True) # we use DB to store
178
+
179
+ # Mount static files
180
+ app.mount("/static", StaticFiles(directory="static"), name="static")
181
+ app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
182
+
183
+ # Initialize database
184
+ def init_db():
185
+ try:
186
+ print("Creating the database...")
187
+ # If EMPTY_DB is True, drop all tables and recreate them
188
+ if EMPTY_DB:
189
+ print("EMPTY_DB flag is set to True. Dropping all tables...")
190
+ Base.metadata.drop_all(bind=engine)
191
+ print("All tables dropped successfully.")
192
+ Base.metadata.create_all(bind=engine)
193
+ print("Database initialized successfully")
194
+ except Exception as e:
195
+ print(f"Database: {DB_TYPE} initialization error: {str(e)}")
196
+
197
+ def verify_password(plain_password, hashed_password):
198
+ try:
199
+ # Ensure both password and hash are in correct format
200
+ if isinstance(hashed_password, str):
201
+ hashed_password = hashed_password.encode('utf-8')
202
+ if isinstance(plain_password, str):
203
+ plain_password = plain_password.encode('utf-8')
204
+ return bcrypt.checkpw(plain_password, hashed_password)
205
+ except Exception as e:
206
+ print(f"Password verification error: {str(e)}")
207
+ return False
208
+
209
+ def get_password_hash(password):
210
+ if isinstance(password, str):
211
+ password = password.encode('utf-8')
212
+ return bcrypt.hashpw(password, bcrypt.gensalt()).decode('utf-8')
213
+
214
+ def get_user(db: Session, username: str):
215
+ user = db.query(UserModel).filter(UserModel.username == username).first()
216
+ if user:
217
+ return User(
218
+ id=user.id,
219
+ username=user.username,
220
+ email=user.email,
221
+ disabled=user.disabled,
222
+ is_admin=user.is_admin
223
+ )
224
+ return None
225
+
226
+ def authenticate_user(db: Session, username: str, password: str):
227
+ user = db.query(UserModel).filter(UserModel.username == username).first()
228
+ if not user:
229
+ return False
230
+ if not verify_password(password, user.hashed_password):
231
+ return False
232
+ return User(
233
+ id=user.id,
234
+ username=user.username,
235
+ email=user.email,
236
+ disabled=user.disabled,
237
+ is_admin=user.is_admin
238
+ )
239
+
240
+ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
241
+ credentials_exception = HTTPException(
242
+ status_code=401,
243
+ detail="Could not validate credentials",
244
+ headers={"WWW-Authenticate": "Bearer"},
245
+ )
246
+ try:
247
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
248
+ username: str = payload.get("sub")
249
+ if username is None:
250
+ raise credentials_exception
251
+ token_data = TokenData(username=username)
252
+ except JWTError:
253
+ raise credentials_exception
254
+ user = get_user(db, username=token_data.username)
255
+ if user is None:
256
+ raise credentials_exception
257
+ return user
258
+
259
+ async def get_current_active_admin(current_user: User = Depends(get_current_user)):
260
+ if current_user.disabled:
261
+ raise HTTPException(status_code=400, detail="Inactive user")
262
+ if not current_user.is_admin:
263
+ raise HTTPException(status_code=403, detail="Not enough permissions")
264
+ return current_user
265
+
266
+ @app.get("/api/users")
267
+ async def get_list_users(
268
+ current_user: User = Depends(get_current_active_admin),
269
+ db: Session = Depends(get_db)
270
+ ):
271
+ users = db.query(UserModel).all()
272
+ return [
273
+ {
274
+ "id": user.id,
275
+ "username": user.username,
276
+ "email": user.email,
277
+ "disabled": user.disabled,
278
+ "is_admin": user.is_admin
279
+ }
280
+ for user in users
281
+ ]
282
+
283
+ @app.put("/api/users/{user_id}/enable")
284
+ async def enable_user(
285
+ user_id: int,
286
+ current_user: User = Depends(get_current_active_admin),
287
+ db: Session = Depends(get_db)
288
+ ):
289
+ user = db.query(UserModel).filter(UserModel.id == user_id).first()
290
+ if not user:
291
+ raise HTTPException(status_code=404, detail="User not found")
292
+
293
+ # Prevent disabling the last admin
294
+ if user.is_admin and user.id == current_user.id:
295
+ admin_count = db.query(UserModel).filter(UserModel.is_admin == True, UserModel.disabled == False).count()
296
+ if admin_count <= 1:
297
+ raise HTTPException(status_code=400, detail="Cannot disable the last active admin")
298
+
299
+ user.disabled = False
300
+ db.commit()
301
+ return {"message": f"User {user.username} has been enabled"}
302
+
303
+ @app.put("/api/users/{user_id}/disable")
304
+ async def disable_user(
305
+ user_id: int,
306
+ current_user: User = Depends(get_current_active_admin),
307
+ db: Session = Depends(get_db)
308
+ ):
309
+ user = db.query(UserModel).filter(UserModel.id == user_id).first()
310
+ if not user:
311
+ raise HTTPException(status_code=404, detail="User not found")
312
+
313
+ # Prevent disabling yourself if you're an admin
314
+ if user.is_admin and user.id == current_user.id:
315
+ raise HTTPException(status_code=400, detail="Admins cannot disable themselves")
316
+
317
+ user.disabled = True
318
+ db.commit()
319
+ return {"message": f"User {user.username} has been disabled"}
320
+
321
+ @app.delete("/api/users/{user_id}")
322
+ async def delete_user(
323
+ user_id: int,
324
+ current_user: User = Depends(get_current_active_admin),
325
+ db: Session = Depends(get_db)
326
+ ):
327
+ # Prevent deleting yourself
328
+ if user_id == current_user.id:
329
+ raise HTTPException(status_code=400, detail="Users cannot delete their own accounts")
330
+
331
+ user = db.query(UserModel).filter(UserModel.id == user_id).first()
332
+ if not user:
333
+ raise HTTPException(status_code=404, detail="User not found")
334
+
335
+ # Get username before deletion for the response
336
+ username = user.username
337
+
338
+ # Delete the user
339
+ db.delete(user)
340
+ db.commit()
341
+
342
+ return {"message": f"User {username} has been deleted"}
343
+
344
+ def create_access_token(data: dict, expires_delta: timedelta | None = None):
345
+ to_encode = data.copy()
346
+ if expires_delta:
347
+ expire = datetime.utcnow() + expires_delta
348
+ else:
349
+ expire = datetime.utcnow() + timedelta(minutes=15)
350
+ to_encode.update({"exp": expire})
351
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
352
+ return encoded_jwt
353
+
354
+
355
+ ### FUNCTIONS
356
+
357
+ def convert_to_flac(input_file: str, output_file: str):
358
+ """
359
+ Convert audio to 16KHz mono FLAC format.
360
+ """
361
+ try:
362
+ # Read the audio file
363
+ data, samplerate = sf.read(input_file)
364
+
365
+ # Convert to mono if stereo
366
+ if len(data.shape) > 1:
367
+ data = np.mean(data, axis=1)
368
+
369
+ # Resample to 16KHz if needed
370
+ if samplerate != 16000:
371
+ ratio = 16000 / samplerate
372
+ data = signal.resample(data, int(len(data) * ratio))
373
+ samplerate = 16000
374
+
375
+ # Save as FLAC
376
+ sf.write(output_file, data, samplerate, format='FLAC')
377
+
378
+ except Exception as e:
379
+ raise HTTPException(500, f"Error converting to FLAC: {str(e)}")
380
+
381
+ ######## ROUTES
382
+
383
+ # Near the top where CORS is configured
384
+ app.add_middleware(
385
+ CORSMiddleware,
386
+ allow_origins=["*"], # For production, replace with specific domains
387
+ allow_credentials=True,
388
+ allow_methods=["*"],
389
+ allow_headers=["*"],
390
+ expose_headers=["*"]
391
+ )
392
+
393
+ # Also ensure your login form data is properly sent
394
+ @app.post("/token")
395
+ async def login_for_access_token(
396
+ form_data: OAuth2PasswordRequestForm = Depends(),
397
+ db: Session = Depends(get_db)
398
+ ):
399
+ try:
400
+ user = authenticate_user(db, form_data.username, form_data.password)
401
+ if not user:
402
+ raise HTTPException(
403
+ status_code=401,
404
+ detail="Incorrect username or password",
405
+ headers={"WWW-Authenticate": "Bearer"},
406
+ )
407
+ if user.disabled:
408
+ raise HTTPException(
409
+ status_code=400,
410
+ detail="User is disabled"
411
+ )
412
+ access_token = create_access_token(data={"sub": user.username})
413
+ return {"access_token": access_token, "token_type": "bearer"}
414
+ except Exception as e:
415
+ print(f"Login error: {str(e)}") # Add logging for debugging
416
+ raise HTTPException(
417
+ status_code=500,
418
+ detail=f"Internal server error: {str(e)}"
419
+ )
420
+
421
+ # @app.post("/token", response_model=Token)
422
+ # async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
423
+ # user = authenticate_user(db, form_data.username, form_data.password)
424
+ # if not user:
425
+ # raise HTTPException(
426
+ # status_code=401,
427
+ # detail="Incorrect username or password",
428
+ # headers={"WWW-Authenticate": "Bearer"},
429
+ # )
430
+ # access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
431
+ # access_token = create_access_token(
432
+ # data={"sub": user.username}, expires_delta=access_token_expires
433
+ # )
434
+ # return {"access_token": access_token, "token_type": "bearer"}
435
+
436
+ @app.post("/api/signup")
437
+ async def signup(user: UserCreate, db: Session = Depends(get_db)):
438
+ try:
439
+ # Check if username or email already exists
440
+ db_user = db.query(UserModel).filter(
441
+ (UserModel.username == user.username) | (UserModel.email == user.email)
442
+ ).first()
443
+
444
+ if db_user:
445
+ raise HTTPException(
446
+ status_code=400,
447
+ detail="Username or email already exists"
448
+ )
449
+
450
+ # Check if this is the first user
451
+ user_count = db.query(UserModel).count()
452
+ is_first_user = user_count == 0
453
+
454
+ # Create new user
455
+ hashed_password = get_password_hash(user.password)
456
+ db_user = UserModel(
457
+ username=user.username,
458
+ email=user.email,
459
+ hashed_password=hashed_password,
460
+ disabled=not is_first_user, # First user is enabled, others disabled
461
+ is_admin=is_first_user # First user is admin
462
+ )
463
+ db.add(db_user)
464
+ db.commit()
465
+ db.refresh(db_user)
466
+
467
+ return {"message": "User created successfully", "is_admin": is_first_user, "disabled": not is_first_user}
468
+ except HTTPException:
469
+ raise
470
+ except Exception as e:
471
+ db.rollback()
472
+ raise HTTPException(
473
+ status_code=500,
474
+ detail=f"An error occurred: {str(e)}"
475
+ )
476
+
477
+ # Modify the existing routes to require authentication
478
+ @app.get("/api/me", response_model=User)
479
+ async def read_users_me(current_user: User = Depends(get_current_user)):
480
+ return current_user
481
+
482
+ @app.get("/", response_class=HTMLResponse)
483
+ async def read_index():
484
+ async with aiofiles.open("index.html", mode="r") as f:
485
+ content = await f.read()
486
+ return HTMLResponse(content=content)
487
+
488
+ # Helper function to split audio into chunks
489
+ def split_audio_chunks(
490
+ file_path: str,
491
+ chunk_size: int = 600, # 10 minutes in seconds
492
+ overlap: int = 5 # 5 seconds overlap
493
+ ) -> List[Dict[str, Union[str, float]]]:
494
+ """
495
+ Split audio into chunks with overlap and save as FLAC files.
496
+ """
497
+ try:
498
+ # Check file size
499
+ file_size = os.path.getsize(file_path)
500
+ if file_size > MAX_FILE_SIZE:
501
+ print(f"Warning: File size ({file_size / (1024 * 1024):.2f}MB) exceeds maximum size limit ({MAX_FILE_SIZE / (1024 * 1024)}MB)")
502
+ else:
503
+ print(f"File size: {file_size / (1024 * 1024):.2f}MB")
504
+ # Check duration
505
+ # with wave.open(file_path, 'rb') as wav_file:
506
+ # duration = wav_file.getnframes() / wav_file.getframerate()
507
+ # if duration > MAX_DURATION:
508
+ # print(f"Warning: File duration ({duration:.2f}s) exceeds maximum duration limit ({MAX_DURATION:.2f}s)")
509
+ # else:
510
+ # print(f"File duration: {duration:.2f}s")
511
+
512
+ # Read audio file using soundfile (supports multiple formats)
513
+ audio_data, sample_rate = sf.read(file_path)
514
+
515
+ # Convert stereo to mono if needed
516
+ if len(audio_data.shape) > 1:
517
+ audio_data = np.mean(audio_data, axis=1)
518
+
519
+ # Calculate chunk size in samples
520
+ chunk_size_samples = chunk_size * sample_rate
521
+ overlap_samples = overlap * sample_rate
522
+
523
+ chunks = []
524
+ start = 0
525
+
526
+ while start < len(audio_data):
527
+ end = min(start + chunk_size_samples, len(audio_data))
528
+ chunk_data = audio_data[start:end]
529
+
530
+ # Save chunk as FLAC
531
+ chunk_path = f"{file_path}_chunk_{len(chunks)}.flac"
532
+ sf.write(chunk_path, chunk_data, sample_rate, format='FLAC')
533
+
534
+ chunks.append({
535
+ "path": chunk_path,
536
+ "start": start / sample_rate, # Start time in seconds
537
+ "end": end / sample_rate # End time in seconds
538
+ })
539
+
540
+ # Move start position with overlap
541
+ start += chunk_size_samples - overlap_samples
542
+
543
+ return chunks
544
+
545
+ except Exception as e:
546
+ raise HTTPException(500, f"Error splitting audio: {str(e)}")
547
+
548
+ # Process individual chunk
549
+ async def process_chunk(
550
+ chunk_path: str,
551
+ model: str,
552
+ language: str,
553
+ temperature: float,
554
+ response_format: str,
555
+ prompt: str,
556
+ chunk_offset: float,
557
+ semaphore: asyncio.Semaphore
558
+ ):
559
+ """
560
+ Process a single audio chunk with the transcription API.
561
+ """
562
+ async with semaphore:
563
+ try:
564
+ async with aiofiles.open(chunk_path, "rb") as f:
565
+ file_data = await f.read()
566
+
567
+ form_data = aiohttp.FormData()
568
+ form_data.add_field('file', file_data, filename=chunk_path)
569
+ form_data.add_field('model', model)
570
+ form_data.add_field('temperature', str(temperature))
571
+ form_data.add_field('response_format', response_format)
572
+ if language != "auto":
573
+ form_data.add_field('language', language)
574
+ if prompt:
575
+ form_data.add_field('prompt', prompt)
576
+
577
+ print(f"Processing chunk {chunk_path}... prompt: {prompt}")
578
+ async with aiohttp.ClientSession() as session:
579
+ async with session.post(
580
+ f"{os.getenv('OPENAI_BASE_URL')}/audio/transcriptions",
581
+ headers={'Authorization': f"Bearer {os.getenv('OPENAI_API_KEY')}"},
582
+ data=form_data
583
+ ) as response:
584
+ result = await response.json() if response_format != "text" else await response.text()
585
+
586
+ # Adjust timestamps for chunks
587
+ if response_format == "verbose_json":
588
+ for segment in result.get("segments", []):
589
+ segment["start"] += chunk_offset
590
+ segment["end"] += chunk_offset
591
+ return result
592
+
593
+ except Exception as e:
594
+ # Add detailed error logging
595
+ print(f"Error processing chunk {chunk_path}:")
596
+ print(f"Type: {type(e).__name__}")
597
+ print(f"Message: {str(e)}")
598
+ return {"error": str(e), "chunk": chunk_path}
599
+
600
+ # Combine results from all chunks
601
+ def combine_results(results: list, response_format: str):
602
+ """Combine results from all chunks with validation"""
603
+ final = {"text": "", "segments": []}
604
+ error_chunks = []
605
+
606
+ for idx, result in enumerate(results):
607
+ if "error" in result:
608
+ error_chunks.append({
609
+ "chunk": idx,
610
+ "error": result["error"]
611
+ })
612
+ continue
613
+
614
+ # Validate response structure
615
+ if response_format == "verbose_json":
616
+ if not isinstance(result.get("segments"), list):
617
+ error_chunks.append({
618
+ "chunk": idx,
619
+ "error": "Invalid segments format"
620
+ })
621
+ continue
622
+
623
+ # Validate segment timestamps
624
+ for segment in result.get("segments", []):
625
+ if not all(key in segment for key in ['start', 'end', 'text']):
626
+ error_chunks.append({
627
+ "chunk": idx,
628
+ "error": f"Invalid segment format in chunk {idx}"
629
+ })
630
+ break
631
+
632
+ final["segments"].extend(result.get("segments", []))
633
+
634
+ # Add debug information to response
635
+ final["metadata"] = {
636
+ "total_chunks": len(results),
637
+ "failed_chunks": len(error_chunks),
638
+ "errors": error_chunks
639
+ }
640
+
641
+ return final
642
+
643
+ @app.post("/api/upload")
644
+ async def create_upload_file(
645
+ current_user: User = Depends(get_current_user),
646
+ file: UploadFile = File(...),
647
+ model: str = Form(default="whisper-large-v3-turbo"),
648
+ language: str = Form(default="auto"),
649
+ temperature: float = Form(default=0.0),
650
+ response_format: str = Form(default="verbose_json"),
651
+ prompt: str = Form(default=""),
652
+ chunk_size: int = Form(default=600),
653
+ overlap: int = Form(default=5),
654
+ start_time: float = Form(default=0.0),
655
+ end_time: float = Form(default=None),
656
+ db: Session = Depends(get_db)
657
+ ):
658
+ try:
659
+ # Validate input
660
+ if not file.content_type.startswith('audio/'):
661
+ raise HTTPException(400, "Only audio files allowed")
662
+
663
+ # Save original file
664
+ temp_dir = tempfile.gettempdir()
665
+ # os.makedirs(temp_dir, exist_ok=True)
666
+ temp_path = os.path.join(temp_dir, file.filename)
667
+ temp_path = f"{temp_dir}/{file.filename}"
668
+
669
+ async with aiofiles.open(temp_path, "wb") as f:
670
+ await f.write(await file.read())
671
+
672
+ # Read the audio file
673
+ audio_data, sample_rate = sf.read(temp_path)
674
+
675
+ # Convert stereo to mono if needed
676
+ if len(audio_data.shape) > 1:
677
+ audio_data = np.mean(audio_data, axis=1)
678
+
679
+ # Calculate time boundaries in samples
680
+ start_sample = int(start_time * sample_rate)
681
+ end_sample = int(end_time * sample_rate) if end_time else len(audio_data)
682
+
683
+ # Validate boundaries
684
+ if start_sample >= len(audio_data):
685
+ raise HTTPException(400, "Start time exceeds audio duration")
686
+ if end_sample > len(audio_data):
687
+ end_sample = len(audio_data)
688
+ if start_sample >= end_sample:
689
+ raise HTTPException(400, "Invalid time range")
690
+
691
+ # Slice the audio data
692
+ audio_data = audio_data[start_sample:end_sample]
693
+
694
+ # Save the sliced audio
695
+ sliced_path = f"{temp_path}_sliced.flac"
696
+ sf.write(sliced_path, audio_data, sample_rate, format='FLAC')
697
+
698
+ # Split into FLAC chunks
699
+ chunks = split_audio_chunks(
700
+ sliced_path,
701
+ chunk_size=chunk_size,
702
+ overlap=overlap
703
+ )
704
+
705
+ # Process chunks concurrently
706
+ semaphore = asyncio.Semaphore(3)
707
+ tasks = [process_chunk(
708
+ chunk_path=chunk["path"],
709
+ model=model,
710
+ language=language,
711
+ temperature=temperature,
712
+ response_format=response_format,
713
+ prompt=prompt,
714
+ chunk_offset=chunk["start"] + start_time, # Adjust offset to include start_time
715
+ semaphore=semaphore
716
+ ) for chunk in chunks]
717
+
718
+ results = await asyncio.gather(*tasks)
719
+
720
+ # Combine results
721
+ combined = combine_results(results, response_format)
722
+
723
+ # Cleanup
724
+ for chunk in chunks:
725
+ os.remove(chunk["path"])
726
+ os.remove(temp_path)
727
+ os.remove(sliced_path)
728
+
729
+ return combined
730
+
731
+ except Exception as e:
732
+ # Cleanup in case of error
733
+ if 'temp_path' in locals() and os.path.exists(temp_path):
734
+ os.remove(temp_path)
735
+ if 'sliced_path' in locals() and os.path.exists(sliced_path):
736
+ os.remove(sliced_path)
737
+ raise HTTPException(500, str(e))
738
+
739
+ @app.post("/api/upload-audio")
740
+ async def upload_audio(
741
+ request: Request,
742
+ current_user: User = Depends(get_current_user),
743
+ db: Session = Depends(get_db)
744
+ ):
745
+ try:
746
+ form_data = await request.form()
747
+ file = form_data["file"]
748
+
749
+ file_id = str(uuid.uuid4())
750
+ filename = f"{file_id}_{file.filename}"
751
+ file_path = os.path.join("uploads", filename)
752
+ print(f"file_path: ${file_path}")
753
+
754
+ contents = await file.read()
755
+ async with aiofiles.open(file_path, "wb") as f:
756
+ await f.write(contents)
757
+
758
+ return JSONResponse({"filename": filename})
759
+ except Exception as e:
760
+ raise HTTPException(status_code=500, detail=str(e))
761
+
762
+ @app.get("/api/transcriptions")
763
+ async def get_transcriptions(
764
+ current_user: User = Depends(get_current_user),
765
+ db: Session = Depends(get_db)
766
+ ):
767
+ try:
768
+ # Query transcriptions from database instead of reading files
769
+ transcriptions = db.query(TranscriptionModel).filter(
770
+ TranscriptionModel.user_id == current_user.id
771
+ ).all()
772
+
773
+ return [
774
+ {
775
+ "id": t.id,
776
+ "name": t.name,
777
+ "text": t.text,
778
+ "segments": json.loads(t.segments) if t.segments else [],
779
+ "audio_file": t.audio_file
780
+ }
781
+ for t in transcriptions
782
+ ]
783
+ except Exception as e:
784
+ print(f"Error getting transcriptions: {str(e)}")
785
+ raise HTTPException(status_code=500, detail=str(e))
786
+
787
+ @app.post("/api/save-transcription")
788
+ async def save_transcription(
789
+ data: dict,
790
+ current_user: User = Depends(get_current_user),
791
+ db: Session = Depends(get_db)
792
+ ):
793
+ try:
794
+ trans_id = str(uuid.uuid4())
795
+
796
+ # Create new transcription in database
797
+ transcription = TranscriptionModel(
798
+ id=trans_id,
799
+ name=f"t{trans_id[:8]}",
800
+ text=data["text"],
801
+ segments=json.dumps(data.get("segments", [])),
802
+ audio_file=data.get("audio_file"),
803
+ user_id=current_user.id
804
+ )
805
+
806
+ db.add(transcription)
807
+ db.commit()
808
+
809
+ return {"status": "success", "id": trans_id}
810
+ except Exception as e:
811
+ db.rollback()
812
+ print(f"Error saving transcription: {str(e)}")
813
+ raise HTTPException(status_code=500, detail=str(e))
814
+
815
+ @app.get("/api/transcriptions/{trans_id}")
816
+ async def get_transcription(
817
+ trans_id: str,
818
+ current_user: User = Depends(get_current_user),
819
+ db: Session = Depends(get_db)
820
+ ):
821
+ try:
822
+ # Query from database instead of reading file
823
+ transcription = db.query(TranscriptionModel).filter(
824
+ TranscriptionModel.id == trans_id,
825
+ TranscriptionModel.user_id == current_user.id
826
+ ).first()
827
+
828
+ if not transcription:
829
+ raise HTTPException(status_code=404, detail="Transcription not found")
830
+
831
+ return {
832
+ "id": transcription.id,
833
+ "name": transcription.name,
834
+ "text": transcription.text,
835
+ "segments": json.loads(transcription.segments) if transcription.segments else [],
836
+ "audio_file": transcription.audio_file
837
+ }
838
+ except HTTPException:
839
+ raise
840
+ except Exception as e:
841
+ print(f"Error getting transcription: {str(e)}")
842
+ raise HTTPException(status_code=500, detail=str(e))
843
+
844
+ @app.delete("/api/transcriptions/{trans_id}")
845
+ async def delete_transcription(
846
+ trans_id: str,
847
+ current_user: User = Depends(get_current_user),
848
+ db: Session = Depends(get_db)
849
+ ):
850
+ try:
851
+ # Query and delete from database
852
+ transcription = db.query(TranscriptionModel).filter(
853
+ TranscriptionModel.id == trans_id,
854
+ TranscriptionModel.user_id == current_user.id
855
+ ).first()
856
+
857
+ if not transcription:
858
+ raise HTTPException(status_code=404, detail="Transcription not found")
859
+
860
+ db.delete(transcription)
861
+ db.commit()
862
+
863
+ return {"status": "success", "message": "Transcription deleted successfully"}
864
+ except HTTPException:
865
+ raise
866
+ except Exception as e:
867
+ db.rollback()
868
+ print(f"Error deleting transcription: {str(e)}")
869
+ raise HTTPException(status_code=500, detail=str(e))
870
+
871
+ @app.on_event("startup")
872
+ async def startup_event():
873
+ init_db()
874
+
875
+ if __name__ == "__main__":
876
+ import uvicorn
877
+ uvicorn.run(app, host="127.0.0.1", port=8000)
requirements.txt CHANGED
@@ -1,6 +1,7 @@
1
  python-multipart==0.0.9
2
  fastapi==0.111.0
3
  uvicorn==0.29.0
 
4
  typing_extensions==4.11.0
5
  pydantic==2.7.1
6
  aiohttp==3.11.11
 
1
  python-multipart==0.0.9
2
  fastapi==0.111.0
3
  uvicorn==0.29.0
4
+ gunicorn>=20.1.0
5
  typing_extensions==4.11.0
6
  pydantic==2.7.1
7
  aiohttp==3.11.11
static/.DS_Store ADDED
Binary file (6.15 kB). View file
 
static/css/buefy.min.css ADDED
The diff for this file is too large to render. See raw diff
 
static/css/materialdesignicons.css.map ADDED
The diff for this file is too large to render. See raw diff
 
static/css/materialdesignicons.min.css ADDED
The diff for this file is too large to render. See raw diff
 
static/favicon.ico ADDED
static/fonts/materialdesignicons-webfont.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:61e8aba5a4e981fe22cf7c8e8bcdbea00476e75c62c37f01bf7ee33361d68428
3
+ size 1307660
static/fonts/materialdesignicons-webfont.woff ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a5928a0d5c2f624e46f98d9b15c2f60045377f7c594dd78a1759132ea3b463eb
3
+ size 587984
static/fonts/materialdesignicons-webfont.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:662fefa8f2f8a95c18588d21774789c107c64e771cbe65a69af46291c4311afc
3
+ size 403216
static/js/buefy.min.js ADDED
The diff for this file is too large to render. See raw diff
 
static/js/howler.min.js ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /*! howler.js v2.2.4 | (c) 2013-2020, James Simpson of GoldFire Studios | MIT License | howlerjs.com */
2
+ !function(){"use strict";var e=function(){this.init()};e.prototype={init:function(){var e=this||n;return e._counter=1e3,e._html5AudioPool=[],e.html5PoolSize=10,e._codecs={},e._howls=[],e._muted=!1,e._volume=1,e._canPlayEvent="canplaythrough",e._navigator="undefined"!=typeof window&&window.navigator?window.navigator:null,e.masterGain=null,e.noAudio=!1,e.usingWebAudio=!0,e.autoSuspend=!0,e.ctx=null,e.autoUnlock=!0,e._setup(),e},volume:function(e){var o=this||n;if(e=parseFloat(e),o.ctx||_(),void 0!==e&&e>=0&&e<=1){if(o._volume=e,o._muted)return o;o.usingWebAudio&&o.masterGain.gain.setValueAtTime(e,n.ctx.currentTime);for(var t=0;t<o._howls.length;t++)if(!o._howls[t]._webAudio)for(var r=o._howls[t]._getSoundIds(),a=0;a<r.length;a++){var u=o._howls[t]._soundById(r[a]);u&&u._node&&(u._node.volume=u._volume*e)}return o}return o._volume},mute:function(e){var o=this||n;o.ctx||_(),o._muted=e,o.usingWebAudio&&o.masterGain.gain.setValueAtTime(e?0:o._volume,n.ctx.currentTime);for(var t=0;t<o._howls.length;t++)if(!o._howls[t]._webAudio)for(var r=o._howls[t]._getSoundIds(),a=0;a<r.length;a++){var u=o._howls[t]._soundById(r[a]);u&&u._node&&(u._node.muted=!!e||u._muted)}return o},stop:function(){for(var e=this||n,o=0;o<e._howls.length;o++)e._howls[o].stop();return e},unload:function(){for(var e=this||n,o=e._howls.length-1;o>=0;o--)e._howls[o].unload();return e.usingWebAudio&&e.ctx&&void 0!==e.ctx.close&&(e.ctx.close(),e.ctx=null,_()),e},codecs:function(e){return(this||n)._codecs[e.replace(/^x-/,"")]},_setup:function(){var e=this||n;if(e.state=e.ctx?e.ctx.state||"suspended":"suspended",e._autoSuspend(),!e.usingWebAudio)if("undefined"!=typeof Audio)try{var o=new Audio;void 0===o.oncanplaythrough&&(e._canPlayEvent="canplay")}catch(n){e.noAudio=!0}else e.noAudio=!0;try{var o=new Audio;o.muted&&(e.noAudio=!0)}catch(e){}return e.noAudio||e._setupCodecs(),e},_setupCodecs:function(){var e=this||n,o=null;try{o="undefined"!=typeof Audio?new Audio:null}catch(n){return e}if(!o||"function"!=typeof o.canPlayType)return e;var t=o.canPlayType("audio/mpeg;").replace(/^no$/,""),r=e._navigator?e._navigator.userAgent:"",a=r.match(/OPR\/(\d+)/g),u=a&&parseInt(a[0].split("/")[1],10)<33,d=-1!==r.indexOf("Safari")&&-1===r.indexOf("Chrome"),i=r.match(/Version\/(.*?) /),_=d&&i&&parseInt(i[1],10)<15;return e._codecs={mp3:!(u||!t&&!o.canPlayType("audio/mp3;").replace(/^no$/,"")),mpeg:!!t,opus:!!o.canPlayType('audio/ogg; codecs="opus"').replace(/^no$/,""),ogg:!!o.canPlayType('audio/ogg; codecs="vorbis"').replace(/^no$/,""),oga:!!o.canPlayType('audio/ogg; codecs="vorbis"').replace(/^no$/,""),wav:!!(o.canPlayType('audio/wav; codecs="1"')||o.canPlayType("audio/wav")).replace(/^no$/,""),aac:!!o.canPlayType("audio/aac;").replace(/^no$/,""),caf:!!o.canPlayType("audio/x-caf;").replace(/^no$/,""),m4a:!!(o.canPlayType("audio/x-m4a;")||o.canPlayType("audio/m4a;")||o.canPlayType("audio/aac;")).replace(/^no$/,""),m4b:!!(o.canPlayType("audio/x-m4b;")||o.canPlayType("audio/m4b;")||o.canPlayType("audio/aac;")).replace(/^no$/,""),mp4:!!(o.canPlayType("audio/x-mp4;")||o.canPlayType("audio/mp4;")||o.canPlayType("audio/aac;")).replace(/^no$/,""),weba:!(_||!o.canPlayType('audio/webm; codecs="vorbis"').replace(/^no$/,"")),webm:!(_||!o.canPlayType('audio/webm; codecs="vorbis"').replace(/^no$/,"")),dolby:!!o.canPlayType('audio/mp4; codecs="ec-3"').replace(/^no$/,""),flac:!!(o.canPlayType("audio/x-flac;")||o.canPlayType("audio/flac;")).replace(/^no$/,"")},e},_unlockAudio:function(){var e=this||n;if(!e._audioUnlocked&&e.ctx){e._audioUnlocked=!1,e.autoUnlock=!1,e._mobileUnloaded||44100===e.ctx.sampleRate||(e._mobileUnloaded=!0,e.unload()),e._scratchBuffer=e.ctx.createBuffer(1,1,22050);var o=function(n){for(;e._html5AudioPool.length<e.html5PoolSize;)try{var t=new Audio;t._unlocked=!0,e._releaseHtml5Audio(t)}catch(n){e.noAudio=!0;break}for(var r=0;r<e._howls.length;r++)if(!e._howls[r]._webAudio)for(var a=e._howls[r]._getSoundIds(),u=0;u<a.length;u++){var d=e._howls[r]._soundById(a[u]);d&&d._node&&!d._node._unlocked&&(d._node._unlocked=!0,d._node.load())}e._autoResume();var i=e.ctx.createBufferSource();i.buffer=e._scratchBuffer,i.connect(e.ctx.destination),void 0===i.start?i.noteOn(0):i.start(0),"function"==typeof e.ctx.resume&&e.ctx.resume(),i.onended=function(){i.disconnect(0),e._audioUnlocked=!0,document.removeEventListener("touchstart",o,!0),document.removeEventListener("touchend",o,!0),document.removeEventListener("click",o,!0),document.removeEventListener("keydown",o,!0);for(var n=0;n<e._howls.length;n++)e._howls[n]._emit("unlock")}};return document.addEventListener("touchstart",o,!0),document.addEventListener("touchend",o,!0),document.addEventListener("click",o,!0),document.addEventListener("keydown",o,!0),e}},_obtainHtml5Audio:function(){var e=this||n;if(e._html5AudioPool.length)return e._html5AudioPool.pop();var o=(new Audio).play();return o&&"undefined"!=typeof Promise&&(o instanceof Promise||"function"==typeof o.then)&&o.catch(function(){console.warn("HTML5 Audio pool exhausted, returning potentially locked audio object.")}),new Audio},_releaseHtml5Audio:function(e){var o=this||n;return e._unlocked&&o._html5AudioPool.push(e),o},_autoSuspend:function(){var e=this;if(e.autoSuspend&&e.ctx&&void 0!==e.ctx.suspend&&n.usingWebAudio){for(var o=0;o<e._howls.length;o++)if(e._howls[o]._webAudio)for(var t=0;t<e._howls[o]._sounds.length;t++)if(!e._howls[o]._sounds[t]._paused)return e;return e._suspendTimer&&clearTimeout(e._suspendTimer),e._suspendTimer=setTimeout(function(){if(e.autoSuspend){e._suspendTimer=null,e.state="suspending";var n=function(){e.state="suspended",e._resumeAfterSuspend&&(delete e._resumeAfterSuspend,e._autoResume())};e.ctx.suspend().then(n,n)}},3e4),e}},_autoResume:function(){var e=this;if(e.ctx&&void 0!==e.ctx.resume&&n.usingWebAudio)return"running"===e.state&&"interrupted"!==e.ctx.state&&e._suspendTimer?(clearTimeout(e._suspendTimer),e._suspendTimer=null):"suspended"===e.state||"running"===e.state&&"interrupted"===e.ctx.state?(e.ctx.resume().then(function(){e.state="running";for(var n=0;n<e._howls.length;n++)e._howls[n]._emit("resume")}),e._suspendTimer&&(clearTimeout(e._suspendTimer),e._suspendTimer=null)):"suspending"===e.state&&(e._resumeAfterSuspend=!0),e}};var n=new e,o=function(e){var n=this;if(!e.src||0===e.src.length)return void console.error("An array of source files must be passed with any new Howl.");n.init(e)};o.prototype={init:function(e){var o=this;return n.ctx||_(),o._autoplay=e.autoplay||!1,o._format="string"!=typeof e.format?e.format:[e.format],o._html5=e.html5||!1,o._muted=e.mute||!1,o._loop=e.loop||!1,o._pool=e.pool||5,o._preload="boolean"!=typeof e.preload&&"metadata"!==e.preload||e.preload,o._rate=e.rate||1,o._sprite=e.sprite||{},o._src="string"!=typeof e.src?e.src:[e.src],o._volume=void 0!==e.volume?e.volume:1,o._xhr={method:e.xhr&&e.xhr.method?e.xhr.method:"GET",headers:e.xhr&&e.xhr.headers?e.xhr.headers:null,withCredentials:!(!e.xhr||!e.xhr.withCredentials)&&e.xhr.withCredentials},o._duration=0,o._state="unloaded",o._sounds=[],o._endTimers={},o._queue=[],o._playLock=!1,o._onend=e.onend?[{fn:e.onend}]:[],o._onfade=e.onfade?[{fn:e.onfade}]:[],o._onload=e.onload?[{fn:e.onload}]:[],o._onloaderror=e.onloaderror?[{fn:e.onloaderror}]:[],o._onplayerror=e.onplayerror?[{fn:e.onplayerror}]:[],o._onpause=e.onpause?[{fn:e.onpause}]:[],o._onplay=e.onplay?[{fn:e.onplay}]:[],o._onstop=e.onstop?[{fn:e.onstop}]:[],o._onmute=e.onmute?[{fn:e.onmute}]:[],o._onvolume=e.onvolume?[{fn:e.onvolume}]:[],o._onrate=e.onrate?[{fn:e.onrate}]:[],o._onseek=e.onseek?[{fn:e.onseek}]:[],o._onunlock=e.onunlock?[{fn:e.onunlock}]:[],o._onresume=[],o._webAudio=n.usingWebAudio&&!o._html5,void 0!==n.ctx&&n.ctx&&n.autoUnlock&&n._unlockAudio(),n._howls.push(o),o._autoplay&&o._queue.push({event:"play",action:function(){o.play()}}),o._preload&&"none"!==o._preload&&o.load(),o},load:function(){var e=this,o=null;if(n.noAudio)return void e._emit("loaderror",null,"No audio support.");"string"==typeof e._src&&(e._src=[e._src]);for(var r=0;r<e._src.length;r++){var u,d;if(e._format&&e._format[r])u=e._format[r];else{if("string"!=typeof(d=e._src[r])){e._emit("loaderror",null,"Non-string found in selected audio sources - ignoring.");continue}u=/^data:audio\/([^;,]+);/i.exec(d),u||(u=/\.([^.]+)$/.exec(d.split("?",1)[0])),u&&(u=u[1].toLowerCase())}if(u||console.warn('No file extension was found. Consider using the "format" property or specify an extension.'),u&&n.codecs(u)){o=e._src[r];break}}return o?(e._src=o,e._state="loading","https:"===window.location.protocol&&"http:"===o.slice(0,5)&&(e._html5=!0,e._webAudio=!1),new t(e),e._webAudio&&a(e),e):void e._emit("loaderror",null,"No codec support for selected audio sources.")},play:function(e,o){var t=this,r=null;if("number"==typeof e)r=e,e=null;else{if("string"==typeof e&&"loaded"===t._state&&!t._sprite[e])return null;if(void 0===e&&(e="__default",!t._playLock)){for(var a=0,u=0;u<t._sounds.length;u++)t._sounds[u]._paused&&!t._sounds[u]._ended&&(a++,r=t._sounds[u]._id);1===a?e=null:r=null}}var d=r?t._soundById(r):t._inactiveSound();if(!d)return null;if(r&&!e&&(e=d._sprite||"__default"),"loaded"!==t._state){d._sprite=e,d._ended=!1;var i=d._id;return t._queue.push({event:"play",action:function(){t.play(i)}}),i}if(r&&!d._paused)return o||t._loadQueue("play"),d._id;t._webAudio&&n._autoResume();var _=Math.max(0,d._seek>0?d._seek:t._sprite[e][0]/1e3),s=Math.max(0,(t._sprite[e][0]+t._sprite[e][1])/1e3-_),l=1e3*s/Math.abs(d._rate),c=t._sprite[e][0]/1e3,f=(t._sprite[e][0]+t._sprite[e][1])/1e3;d._sprite=e,d._ended=!1;var p=function(){d._paused=!1,d._seek=_,d._start=c,d._stop=f,d._loop=!(!d._loop&&!t._sprite[e][2])};if(_>=f)return void t._ended(d);var m=d._node;if(t._webAudio){var v=function(){t._playLock=!1,p(),t._refreshBuffer(d);var e=d._muted||t._muted?0:d._volume;m.gain.setValueAtTime(e,n.ctx.currentTime),d._playStart=n.ctx.currentTime,void 0===m.bufferSource.start?d._loop?m.bufferSource.noteGrainOn(0,_,86400):m.bufferSource.noteGrainOn(0,_,s):d._loop?m.bufferSource.start(0,_,86400):m.bufferSource.start(0,_,s),l!==1/0&&(t._endTimers[d._id]=setTimeout(t._ended.bind(t,d),l)),o||setTimeout(function(){t._emit("play",d._id),t._loadQueue()},0)};"running"===n.state&&"interrupted"!==n.ctx.state?v():(t._playLock=!0,t.once("resume",v),t._clearTimer(d._id))}else{var h=function(){m.currentTime=_,m.muted=d._muted||t._muted||n._muted||m.muted,m.volume=d._volume*n.volume(),m.playbackRate=d._rate;try{var r=m.play();if(r&&"undefined"!=typeof Promise&&(r instanceof Promise||"function"==typeof r.then)?(t._playLock=!0,p(),r.then(function(){t._playLock=!1,m._unlocked=!0,o?t._loadQueue():t._emit("play",d._id)}).catch(function(){t._playLock=!1,t._emit("playerror",d._id,"Playback was unable to start. This is most commonly an issue on mobile devices and Chrome where playback was not within a user interaction."),d._ended=!0,d._paused=!0})):o||(t._playLock=!1,p(),t._emit("play",d._id)),m.playbackRate=d._rate,m.paused)return void t._emit("playerror",d._id,"Playback was unable to start. This is most commonly an issue on mobile devices and Chrome where playback was not within a user interaction.");"__default"!==e||d._loop?t._endTimers[d._id]=setTimeout(t._ended.bind(t,d),l):(t._endTimers[d._id]=function(){t._ended(d),m.removeEventListener("ended",t._endTimers[d._id],!1)},m.addEventListener("ended",t._endTimers[d._id],!1))}catch(e){t._emit("playerror",d._id,e)}};"data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA"===m.src&&(m.src=t._src,m.load());var y=window&&window.ejecta||!m.readyState&&n._navigator.isCocoonJS;if(m.readyState>=3||y)h();else{t._playLock=!0,t._state="loading";var g=function(){t._state="loaded",h(),m.removeEventListener(n._canPlayEvent,g,!1)};m.addEventListener(n._canPlayEvent,g,!1),t._clearTimer(d._id)}}return d._id},pause:function(e){var n=this;if("loaded"!==n._state||n._playLock)return n._queue.push({event:"pause",action:function(){n.pause(e)}}),n;for(var o=n._getSoundIds(e),t=0;t<o.length;t++){n._clearTimer(o[t]);var r=n._soundById(o[t]);if(r&&!r._paused&&(r._seek=n.seek(o[t]),r._rateSeek=0,r._paused=!0,n._stopFade(o[t]),r._node))if(n._webAudio){if(!r._node.bufferSource)continue;void 0===r._node.bufferSource.stop?r._node.bufferSource.noteOff(0):r._node.bufferSource.stop(0),n._cleanBuffer(r._node)}else isNaN(r._node.duration)&&r._node.duration!==1/0||r._node.pause();arguments[1]||n._emit("pause",r?r._id:null)}return n},stop:function(e,n){var o=this;if("loaded"!==o._state||o._playLock)return o._queue.push({event:"stop",action:function(){o.stop(e)}}),o;for(var t=o._getSoundIds(e),r=0;r<t.length;r++){o._clearTimer(t[r]);var a=o._soundById(t[r]);a&&(a._seek=a._start||0,a._rateSeek=0,a._paused=!0,a._ended=!0,o._stopFade(t[r]),a._node&&(o._webAudio?a._node.bufferSource&&(void 0===a._node.bufferSource.stop?a._node.bufferSource.noteOff(0):a._node.bufferSource.stop(0),o._cleanBuffer(a._node)):isNaN(a._node.duration)&&a._node.duration!==1/0||(a._node.currentTime=a._start||0,a._node.pause(),a._node.duration===1/0&&o._clearSound(a._node))),n||o._emit("stop",a._id))}return o},mute:function(e,o){var t=this;if("loaded"!==t._state||t._playLock)return t._queue.push({event:"mute",action:function(){t.mute(e,o)}}),t;if(void 0===o){if("boolean"!=typeof e)return t._muted;t._muted=e}for(var r=t._getSoundIds(o),a=0;a<r.length;a++){var u=t._soundById(r[a]);u&&(u._muted=e,u._interval&&t._stopFade(u._id),t._webAudio&&u._node?u._node.gain.setValueAtTime(e?0:u._volume,n.ctx.currentTime):u._node&&(u._node.muted=!!n._muted||e),t._emit("mute",u._id))}return t},volume:function(){var e,o,t=this,r=arguments;if(0===r.length)return t._volume;if(1===r.length||2===r.length&&void 0===r[1]){t._getSoundIds().indexOf(r[0])>=0?o=parseInt(r[0],10):e=parseFloat(r[0])}else r.length>=2&&(e=parseFloat(r[0]),o=parseInt(r[1],10));var a;if(!(void 0!==e&&e>=0&&e<=1))return a=o?t._soundById(o):t._sounds[0],a?a._volume:0;if("loaded"!==t._state||t._playLock)return t._queue.push({event:"volume",action:function(){t.volume.apply(t,r)}}),t;void 0===o&&(t._volume=e),o=t._getSoundIds(o);for(var u=0;u<o.length;u++)(a=t._soundById(o[u]))&&(a._volume=e,r[2]||t._stopFade(o[u]),t._webAudio&&a._node&&!a._muted?a._node.gain.setValueAtTime(e,n.ctx.currentTime):a._node&&!a._muted&&(a._node.volume=e*n.volume()),t._emit("volume",a._id));return t},fade:function(e,o,t,r){var a=this;if("loaded"!==a._state||a._playLock)return a._queue.push({event:"fade",action:function(){a.fade(e,o,t,r)}}),a;e=Math.min(Math.max(0,parseFloat(e)),1),o=Math.min(Math.max(0,parseFloat(o)),1),t=parseFloat(t),a.volume(e,r);for(var u=a._getSoundIds(r),d=0;d<u.length;d++){var i=a._soundById(u[d]);if(i){if(r||a._stopFade(u[d]),a._webAudio&&!i._muted){var _=n.ctx.currentTime,s=_+t/1e3;i._volume=e,i._node.gain.setValueAtTime(e,_),i._node.gain.linearRampToValueAtTime(o,s)}a._startFadeInterval(i,e,o,t,u[d],void 0===r)}}return a},_startFadeInterval:function(e,n,o,t,r,a){var u=this,d=n,i=o-n,_=Math.abs(i/.01),s=Math.max(4,_>0?t/_:t),l=Date.now();e._fadeTo=o,e._interval=setInterval(function(){var r=(Date.now()-l)/t;l=Date.now(),d+=i*r,d=Math.round(100*d)/100,d=i<0?Math.max(o,d):Math.min(o,d),u._webAudio?e._volume=d:u.volume(d,e._id,!0),a&&(u._volume=d),(o<n&&d<=o||o>n&&d>=o)&&(clearInterval(e._interval),e._interval=null,e._fadeTo=null,u.volume(o,e._id),u._emit("fade",e._id))},s)},_stopFade:function(e){var o=this,t=o._soundById(e);return t&&t._interval&&(o._webAudio&&t._node.gain.cancelScheduledValues(n.ctx.currentTime),clearInterval(t._interval),t._interval=null,o.volume(t._fadeTo,e),t._fadeTo=null,o._emit("fade",e)),o},loop:function(){var e,n,o,t=this,r=arguments;if(0===r.length)return t._loop;if(1===r.length){if("boolean"!=typeof r[0])return!!(o=t._soundById(parseInt(r[0],10)))&&o._loop;e=r[0],t._loop=e}else 2===r.length&&(e=r[0],n=parseInt(r[1],10));for(var a=t._getSoundIds(n),u=0;u<a.length;u++)(o=t._soundById(a[u]))&&(o._loop=e,t._webAudio&&o._node&&o._node.bufferSource&&(o._node.bufferSource.loop=e,e&&(o._node.bufferSource.loopStart=o._start||0,o._node.bufferSource.loopEnd=o._stop,t.playing(a[u])&&(t.pause(a[u],!0),t.play(a[u],!0)))));return t},rate:function(){var e,o,t=this,r=arguments;if(0===r.length)o=t._sounds[0]._id;else if(1===r.length){var a=t._getSoundIds(),u=a.indexOf(r[0]);u>=0?o=parseInt(r[0],10):e=parseFloat(r[0])}else 2===r.length&&(e=parseFloat(r[0]),o=parseInt(r[1],10));var d;if("number"!=typeof e)return d=t._soundById(o),d?d._rate:t._rate;if("loaded"!==t._state||t._playLock)return t._queue.push({event:"rate",action:function(){t.rate.apply(t,r)}}),t;void 0===o&&(t._rate=e),o=t._getSoundIds(o);for(var i=0;i<o.length;i++)if(d=t._soundById(o[i])){t.playing(o[i])&&(d._rateSeek=t.seek(o[i]),d._playStart=t._webAudio?n.ctx.currentTime:d._playStart),d._rate=e,t._webAudio&&d._node&&d._node.bufferSource?d._node.bufferSource.playbackRate.setValueAtTime(e,n.ctx.currentTime):d._node&&(d._node.playbackRate=e);var _=t.seek(o[i]),s=(t._sprite[d._sprite][0]+t._sprite[d._sprite][1])/1e3-_,l=1e3*s/Math.abs(d._rate);!t._endTimers[o[i]]&&d._paused||(t._clearTimer(o[i]),t._endTimers[o[i]]=setTimeout(t._ended.bind(t,d),l)),t._emit("rate",d._id)}return t},seek:function(){var e,o,t=this,r=arguments;if(0===r.length)t._sounds.length&&(o=t._sounds[0]._id);else if(1===r.length){var a=t._getSoundIds(),u=a.indexOf(r[0]);u>=0?o=parseInt(r[0],10):t._sounds.length&&(o=t._sounds[0]._id,e=parseFloat(r[0]))}else 2===r.length&&(e=parseFloat(r[0]),o=parseInt(r[1],10));if(void 0===o)return 0;if("number"==typeof e&&("loaded"!==t._state||t._playLock))return t._queue.push({event:"seek",action:function(){t.seek.apply(t,r)}}),t;var d=t._soundById(o);if(d){if(!("number"==typeof e&&e>=0)){if(t._webAudio){var i=t.playing(o)?n.ctx.currentTime-d._playStart:0,_=d._rateSeek?d._rateSeek-d._seek:0;return d._seek+(_+i*Math.abs(d._rate))}return d._node.currentTime}var s=t.playing(o);s&&t.pause(o,!0),d._seek=e,d._ended=!1,t._clearTimer(o),t._webAudio||!d._node||isNaN(d._node.duration)||(d._node.currentTime=e);var l=function(){s&&t.play(o,!0),t._emit("seek",o)};if(s&&!t._webAudio){var c=function(){t._playLock?setTimeout(c,0):l()};setTimeout(c,0)}else l()}return t},playing:function(e){var n=this;if("number"==typeof e){var o=n._soundById(e);return!!o&&!o._paused}for(var t=0;t<n._sounds.length;t++)if(!n._sounds[t]._paused)return!0;return!1},duration:function(e){var n=this,o=n._duration,t=n._soundById(e);return t&&(o=n._sprite[t._sprite][1]/1e3),o},state:function(){return this._state},unload:function(){for(var e=this,o=e._sounds,t=0;t<o.length;t++)o[t]._paused||e.stop(o[t]._id),e._webAudio||(e._clearSound(o[t]._node),o[t]._node.removeEventListener("error",o[t]._errorFn,!1),o[t]._node.removeEventListener(n._canPlayEvent,o[t]._loadFn,!1),o[t]._node.removeEventListener("ended",o[t]._endFn,!1),n._releaseHtml5Audio(o[t]._node)),delete o[t]._node,e._clearTimer(o[t]._id);var a=n._howls.indexOf(e);a>=0&&n._howls.splice(a,1);var u=!0;for(t=0;t<n._howls.length;t++)if(n._howls[t]._src===e._src||e._src.indexOf(n._howls[t]._src)>=0){u=!1;break}return r&&u&&delete r[e._src],n.noAudio=!1,e._state="unloaded",e._sounds=[],e=null,null},on:function(e,n,o,t){var r=this,a=r["_on"+e];return"function"==typeof n&&a.push(t?{id:o,fn:n,once:t}:{id:o,fn:n}),r},off:function(e,n,o){var t=this,r=t["_on"+e],a=0;if("number"==typeof n&&(o=n,n=null),n||o)for(a=0;a<r.length;a++){var u=o===r[a].id;if(n===r[a].fn&&u||!n&&u){r.splice(a,1);break}}else if(e)t["_on"+e]=[];else{var d=Object.keys(t);for(a=0;a<d.length;a++)0===d[a].indexOf("_on")&&Array.isArray(t[d[a]])&&(t[d[a]]=[])}return t},once:function(e,n,o){var t=this;return t.on(e,n,o,1),t},_emit:function(e,n,o){for(var t=this,r=t["_on"+e],a=r.length-1;a>=0;a--)r[a].id&&r[a].id!==n&&"load"!==e||(setTimeout(function(e){e.call(this,n,o)}.bind(t,r[a].fn),0),r[a].once&&t.off(e,r[a].fn,r[a].id));return t._loadQueue(e),t},_loadQueue:function(e){var n=this;if(n._queue.length>0){var o=n._queue[0];o.event===e&&(n._queue.shift(),n._loadQueue()),e||o.action()}return n},_ended:function(e){var o=this,t=e._sprite;if(!o._webAudio&&e._node&&!e._node.paused&&!e._node.ended&&e._node.currentTime<e._stop)return setTimeout(o._ended.bind(o,e),100),o;var r=!(!e._loop&&!o._sprite[t][2]);if(o._emit("end",e._id),!o._webAudio&&r&&o.stop(e._id,!0).play(e._id),o._webAudio&&r){o._emit("play",e._id),e._seek=e._start||0,e._rateSeek=0,e._playStart=n.ctx.currentTime;var a=1e3*(e._stop-e._start)/Math.abs(e._rate);o._endTimers[e._id]=setTimeout(o._ended.bind(o,e),a)}return o._webAudio&&!r&&(e._paused=!0,e._ended=!0,e._seek=e._start||0,e._rateSeek=0,o._clearTimer(e._id),o._cleanBuffer(e._node),n._autoSuspend()),o._webAudio||r||o.stop(e._id,!0),o},_clearTimer:function(e){var n=this;if(n._endTimers[e]){if("function"!=typeof n._endTimers[e])clearTimeout(n._endTimers[e]);else{var o=n._soundById(e);o&&o._node&&o._node.removeEventListener("ended",n._endTimers[e],!1)}delete n._endTimers[e]}return n},_soundById:function(e){for(var n=this,o=0;o<n._sounds.length;o++)if(e===n._sounds[o]._id)return n._sounds[o];return null},_inactiveSound:function(){var e=this;e._drain();for(var n=0;n<e._sounds.length;n++)if(e._sounds[n]._ended)return e._sounds[n].reset();return new t(e)},_drain:function(){var e=this,n=e._pool,o=0,t=0;if(!(e._sounds.length<n)){for(t=0;t<e._sounds.length;t++)e._sounds[t]._ended&&o++;for(t=e._sounds.length-1;t>=0;t--){if(o<=n)return;e._sounds[t]._ended&&(e._webAudio&&e._sounds[t]._node&&e._sounds[t]._node.disconnect(0),e._sounds.splice(t,1),o--)}}},_getSoundIds:function(e){var n=this;if(void 0===e){for(var o=[],t=0;t<n._sounds.length;t++)o.push(n._sounds[t]._id);return o}return[e]},_refreshBuffer:function(e){var o=this;return e._node.bufferSource=n.ctx.createBufferSource(),e._node.bufferSource.buffer=r[o._src],e._panner?e._node.bufferSource.connect(e._panner):e._node.bufferSource.connect(e._node),e._node.bufferSource.loop=e._loop,e._loop&&(e._node.bufferSource.loopStart=e._start||0,e._node.bufferSource.loopEnd=e._stop||0),e._node.bufferSource.playbackRate.setValueAtTime(e._rate,n.ctx.currentTime),o},_cleanBuffer:function(e){var o=this,t=n._navigator&&n._navigator.vendor.indexOf("Apple")>=0;if(!e.bufferSource)return o;if(n._scratchBuffer&&e.bufferSource&&(e.bufferSource.onended=null,e.bufferSource.disconnect(0),t))try{e.bufferSource.buffer=n._scratchBuffer}catch(e){}return e.bufferSource=null,o},_clearSound:function(e){/MSIE |Trident\//.test(n._navigator&&n._navigator.userAgent)||(e.src="data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA")}};var t=function(e){this._parent=e,this.init()};t.prototype={init:function(){var e=this,o=e._parent;return e._muted=o._muted,e._loop=o._loop,e._volume=o._volume,e._rate=o._rate,e._seek=0,e._paused=!0,e._ended=!0,e._sprite="__default",e._id=++n._counter,o._sounds.push(e),e.create(),e},create:function(){var e=this,o=e._parent,t=n._muted||e._muted||e._parent._muted?0:e._volume;return o._webAudio?(e._node=void 0===n.ctx.createGain?n.ctx.createGainNode():n.ctx.createGain(),e._node.gain.setValueAtTime(t,n.ctx.currentTime),e._node.paused=!0,e._node.connect(n.masterGain)):n.noAudio||(e._node=n._obtainHtml5Audio(),e._errorFn=e._errorListener.bind(e),e._node.addEventListener("error",e._errorFn,!1),e._loadFn=e._loadListener.bind(e),e._node.addEventListener(n._canPlayEvent,e._loadFn,!1),e._endFn=e._endListener.bind(e),e._node.addEventListener("ended",e._endFn,!1),e._node.src=o._src,e._node.preload=!0===o._preload?"auto":o._preload,e._node.volume=t*n.volume(),e._node.load()),e},reset:function(){var e=this,o=e._parent;return e._muted=o._muted,e._loop=o._loop,e._volume=o._volume,e._rate=o._rate,e._seek=0,e._rateSeek=0,e._paused=!0,e._ended=!0,e._sprite="__default",e._id=++n._counter,e},_errorListener:function(){var e=this;e._parent._emit("loaderror",e._id,e._node.error?e._node.error.code:0),e._node.removeEventListener("error",e._errorFn,!1)},_loadListener:function(){var e=this,o=e._parent;o._duration=Math.ceil(10*e._node.duration)/10,0===Object.keys(o._sprite).length&&(o._sprite={__default:[0,1e3*o._duration]}),"loaded"!==o._state&&(o._state="loaded",o._emit("load"),o._loadQueue()),e._node.removeEventListener(n._canPlayEvent,e._loadFn,!1)},_endListener:function(){var e=this,n=e._parent;n._duration===1/0&&(n._duration=Math.ceil(10*e._node.duration)/10,n._sprite.__default[1]===1/0&&(n._sprite.__default[1]=1e3*n._duration),n._ended(e)),e._node.removeEventListener("ended",e._endFn,!1)}};var r={},a=function(e){var n=e._src;if(r[n])return e._duration=r[n].duration,void i(e);if(/^data:[^;]+;base64,/.test(n)){for(var o=atob(n.split(",")[1]),t=new Uint8Array(o.length),a=0;a<o.length;++a)t[a]=o.charCodeAt(a);d(t.buffer,e)}else{var _=new XMLHttpRequest;_.open(e._xhr.method,n,!0),_.withCredentials=e._xhr.withCredentials,_.responseType="arraybuffer",e._xhr.headers&&Object.keys(e._xhr.headers).forEach(function(n){_.setRequestHeader(n,e._xhr.headers[n])}),_.onload=function(){var n=(_.status+"")[0];if("0"!==n&&"2"!==n&&"3"!==n)return void e._emit("loaderror",null,"Failed loading audio file with status: "+_.status+".");d(_.response,e)},_.onerror=function(){e._webAudio&&(e._html5=!0,e._webAudio=!1,e._sounds=[],delete r[n],e.load())},u(_)}},u=function(e){try{e.send()}catch(n){e.onerror()}},d=function(e,o){var t=function(){o._emit("loaderror",null,"Decoding audio data failed.")},a=function(e){e&&o._sounds.length>0?(r[o._src]=e,i(o,e)):t()};"undefined"!=typeof Promise&&1===n.ctx.decodeAudioData.length?n.ctx.decodeAudioData(e).then(a).catch(t):n.ctx.decodeAudioData(e,a,t)},i=function(e,n){n&&!e._duration&&(e._duration=n.duration),0===Object.keys(e._sprite).length&&(e._sprite={__default:[0,1e3*e._duration]}),"loaded"!==e._state&&(e._state="loaded",e._emit("load"),e._loadQueue())},_=function(){if(n.usingWebAudio){try{"undefined"!=typeof AudioContext?n.ctx=new AudioContext:"undefined"!=typeof webkitAudioContext?n.ctx=new webkitAudioContext:n.usingWebAudio=!1}catch(e){n.usingWebAudio=!1}n.ctx||(n.usingWebAudio=!1);var e=/iP(hone|od|ad)/.test(n._navigator&&n._navigator.platform),o=n._navigator&&n._navigator.appVersion.match(/OS (\d+)_(\d+)_?(\d+)?/),t=o?parseInt(o[1],10):null;if(e&&t&&t<9){var r=/safari/.test(n._navigator&&n._navigator.userAgent.toLowerCase());n._navigator&&!r&&(n.usingWebAudio=!1)}n.usingWebAudio&&(n.masterGain=void 0===n.ctx.createGain?n.ctx.createGainNode():n.ctx.createGain(),n.masterGain.gain.setValueAtTime(n._muted?0:n._volume,n.ctx.currentTime),n.masterGain.connect(n.ctx.destination)),n._setup()}};"function"==typeof define&&define.amd&&define([],function(){return{Howler:n,Howl:o}}),"undefined"!=typeof exports&&(exports.Howler=n,exports.Howl=o),"undefined"!=typeof global?(global.HowlerGlobal=e,global.Howler=n,global.Howl=o,global.Sound=t):"undefined"!=typeof window&&(window.HowlerGlobal=e,window.Howler=n,window.Howl=o,window.Sound=t)}();
3
+ /*! Spatial Plugin */
4
+ !function(){"use strict";HowlerGlobal.prototype._pos=[0,0,0],HowlerGlobal.prototype._orientation=[0,0,-1,0,1,0],HowlerGlobal.prototype.stereo=function(e){var n=this;if(!n.ctx||!n.ctx.listener)return n;for(var t=n._howls.length-1;t>=0;t--)n._howls[t].stereo(e);return n},HowlerGlobal.prototype.pos=function(e,n,t){var r=this;return r.ctx&&r.ctx.listener?(n="number"!=typeof n?r._pos[1]:n,t="number"!=typeof t?r._pos[2]:t,"number"!=typeof e?r._pos:(r._pos=[e,n,t],void 0!==r.ctx.listener.positionX?(r.ctx.listener.positionX.setTargetAtTime(r._pos[0],Howler.ctx.currentTime,.1),r.ctx.listener.positionY.setTargetAtTime(r._pos[1],Howler.ctx.currentTime,.1),r.ctx.listener.positionZ.setTargetAtTime(r._pos[2],Howler.ctx.currentTime,.1)):r.ctx.listener.setPosition(r._pos[0],r._pos[1],r._pos[2]),r)):r},HowlerGlobal.prototype.orientation=function(e,n,t,r,o,i){var a=this;if(!a.ctx||!a.ctx.listener)return a;var p=a._orientation;return n="number"!=typeof n?p[1]:n,t="number"!=typeof t?p[2]:t,r="number"!=typeof r?p[3]:r,o="number"!=typeof o?p[4]:o,i="number"!=typeof i?p[5]:i,"number"!=typeof e?p:(a._orientation=[e,n,t,r,o,i],void 0!==a.ctx.listener.forwardX?(a.ctx.listener.forwardX.setTargetAtTime(e,Howler.ctx.currentTime,.1),a.ctx.listener.forwardY.setTargetAtTime(n,Howler.ctx.currentTime,.1),a.ctx.listener.forwardZ.setTargetAtTime(t,Howler.ctx.currentTime,.1),a.ctx.listener.upX.setTargetAtTime(r,Howler.ctx.currentTime,.1),a.ctx.listener.upY.setTargetAtTime(o,Howler.ctx.currentTime,.1),a.ctx.listener.upZ.setTargetAtTime(i,Howler.ctx.currentTime,.1)):a.ctx.listener.setOrientation(e,n,t,r,o,i),a)},Howl.prototype.init=function(e){return function(n){var t=this;return t._orientation=n.orientation||[1,0,0],t._stereo=n.stereo||null,t._pos=n.pos||null,t._pannerAttr={coneInnerAngle:void 0!==n.coneInnerAngle?n.coneInnerAngle:360,coneOuterAngle:void 0!==n.coneOuterAngle?n.coneOuterAngle:360,coneOuterGain:void 0!==n.coneOuterGain?n.coneOuterGain:0,distanceModel:void 0!==n.distanceModel?n.distanceModel:"inverse",maxDistance:void 0!==n.maxDistance?n.maxDistance:1e4,panningModel:void 0!==n.panningModel?n.panningModel:"HRTF",refDistance:void 0!==n.refDistance?n.refDistance:1,rolloffFactor:void 0!==n.rolloffFactor?n.rolloffFactor:1},t._onstereo=n.onstereo?[{fn:n.onstereo}]:[],t._onpos=n.onpos?[{fn:n.onpos}]:[],t._onorientation=n.onorientation?[{fn:n.onorientation}]:[],e.call(this,n)}}(Howl.prototype.init),Howl.prototype.stereo=function(n,t){var r=this;if(!r._webAudio)return r;if("loaded"!==r._state)return r._queue.push({event:"stereo",action:function(){r.stereo(n,t)}}),r;var o=void 0===Howler.ctx.createStereoPanner?"spatial":"stereo";if(void 0===t){if("number"!=typeof n)return r._stereo;r._stereo=n,r._pos=[n,0,0]}for(var i=r._getSoundIds(t),a=0;a<i.length;a++){var p=r._soundById(i[a]);if(p){if("number"!=typeof n)return p._stereo;p._stereo=n,p._pos=[n,0,0],p._node&&(p._pannerAttr.panningModel="equalpower",p._panner&&p._panner.pan||e(p,o),"spatial"===o?void 0!==p._panner.positionX?(p._panner.positionX.setValueAtTime(n,Howler.ctx.currentTime),p._panner.positionY.setValueAtTime(0,Howler.ctx.currentTime),p._panner.positionZ.setValueAtTime(0,Howler.ctx.currentTime)):p._panner.setPosition(n,0,0):p._panner.pan.setValueAtTime(n,Howler.ctx.currentTime)),r._emit("stereo",p._id)}}return r},Howl.prototype.pos=function(n,t,r,o){var i=this;if(!i._webAudio)return i;if("loaded"!==i._state)return i._queue.push({event:"pos",action:function(){i.pos(n,t,r,o)}}),i;if(t="number"!=typeof t?0:t,r="number"!=typeof r?-.5:r,void 0===o){if("number"!=typeof n)return i._pos;i._pos=[n,t,r]}for(var a=i._getSoundIds(o),p=0;p<a.length;p++){var s=i._soundById(a[p]);if(s){if("number"!=typeof n)return s._pos;s._pos=[n,t,r],s._node&&(s._panner&&!s._panner.pan||e(s,"spatial"),void 0!==s._panner.positionX?(s._panner.positionX.setValueAtTime(n,Howler.ctx.currentTime),s._panner.positionY.setValueAtTime(t,Howler.ctx.currentTime),s._panner.positionZ.setValueAtTime(r,Howler.ctx.currentTime)):s._panner.setPosition(n,t,r)),i._emit("pos",s._id)}}return i},Howl.prototype.orientation=function(n,t,r,o){var i=this;if(!i._webAudio)return i;if("loaded"!==i._state)return i._queue.push({event:"orientation",action:function(){i.orientation(n,t,r,o)}}),i;if(t="number"!=typeof t?i._orientation[1]:t,r="number"!=typeof r?i._orientation[2]:r,void 0===o){if("number"!=typeof n)return i._orientation;i._orientation=[n,t,r]}for(var a=i._getSoundIds(o),p=0;p<a.length;p++){var s=i._soundById(a[p]);if(s){if("number"!=typeof n)return s._orientation;s._orientation=[n,t,r],s._node&&(s._panner||(s._pos||(s._pos=i._pos||[0,0,-.5]),e(s,"spatial")),void 0!==s._panner.orientationX?(s._panner.orientationX.setValueAtTime(n,Howler.ctx.currentTime),s._panner.orientationY.setValueAtTime(t,Howler.ctx.currentTime),s._panner.orientationZ.setValueAtTime(r,Howler.ctx.currentTime)):s._panner.setOrientation(n,t,r)),i._emit("orientation",s._id)}}return i},Howl.prototype.pannerAttr=function(){var n,t,r,o=this,i=arguments;if(!o._webAudio)return o;if(0===i.length)return o._pannerAttr;if(1===i.length){if("object"!=typeof i[0])return r=o._soundById(parseInt(i[0],10)),r?r._pannerAttr:o._pannerAttr;n=i[0],void 0===t&&(n.pannerAttr||(n.pannerAttr={coneInnerAngle:n.coneInnerAngle,coneOuterAngle:n.coneOuterAngle,coneOuterGain:n.coneOuterGain,distanceModel:n.distanceModel,maxDistance:n.maxDistance,refDistance:n.refDistance,rolloffFactor:n.rolloffFactor,panningModel:n.panningModel}),o._pannerAttr={coneInnerAngle:void 0!==n.pannerAttr.coneInnerAngle?n.pannerAttr.coneInnerAngle:o._coneInnerAngle,coneOuterAngle:void 0!==n.pannerAttr.coneOuterAngle?n.pannerAttr.coneOuterAngle:o._coneOuterAngle,coneOuterGain:void 0!==n.pannerAttr.coneOuterGain?n.pannerAttr.coneOuterGain:o._coneOuterGain,distanceModel:void 0!==n.pannerAttr.distanceModel?n.pannerAttr.distanceModel:o._distanceModel,maxDistance:void 0!==n.pannerAttr.maxDistance?n.pannerAttr.maxDistance:o._maxDistance,refDistance:void 0!==n.pannerAttr.refDistance?n.pannerAttr.refDistance:o._refDistance,rolloffFactor:void 0!==n.pannerAttr.rolloffFactor?n.pannerAttr.rolloffFactor:o._rolloffFactor,panningModel:void 0!==n.pannerAttr.panningModel?n.pannerAttr.panningModel:o._panningModel})}else 2===i.length&&(n=i[0],t=parseInt(i[1],10));for(var a=o._getSoundIds(t),p=0;p<a.length;p++)if(r=o._soundById(a[p])){var s=r._pannerAttr;s={coneInnerAngle:void 0!==n.coneInnerAngle?n.coneInnerAngle:s.coneInnerAngle,coneOuterAngle:void 0!==n.coneOuterAngle?n.coneOuterAngle:s.coneOuterAngle,coneOuterGain:void 0!==n.coneOuterGain?n.coneOuterGain:s.coneOuterGain,distanceModel:void 0!==n.distanceModel?n.distanceModel:s.distanceModel,maxDistance:void 0!==n.maxDistance?n.maxDistance:s.maxDistance,refDistance:void 0!==n.refDistance?n.refDistance:s.refDistance,rolloffFactor:void 0!==n.rolloffFactor?n.rolloffFactor:s.rolloffFactor,panningModel:void 0!==n.panningModel?n.panningModel:s.panningModel};var c=r._panner;c||(r._pos||(r._pos=o._pos||[0,0,-.5]),e(r,"spatial"),c=r._panner),c.coneInnerAngle=s.coneInnerAngle,c.coneOuterAngle=s.coneOuterAngle,c.coneOuterGain=s.coneOuterGain,c.distanceModel=s.distanceModel,c.maxDistance=s.maxDistance,c.refDistance=s.refDistance,c.rolloffFactor=s.rolloffFactor,c.panningModel=s.panningModel}return o},Sound.prototype.init=function(e){return function(){var n=this,t=n._parent;n._orientation=t._orientation,n._stereo=t._stereo,n._pos=t._pos,n._pannerAttr=t._pannerAttr,e.call(this),n._stereo?t.stereo(n._stereo):n._pos&&t.pos(n._pos[0],n._pos[1],n._pos[2],n._id)}}(Sound.prototype.init),Sound.prototype.reset=function(e){return function(){var n=this,t=n._parent;return n._orientation=t._orientation,n._stereo=t._stereo,n._pos=t._pos,n._pannerAttr=t._pannerAttr,n._stereo?t.stereo(n._stereo):n._pos?t.pos(n._pos[0],n._pos[1],n._pos[2],n._id):n._panner&&(n._panner.disconnect(0),n._panner=void 0,t._refreshBuffer(n)),e.call(this)}}(Sound.prototype.reset);var e=function(e,n){n=n||"spatial","spatial"===n?(e._panner=Howler.ctx.createPanner(),e._panner.coneInnerAngle=e._pannerAttr.coneInnerAngle,e._panner.coneOuterAngle=e._pannerAttr.coneOuterAngle,e._panner.coneOuterGain=e._pannerAttr.coneOuterGain,e._panner.distanceModel=e._pannerAttr.distanceModel,e._panner.maxDistance=e._pannerAttr.maxDistance,e._panner.refDistance=e._pannerAttr.refDistance,e._panner.rolloffFactor=e._pannerAttr.rolloffFactor,e._panner.panningModel=e._pannerAttr.panningModel,void 0!==e._panner.positionX?(e._panner.positionX.setValueAtTime(e._pos[0],Howler.ctx.currentTime),e._panner.positionY.setValueAtTime(e._pos[1],Howler.ctx.currentTime),e._panner.positionZ.setValueAtTime(e._pos[2],Howler.ctx.currentTime)):e._panner.setPosition(e._pos[0],e._pos[1],e._pos[2]),void 0!==e._panner.orientationX?(e._panner.orientationX.setValueAtTime(e._orientation[0],Howler.ctx.currentTime),e._panner.orientationY.setValueAtTime(e._orientation[1],Howler.ctx.currentTime),e._panner.orientationZ.setValueAtTime(e._orientation[2],Howler.ctx.currentTime)):e._panner.setOrientation(e._orientation[0],e._orientation[1],e._orientation[2])):(e._panner=Howler.ctx.createStereoPanner(),e._panner.pan.setValueAtTime(e._stereo,Howler.ctx.currentTime)),e._panner.connect(e._node),e._paused||e._parent.pause(e._id,!0).play(e._id,!0)}}();
static/js/vue.global.prod.js ADDED
The diff for this file is too large to render. See raw diff