Michael Natanael commited on
Commit
80cb407
·
1 Parent(s): 8148023

First deployment

Browse files
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ final_checkpoint/
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
2
+ # you will also find guides on how best to write your Dockerfile
3
+
4
+ FROM python:3.9
5
+
6
+ RUN useradd -m -u 1000 user
7
+ USER user
8
+ ENV PATH="/home/user/.local/bin:$PATH"
9
+
10
+ WORKDIR /app
11
+
12
+ COPY --chown=user ./requirements.txt requirements.txt
13
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
14
+
15
+ COPY --chown=user . /app
16
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
app.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request
2
+ import whisper
3
+ import tempfile
4
+ import os
5
+ import time
6
+ import torch
7
+ import numpy as np
8
+ import requests
9
+ from tqdm import tqdm
10
+ from transformers import BertTokenizer
11
+ from model.multi_class_model import MultiClassModel # Adjust if needed
12
+ import lightning as L
13
+
14
+ app = Flask(__name__)
15
+
16
+ # === CONFIG ===
17
+ CHECKPOINT_URL = "https://github.com/michael2002porto/bert_classification_indonesian_song_lyrics/releases/download/finetuned_checkpoints/original_split_synthesized.ckpt"
18
+ CHECKPOINT_PATH = "final_checkpoint/original_split_synthesized.ckpt"
19
+ AGE_LABELS = ["semua usia", "anak", "remaja", "dewasa"]
20
+
21
+ # === FUNCTION TO DOWNLOAD CKPT IF NEEDED ===
22
+ def download_checkpoint_if_needed(url, save_path):
23
+ if not os.path.exists(save_path):
24
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
25
+ print(f"📥 Downloading model checkpoint from {url}...")
26
+ response = requests.get(url, stream=True, timeout=10)
27
+ if response.status_code == 200:
28
+ total = int(response.headers.get("content-length", 0))
29
+ with open(save_path, 'wb') as f, tqdm(total=total, unit='B', unit_scale=True, desc="Downloading") as pbar:
30
+ for chunk in response.iter_content(1024):
31
+ f.write(chunk)
32
+ pbar.update(len(chunk))
33
+ print("✅ Checkpoint downloaded!")
34
+ else:
35
+ raise Exception(f"❌ Failed to download: {response.status_code}")
36
+
37
+ # === INITIAL SETUP: Download & Load Model ===
38
+ download_checkpoint_if_needed(CHECKPOINT_URL, CHECKPOINT_PATH)
39
+
40
+ # Load tokenizer
41
+ tokenizer = BertTokenizer.from_pretrained('indolem/indobert-base-uncased')
42
+
43
+ # Load model from checkpoint
44
+ model = MultiClassModel.load_from_checkpoint(
45
+ CHECKPOINT_PATH,
46
+ n_out=4,
47
+ dropout=0.3,
48
+ lr=1e-5
49
+ )
50
+ model.eval()
51
+
52
+
53
+ # === ROUTES ===
54
+
55
+ @app.route('/', methods=['GET'])
56
+ def index():
57
+ return render_template('index.html')
58
+
59
+
60
+ @app.route('/transcribe', methods=['POST'])
61
+ def transcribe():
62
+ try:
63
+ # Load Whisper with Indonesian language support (large / turbo)
64
+ # https://github.com/openai/whisper
65
+ whisper_model = whisper.load_model("large")
66
+
67
+ # Start measuring time
68
+ start_time = time.time()
69
+
70
+ audio_file = request.files['file']
71
+ if audio_file:
72
+ # Save uploaded audio to temp file
73
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as temp_audio:
74
+ temp_audio.write(audio_file.read())
75
+ temp_audio_path = temp_audio.name
76
+
77
+ # Step 1: Transcribe
78
+ transcription = whisper_model.transcribe(temp_audio_path, language="id")
79
+ os.remove(temp_audio_path)
80
+ transcribed_text = transcription["text"]
81
+
82
+ # Step 2: BERT Prediction
83
+ encoding = tokenizer.encode_plus(
84
+ transcribed_text,
85
+ add_special_tokens=True,
86
+ max_length=512,
87
+ return_token_type_ids=True,
88
+ padding="max_length",
89
+ return_attention_mask=True,
90
+ return_tensors='pt',
91
+ )
92
+
93
+ with torch.no_grad():
94
+ prediction = model(
95
+ encoding["input_ids"],
96
+ encoding["attention_mask"],
97
+ encoding["token_type_ids"]
98
+ )
99
+
100
+ logits = prediction
101
+ probabilities = torch.nn.functional.softmax(logits, dim=1).cpu().numpy().flatten()
102
+ predicted_class = np.argmax(probabilities)
103
+ predicted_label = AGE_LABELS[predicted_class]
104
+
105
+ prob_results = [(label, f"{prob:.4f}") for label, prob in zip(AGE_LABELS, probabilities)]
106
+
107
+ # Stop timer
108
+ end_time = time.time()
109
+ total_time = end_time - start_time
110
+ formatted_time = f"{total_time:.2f} seconds"
111
+
112
+ return render_template(
113
+ 'transcribe.html',
114
+ task=transcribed_text,
115
+ prediction=predicted_label,
116
+ probabilities=prob_results,
117
+ total_time=formatted_time
118
+ )
119
+
120
+ except Exception as e:
121
+ print("Error:", e)
122
+ return str(e)
123
+
124
+
125
+ @app.route('/predict-text', methods=['POST'])
126
+ def predict_text():
127
+ try:
128
+ user_lyrics = request.form.get('lyrics', '').strip()
129
+
130
+ if not user_lyrics:
131
+ return "No lyrics provided.", 400
132
+
133
+ # Start timer
134
+ start_time = time.time()
135
+
136
+ encoding = tokenizer.encode_plus(
137
+ user_lyrics,
138
+ add_special_tokens=True,
139
+ max_length=512,
140
+ return_token_type_ids=True,
141
+ padding="max_length",
142
+ return_attention_mask=True,
143
+ return_tensors='pt',
144
+ )
145
+
146
+ with torch.no_grad():
147
+ prediction = model(
148
+ encoding["input_ids"],
149
+ encoding["attention_mask"],
150
+ encoding["token_type_ids"]
151
+ )
152
+
153
+ logits = prediction
154
+ probabilities = torch.nn.functional.softmax(logits, dim=1).cpu().numpy().flatten()
155
+ predicted_class = np.argmax(probabilities)
156
+ predicted_label = AGE_LABELS[predicted_class]
157
+ prob_results = [(label, f"{prob:.4f}") for label, prob in zip(AGE_LABELS, probabilities)]
158
+
159
+ # End timer
160
+ end_time = time.time()
161
+ total_time = f"{end_time - start_time:.2f} seconds"
162
+
163
+ return render_template(
164
+ 'transcribe.html',
165
+ task=user_lyrics,
166
+ prediction=predicted_label,
167
+ probabilities=prob_results,
168
+ total_time=total_time
169
+ )
170
+
171
+ except Exception as e:
172
+ print("❌ Error in predict-text:", e)
173
+ return str(e), 500
174
+
175
+
176
+ if __name__ == "__main__":
177
+ app.run(debug=True)
model/multi_class_model.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import sys
3
+
4
+ import torch
5
+ import torch.nn as nn
6
+
7
+ import lightning as L
8
+
9
+ from transformers import BertModel
10
+ from torchmetrics.classification import F1Score, Accuracy, Precision, Recall
11
+
12
+ class MultiClassModel(L.LightningModule):
13
+ def __init__(self,
14
+ dropout,
15
+ n_out,
16
+ lr,
17
+ hidden_size = 768,
18
+ model_dim = 768,):
19
+ super(MultiClassModel, self).__init__()
20
+
21
+ # save all the hyperparameters
22
+ self.save_hyperparameters()
23
+
24
+ # seed untuk weight
25
+ torch.manual_seed(1) # Untuk GPU
26
+ random.seed(1) # Untuk CPU
27
+
28
+ # inisialisasi bert
29
+ # sudah di training terhadap dataset tertentu oleh orang di wikipedia
30
+ self.bert = BertModel.from_pretrained('indolem/indobert-base-uncased')
31
+
32
+ # hasil dimasukkan ke linear function
33
+ # pre_classifier = agar weight tidak hilang ketika epoch selanjutnya. Agar weight dapat digunakan kembali
34
+ # Disimpan di memori spesifik untuk song lyrics classification
35
+ # di kecilkan dimensinya dari 768 -> 512
36
+ self.pre_classifier = nn.Linear(hidden_size, model_dim)
37
+
38
+ self.dropout = nn.Dropout(dropout)
39
+
40
+ # n_out = jumlah label
41
+ # jumlah label = 4 (semua usia, anak, remaja, dewasa)
42
+ self.num_classes = n_out
43
+ # output_layer classifier untuk merubah menjadi label
44
+ self.output_layer = nn.Linear(model_dim, self.num_classes)
45
+ # Activation function / Normalisasi
46
+ self.softmax = nn.Softmax()
47
+
48
+ # Seberapa dalam rasio si model di optimize
49
+ self.lr = lr
50
+
51
+ # Persiapan benchmarking
52
+ self.prepare_metrics()
53
+
54
+ # menghitung loss function
55
+ self.criterion = nn.BCEWithLogitsLoss()
56
+
57
+ # mengambil input dari bert, pre_classifier
58
+ def forward(self, input_ids, attention_mask, token_type_ids):
59
+ bert_out = self.bert(
60
+ input_ids = input_ids,
61
+ attention_mask = attention_mask,
62
+ token_type_ids = token_type_ids
63
+ )
64
+
65
+ # hidden_state = bert_out[0]
66
+ # pooler = hidden_state[:, 0]
67
+ # Output size (batch size = 20 baris, sequence length = 100 kata / token, hidden_size = 768 tensor jumlah vektor representation dari)
68
+
69
+ # Full Output Model
70
+ # 12 * 768
71
+ # 12 = layer nya (Filter)
72
+ # 768 = Probabilitas
73
+ # layer 12
74
+
75
+ # dimensi pooler output = 1 * 768
76
+ bert_out = bert_out.pooler_output #ambil output layer terakhir
77
+ out = self.dropout(bert_out) #menghilangkan memory
78
+
79
+ # pre classifier untuk mentransfer wight output ke epch selanjuntya
80
+ out = self.pre_classifier(out) #pindah ke memori khusus klasifikasi
81
+
82
+ # kontrol hasil pooler min -1 max 1
83
+ # pooler = torch.nn.Tanh()(pooler)
84
+
85
+ # 0.02312312412413131 -> 0.023412 (normalisasi) -> 0 -> 1
86
+ # -0.3124211 -> 0.00012
87
+ out = self.output_layer(out) # output_layer classifier untuk memprojeksikan hasil pooler (768) ke jumlah label (4)
88
+ out = self.softmax(out) #menstabilkan sehingga 0 - 1
89
+
90
+ # pooler = self.dropout(pooler)
91
+
92
+ return out
93
+
94
+ def prepare_metrics(self):
95
+ task = "multiclass"
96
+
97
+ self.acc_metrics = Accuracy(task = task, num_classes = self.num_classes)
98
+
99
+ self.f1_metrics_micro = F1Score(task = task, num_classes = self.num_classes, average = "micro")
100
+ self.f1_metrics_macro = F1Score(task = task, num_classes = self.num_classes, average = "macro")
101
+ self.f1_metrics_weighted = F1Score(task = task, num_classes = self.num_classes, average = "weighted")
102
+
103
+ self.prec_metrics_micro = Precision(task = task, num_classes = self.num_classes, average = "micro")
104
+ self.prec_metrics_macro = Precision(task = task, num_classes = self.num_classes, average = "macro")
105
+ self.prec_metrics_weighted = Precision(task = task, num_classes = self.num_classes, average = "weighted")
106
+
107
+ self.recall_metrics_micro = Recall(task = task, num_classes = self.num_classes, average = "micro")
108
+ self.recall_metrics_macro = Recall(task = task, num_classes = self.num_classes, average = "macro")
109
+ self.recall_metrics_weighted = Recall(task = task, num_classes = self.num_classes, average = "weighted")
110
+
111
+ # to make use of all the outputs
112
+ self.training_step_output = []
113
+ self.validation_step_output = []
114
+ self.test_step_output = []
115
+
116
+ def benchmarking_step(self, pred, target):
117
+ '''
118
+ output pred / target =
119
+ [
120
+ [0.001, 0.80],
121
+ [0.8, 0.0001],
122
+ [0.8, 0.0001],
123
+ [0.8, 0.0001],
124
+ [0.8, 0.0001]
125
+ ]
126
+
127
+ y_pred -> [1, 0, 0, 0, 0]
128
+ '''
129
+
130
+ pred = torch.argmax(pred, dim = 1)
131
+ target = torch.argmax(target, dim = 1)
132
+
133
+ metrics = {}
134
+ metrics["accuracy"] = self.acc_metrics(pred, target)
135
+ metrics["f1_micro"] = self.f1_metrics_micro(pred, target)
136
+ metrics["f1_macro"] = self.f1_metrics_macro(pred, target)
137
+ metrics["f1_weighted"] = self.f1_metrics_weighted(pred, target)
138
+ metrics["prec_micro"] = self.prec_metrics_micro(pred, target)
139
+ metrics["prec_macro"] = self.prec_metrics_macro(pred, target)
140
+ metrics["prec_weighted"] = self.prec_metrics_weighted(pred, target)
141
+ metrics["recall_micro"] = self.recall_metrics_micro(pred, target)
142
+ metrics["recall_macro"] = self.recall_metrics_macro(pred, target)
143
+ metrics["recall_weighted"] = self.recall_metrics_weighted(pred, target)
144
+
145
+ return metrics
146
+
147
+ def configure_optimizers(self):
148
+ # di dalam parameter adam, parameters untuk mengambil kesuluruhan input yg di atas
149
+
150
+ # Fungsi adam
151
+ # Tranfer epoch 1 ke epoch 2
152
+ # Mengontrol (efisiensi) loss
153
+ # Proses training lebih cepat
154
+ # Tidak memakan memori berlebih
155
+ #Learning rate semakin tinggi maka hasil itunya semakin besar
156
+ optimizer = torch.optim.Adam(self.parameters(), lr = self.lr) #untuk menjaga training model improve
157
+ return optimizer
158
+
159
+ def training_step(self, batch, batch_idx):
160
+ x_input_ids, x_token_type_ids, x_attention_mask, y = batch
161
+
162
+ # Ke tiga parameter di input dan di olah oleh method / function forward()
163
+ y_pred = self(
164
+ input_ids = x_input_ids,
165
+ attention_mask = x_attention_mask,
166
+ token_type_ids = x_token_type_ids
167
+ )
168
+
169
+ #y_pred semakin salah, maka semakin tinggi loss
170
+ loss = self.criterion(y_pred, target = y.float())
171
+
172
+ metrics = self.benchmarking_step(pred = y_pred, target = y) #tahu skor
173
+ metrics["loss"] = loss
174
+ metrics_loss = loss
175
+
176
+ self.training_step_output.append(metrics)
177
+ self.log_dict({"train_loss": metrics_loss}, prog_bar = True, on_epoch = True)
178
+
179
+ return loss
180
+
181
+ def validation_step(self, batch, batch_idx):
182
+ x_input_ids, x_token_type_ids, x_attention_mask, y = batch
183
+
184
+ # Ke tiga parameter di input dan di olah oleh method / function forward()
185
+ y_pred = self(
186
+ input_ids = x_input_ids,
187
+ attention_mask = x_attention_mask,
188
+ token_type_ids = x_token_type_ids
189
+ )
190
+
191
+ #y_pred semakin salah, maka semakin tinggi loss
192
+ loss = self.criterion(y_pred, target = y.float())
193
+
194
+ metrics = self.benchmarking_step(pred = y_pred, target = y) #tahu skor
195
+ metrics["loss"] = loss
196
+ metrics_loss = loss
197
+
198
+ self.validation_step_output.append(metrics)
199
+ self.log_dict({"val_loss": metrics_loss}, prog_bar = True, on_epoch = True)
200
+
201
+ return loss
202
+
203
+ def test_step(self, batch, batch_idx):
204
+ x_input_ids, x_token_type_ids, x_attention_mask, y = batch
205
+
206
+ # Ke tiga parameter di input dan di olah oleh method / function forward()
207
+ y_pred = self(
208
+ input_ids = x_input_ids,
209
+ attention_mask = x_attention_mask,
210
+ token_type_ids = x_token_type_ids
211
+ )
212
+
213
+ #y_pred semakin salah, maka semakin tinggi loss
214
+ loss = self.criterion(y_pred, target = y.float())
215
+
216
+ metrics = self.benchmarking_step(pred = y_pred, target = y) #tahu skor
217
+ metrics["loss"] = loss
218
+
219
+ self.test_step_output.append(metrics)
220
+ self.log_dict(metrics, prog_bar = True, on_epoch = True)
221
+
222
+ return loss
223
+
224
+ # def predict_step(self, batch, batch_idx):
225
+ # # Tidak ada transfer weight
226
+ # x_input_ids, x_token_type_ids, x_attention_mask, y = batch
227
+
228
+ # out = self(input_ids = x_input_ids,
229
+ # attention_mask = x_attention_mask,
230
+ # token_type_ids = x_token_type_ids)
231
+ # # Ke tiga parameter di input dan di olah oleh method / function forward
232
+
233
+ # pred = out.argmax(1).cpu()
234
+ # true = y.argmax(1).cpu()
235
+
236
+ # outputs = {"predictions": out, "labels": y}
237
+ # self.predict_step_outputs.append(outputs)
238
+
239
+ # # return [pred, true]
240
+ # return outputs
241
+
242
+ # def on_train_epoch_end(self):
243
+ # labels = []
244
+ # predictions = []
245
+
246
+ # for output in self.training_step_outputs:
247
+ # for out_lbl in output["labels"].detach().cpu():
248
+ # labels.append(out_lbl)
249
+ # for out_pred in output["predictions"].detach().cpu():
250
+ # predictions.append(out_pred)
251
+
252
+ # # argmax(dim=1) = convert one-hot encoded labels to class indices
253
+ # labels = torch.stack(labels).int().argmax(dim=1)
254
+ # predictions = torch.stack(predictions).argmax(dim=1)
255
+
256
+ # print("\n")
257
+ # print("labels = ", labels)
258
+ # print("predictions = ", predictions)
259
+ # print("num_classes = ", self.num_classes)
260
+
261
+ # # Hitung akurasi
262
+ # accuracy = Accuracy(task = "multiclass", num_classes = self.num_classes)
263
+ # acc = accuracy(predictions, labels)
264
+
265
+ # # Print Akurasinya
266
+ # print("Overall Training Accuracy : ", acc)
267
+ # print("\n")
268
+ # # sys.exit()
269
+
270
+ # # free memory
271
+ # self.training_step_outputs.clear()
272
+
273
+ # def on_predict_epoch_end(self):
274
+ # labels = []
275
+ # predictions = []
276
+
277
+ # for output in self.predict_step_outputs:
278
+ # # print(output[0]["predictions"][0])
279
+ # # print(len(output))
280
+ # # break
281
+ # for out_lbl in output["labels"].detach().cpu():
282
+ # labels.append(out_lbl)
283
+ # for out_pred in output["predictions"].detach().cpu():
284
+ # predictions.append(out_pred)
285
+
286
+ # # argmax(dim=1) = convert one-hot encoded labels to class indices
287
+ # labels = torch.stack(labels).int().argmax(dim=1)
288
+ # predictions = torch.stack(predictions).argmax(dim=1)
289
+
290
+ # print("\n")
291
+ # print("labels = ", labels)
292
+ # print("predictions = ", predictions)
293
+ # print("num_classes = ", self.num_classes)
294
+
295
+ # accuracy = Accuracy(task = "multiclass", num_classes = self.num_classes)
296
+ # acc = accuracy(predictions, labels)
297
+ # print("Overall Testing Accuracy : ", acc)
298
+ # print("\n")
299
+ # # sys.exit()
300
+
301
+ # # free memory
302
+ # self.predict_step_outputs.clear()
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ Click==7.0
2
+ Flask==1.1.2
3
+ Flask-SQLAlchemy==2.4.4
4
+ gunicorn==19.9.0
5
+ itsdangerous==1.1.0
6
+ Jinja2==2.11.3
7
+ MarkupSafe==1.1.1
8
+ SQLAlchemy==1.3.22
9
+ Werkzeug==1.0.1
static/css/main.css ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body, html {
2
+ margin: 0;
3
+ font-family: sans-serif;
4
+ background-color: lightblue;
5
+ }
6
+
7
+ .content {
8
+ margin: 0 auto;
9
+ width: 400px;
10
+ }
11
+
12
+ table, td, th {
13
+ border: 1px solid #aaa;
14
+ }
15
+
16
+ table {
17
+ border-collapse: collapse;
18
+ width: 100%;
19
+ }
20
+
21
+ th {
22
+ height: 30px;
23
+ }
24
+
25
+ td {
26
+ text-align: center;
27
+ padding: 5px;
28
+ }
29
+
30
+ .form {
31
+ margin-top: 20px;
32
+ }
33
+
34
+ #content {
35
+ width: 70%;
36
+ }
static/css/tailwind.css ADDED
@@ -0,0 +1,1281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ ! tailwindcss v3.3.2 | MIT License | https://tailwindcss.com
3
+ */
4
+ /*
5
+ 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
6
+ 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
7
+ */
8
+
9
+ *,
10
+ ::before,
11
+ ::after {
12
+ box-sizing: border-box;
13
+ /* 1 */
14
+ border-width: 0;
15
+ /* 2 */
16
+ border-style: solid;
17
+ /* 2 */
18
+ border-color: #e5e7eb;
19
+ /* 2 */
20
+ }
21
+
22
+ ::before,
23
+ ::after {
24
+ --tw-content: '';
25
+ }
26
+
27
+ /*
28
+ 1. Use a consistent sensible line-height in all browsers.
29
+ 2. Prevent adjustments of font size after orientation changes in iOS.
30
+ 3. Use a more readable tab size.
31
+ 4. Use the user's configured `sans` font-family by default.
32
+ 5. Use the user's configured `sans` font-feature-settings by default.
33
+ 6. Use the user's configured `sans` font-variation-settings by default.
34
+ */
35
+
36
+ html {
37
+ line-height: 1.5;
38
+ /* 1 */
39
+ -webkit-text-size-adjust: 100%;
40
+ /* 2 */
41
+ -moz-tab-size: 4;
42
+ /* 3 */
43
+ -o-tab-size: 4;
44
+ tab-size: 4;
45
+ /* 3 */
46
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
47
+ /* 4 */
48
+ font-feature-settings: normal;
49
+ /* 5 */
50
+ font-variation-settings: normal;
51
+ /* 6 */
52
+ }
53
+
54
+ /*
55
+ 1. Remove the margin in all browsers.
56
+ 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
57
+ */
58
+
59
+ body {
60
+ margin: 0;
61
+ /* 1 */
62
+ line-height: inherit;
63
+ /* 2 */
64
+ }
65
+
66
+ /*
67
+ 1. Add the correct height in Firefox.
68
+ 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
69
+ 3. Ensure horizontal rules are visible by default.
70
+ */
71
+
72
+ hr {
73
+ height: 0;
74
+ /* 1 */
75
+ color: inherit;
76
+ /* 2 */
77
+ border-top-width: 1px;
78
+ /* 3 */
79
+ }
80
+
81
+ /*
82
+ Add the correct text decoration in Chrome, Edge, and Safari.
83
+ */
84
+
85
+ abbr:where([title]) {
86
+ -webkit-text-decoration: underline dotted;
87
+ text-decoration: underline dotted;
88
+ }
89
+
90
+ /*
91
+ Remove the default font size and weight for headings.
92
+ */
93
+
94
+ h1,
95
+ h2,
96
+ h3,
97
+ h4,
98
+ h5,
99
+ h6 {
100
+ font-size: inherit;
101
+ font-weight: inherit;
102
+ }
103
+
104
+ /*
105
+ Reset links to optimize for opt-in styling instead of opt-out.
106
+ */
107
+
108
+ a {
109
+ color: inherit;
110
+ text-decoration: inherit;
111
+ }
112
+
113
+ /*
114
+ Add the correct font weight in Edge and Safari.
115
+ */
116
+
117
+ b,
118
+ strong {
119
+ font-weight: bolder;
120
+ }
121
+
122
+ /*
123
+ 1. Use the user's configured `mono` font family by default.
124
+ 2. Correct the odd `em` font sizing in all browsers.
125
+ */
126
+
127
+ code,
128
+ kbd,
129
+ samp,
130
+ pre {
131
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
132
+ /* 1 */
133
+ font-size: 1em;
134
+ /* 2 */
135
+ }
136
+
137
+ /*
138
+ Add the correct font size in all browsers.
139
+ */
140
+
141
+ small {
142
+ font-size: 80%;
143
+ }
144
+
145
+ /*
146
+ Prevent `sub` and `sup` elements from affecting the line height in all browsers.
147
+ */
148
+
149
+ sub,
150
+ sup {
151
+ font-size: 75%;
152
+ line-height: 0;
153
+ position: relative;
154
+ vertical-align: baseline;
155
+ }
156
+
157
+ sub {
158
+ bottom: -0.25em;
159
+ }
160
+
161
+ sup {
162
+ top: -0.5em;
163
+ }
164
+
165
+ /*
166
+ 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
167
+ 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
168
+ 3. Remove gaps between table borders by default.
169
+ */
170
+
171
+ table {
172
+ text-indent: 0;
173
+ /* 1 */
174
+ border-color: inherit;
175
+ /* 2 */
176
+ border-collapse: collapse;
177
+ /* 3 */
178
+ }
179
+
180
+ /*
181
+ 1. Change the font styles in all browsers.
182
+ 2. Remove the margin in Firefox and Safari.
183
+ 3. Remove default padding in all browsers.
184
+ */
185
+
186
+ button,
187
+ input,
188
+ optgroup,
189
+ select,
190
+ textarea {
191
+ font-family: inherit;
192
+ /* 1 */
193
+ font-size: 100%;
194
+ /* 1 */
195
+ font-weight: inherit;
196
+ /* 1 */
197
+ line-height: inherit;
198
+ /* 1 */
199
+ color: inherit;
200
+ /* 1 */
201
+ margin: 0;
202
+ /* 2 */
203
+ padding: 0;
204
+ /* 3 */
205
+ }
206
+
207
+ /*
208
+ Remove the inheritance of text transform in Edge and Firefox.
209
+ */
210
+
211
+ button,
212
+ select {
213
+ text-transform: none;
214
+ }
215
+
216
+ /*
217
+ 1. Correct the inability to style clickable types in iOS and Safari.
218
+ 2. Remove default button styles.
219
+ */
220
+
221
+ button,
222
+ [type='button'],
223
+ [type='reset'],
224
+ [type='submit'] {
225
+ -webkit-appearance: button;
226
+ /* 1 */
227
+ background-color: transparent;
228
+ /* 2 */
229
+ background-image: none;
230
+ /* 2 */
231
+ }
232
+
233
+ /*
234
+ Use the modern Firefox focus style for all focusable elements.
235
+ */
236
+
237
+ :-moz-focusring {
238
+ outline: auto;
239
+ }
240
+
241
+ /*
242
+ Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
243
+ */
244
+
245
+ :-moz-ui-invalid {
246
+ box-shadow: none;
247
+ }
248
+
249
+ /*
250
+ Add the correct vertical alignment in Chrome and Firefox.
251
+ */
252
+
253
+ progress {
254
+ vertical-align: baseline;
255
+ }
256
+
257
+ /*
258
+ Correct the cursor style of increment and decrement buttons in Safari.
259
+ */
260
+
261
+ ::-webkit-inner-spin-button,
262
+ ::-webkit-outer-spin-button {
263
+ height: auto;
264
+ }
265
+
266
+ /*
267
+ 1. Correct the odd appearance in Chrome and Safari.
268
+ 2. Correct the outline style in Safari.
269
+ */
270
+
271
+ [type='search'] {
272
+ -webkit-appearance: textfield;
273
+ /* 1 */
274
+ outline-offset: -2px;
275
+ /* 2 */
276
+ }
277
+
278
+ /*
279
+ Remove the inner padding in Chrome and Safari on macOS.
280
+ */
281
+
282
+ ::-webkit-search-decoration {
283
+ -webkit-appearance: none;
284
+ }
285
+
286
+ /*
287
+ 1. Correct the inability to style clickable types in iOS and Safari.
288
+ 2. Change font properties to `inherit` in Safari.
289
+ */
290
+
291
+ ::-webkit-file-upload-button {
292
+ -webkit-appearance: button;
293
+ /* 1 */
294
+ font: inherit;
295
+ /* 2 */
296
+ }
297
+
298
+ /*
299
+ Add the correct display in Chrome and Safari.
300
+ */
301
+
302
+ summary {
303
+ display: list-item;
304
+ }
305
+
306
+ /*
307
+ Removes the default spacing and border for appropriate elements.
308
+ */
309
+
310
+ blockquote,
311
+ dl,
312
+ dd,
313
+ h1,
314
+ h2,
315
+ h3,
316
+ h4,
317
+ h5,
318
+ h6,
319
+ hr,
320
+ figure,
321
+ p,
322
+ pre {
323
+ margin: 0;
324
+ }
325
+
326
+ fieldset {
327
+ margin: 0;
328
+ padding: 0;
329
+ }
330
+
331
+ legend {
332
+ padding: 0;
333
+ }
334
+
335
+ ol,
336
+ ul,
337
+ menu {
338
+ list-style: none;
339
+ margin: 0;
340
+ padding: 0;
341
+ }
342
+
343
+ /*
344
+ Prevent resizing textareas horizontally by default.
345
+ */
346
+
347
+ textarea {
348
+ resize: vertical;
349
+ }
350
+
351
+ /*
352
+ 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
353
+ 2. Set the default placeholder color to the user's configured gray 400 color.
354
+ */
355
+
356
+ input::-moz-placeholder,
357
+ textarea::-moz-placeholder {
358
+ opacity: 1;
359
+ /* 1 */
360
+ color: #9ca3af;
361
+ /* 2 */
362
+ }
363
+
364
+ input::placeholder,
365
+ textarea::placeholder {
366
+ opacity: 1;
367
+ /* 1 */
368
+ color: #9ca3af;
369
+ /* 2 */
370
+ }
371
+
372
+ /*
373
+ Set the default cursor for buttons.
374
+ */
375
+
376
+ button,
377
+ [role="button"] {
378
+ cursor: pointer;
379
+ }
380
+
381
+ /*
382
+ Make sure disabled buttons don't get the pointer cursor.
383
+ */
384
+ :disabled {
385
+ cursor: default;
386
+ }
387
+
388
+ /*
389
+ 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
390
+ 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
391
+ This can trigger a poorly considered lint error in some tools but is included by design.
392
+ */
393
+
394
+ img,
395
+ svg,
396
+ video,
397
+ canvas,
398
+ audio,
399
+ iframe,
400
+ embed,
401
+ object {
402
+ display: block;
403
+ /* 1 */
404
+ vertical-align: middle;
405
+ /* 2 */
406
+ }
407
+
408
+ /*
409
+ Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
410
+ */
411
+
412
+ img,
413
+ video {
414
+ max-width: 100%;
415
+ height: auto;
416
+ }
417
+
418
+ /* Make elements with the HTML hidden attribute stay hidden by default */
419
+ [hidden] {
420
+ display: none;
421
+ }
422
+
423
+ *,
424
+ ::before,
425
+ ::after {
426
+ --tw-border-spacing-x: 0;
427
+ --tw-border-spacing-y: 0;
428
+ --tw-translate-x: 0;
429
+ --tw-translate-y: 0;
430
+ --tw-rotate: 0;
431
+ --tw-skew-x: 0;
432
+ --tw-skew-y: 0;
433
+ --tw-scale-x: 1;
434
+ --tw-scale-y: 1;
435
+ --tw-pan-x: ;
436
+ --tw-pan-y: ;
437
+ --tw-pinch-zoom: ;
438
+ --tw-scroll-snap-strictness: proximity;
439
+ --tw-gradient-from-position: ;
440
+ --tw-gradient-via-position: ;
441
+ --tw-gradient-to-position: ;
442
+ --tw-ordinal: ;
443
+ --tw-slashed-zero: ;
444
+ --tw-numeric-figure: ;
445
+ --tw-numeric-spacing: ;
446
+ --tw-numeric-fraction: ;
447
+ --tw-ring-inset: ;
448
+ --tw-ring-offset-width: 0px;
449
+ --tw-ring-offset-color: #fff;
450
+ --tw-ring-color: rgb(59 130 246 / 0.5);
451
+ --tw-ring-offset-shadow: 0 0 #0000;
452
+ --tw-ring-shadow: 0 0 #0000;
453
+ --tw-shadow: 0 0 #0000;
454
+ --tw-shadow-colored: 0 0 #0000;
455
+ --tw-blur: ;
456
+ --tw-brightness: ;
457
+ --tw-contrast: ;
458
+ --tw-grayscale: ;
459
+ --tw-hue-rotate: ;
460
+ --tw-invert: ;
461
+ --tw-saturate: ;
462
+ --tw-sepia: ;
463
+ --tw-drop-shadow: ;
464
+ --tw-backdrop-blur: ;
465
+ --tw-backdrop-brightness: ;
466
+ --tw-backdrop-contrast: ;
467
+ --tw-backdrop-grayscale: ;
468
+ --tw-backdrop-hue-rotate: ;
469
+ --tw-backdrop-invert: ;
470
+ --tw-backdrop-opacity: ;
471
+ --tw-backdrop-saturate: ;
472
+ --tw-backdrop-sepia: ;
473
+ }
474
+
475
+ ::backdrop {
476
+ --tw-border-spacing-x: 0;
477
+ --tw-border-spacing-y: 0;
478
+ --tw-translate-x: 0;
479
+ --tw-translate-y: 0;
480
+ --tw-rotate: 0;
481
+ --tw-skew-x: 0;
482
+ --tw-skew-y: 0;
483
+ --tw-scale-x: 1;
484
+ --tw-scale-y: 1;
485
+ --tw-pan-x: ;
486
+ --tw-pan-y: ;
487
+ --tw-pinch-zoom: ;
488
+ --tw-scroll-snap-strictness: proximity;
489
+ --tw-gradient-from-position: ;
490
+ --tw-gradient-via-position: ;
491
+ --tw-gradient-to-position: ;
492
+ --tw-ordinal: ;
493
+ --tw-slashed-zero: ;
494
+ --tw-numeric-figure: ;
495
+ --tw-numeric-spacing: ;
496
+ --tw-numeric-fraction: ;
497
+ --tw-ring-inset: ;
498
+ --tw-ring-offset-width: 0px;
499
+ --tw-ring-offset-color: #fff;
500
+ --tw-ring-color: rgb(59 130 246 / 0.5);
501
+ --tw-ring-offset-shadow: 0 0 #0000;
502
+ --tw-ring-shadow: 0 0 #0000;
503
+ --tw-shadow: 0 0 #0000;
504
+ --tw-shadow-colored: 0 0 #0000;
505
+ --tw-blur: ;
506
+ --tw-brightness: ;
507
+ --tw-contrast: ;
508
+ --tw-grayscale: ;
509
+ --tw-hue-rotate: ;
510
+ --tw-invert: ;
511
+ --tw-saturate: ;
512
+ --tw-sepia: ;
513
+ --tw-drop-shadow: ;
514
+ --tw-backdrop-blur: ;
515
+ --tw-backdrop-brightness: ;
516
+ --tw-backdrop-contrast: ;
517
+ --tw-backdrop-grayscale: ;
518
+ --tw-backdrop-hue-rotate: ;
519
+ --tw-backdrop-invert: ;
520
+ --tw-backdrop-opacity: ;
521
+ --tw-backdrop-saturate: ;
522
+ --tw-backdrop-sepia: ;
523
+ }
524
+
525
+ .container {
526
+ width: 100%;
527
+ }
528
+
529
+ @media (min-width: 640px) {
530
+
531
+ .container {
532
+ max-width: 640px;
533
+ }
534
+ }
535
+
536
+ @media (min-width: 768px) {
537
+
538
+ .container {
539
+ max-width: 768px;
540
+ }
541
+ }
542
+
543
+ @media (min-width: 1024px) {
544
+
545
+ .container {
546
+ max-width: 1024px;
547
+ }
548
+ }
549
+
550
+ @media (min-width: 1280px) {
551
+
552
+ .container {
553
+ max-width: 1280px;
554
+ }
555
+ }
556
+
557
+ @media (min-width: 1536px) {
558
+
559
+ .container {
560
+ max-width: 1536px;
561
+ }
562
+ }
563
+
564
+ .static {
565
+ position: static;
566
+ }
567
+
568
+ .fixed {
569
+ position: fixed;
570
+ }
571
+
572
+ .absolute {
573
+ position: absolute;
574
+ }
575
+
576
+ .relative {
577
+ position: relative;
578
+ }
579
+
580
+ .inset-0 {
581
+ inset: 0px;
582
+ }
583
+
584
+ .bottom-4 {
585
+ bottom: 1rem;
586
+ }
587
+
588
+ .right-4 {
589
+ right: 1rem;
590
+ }
591
+
592
+ .top-0 {
593
+ top: 0px;
594
+ }
595
+
596
+ .z-10 {
597
+ z-index: 10;
598
+ }
599
+
600
+ .m-2 {
601
+ margin: 0.5rem;
602
+ }
603
+
604
+ .my-2 {
605
+ margin-top: 0.5rem;
606
+ margin-bottom: 0.5rem;
607
+ }
608
+
609
+ .mb-1 {
610
+ margin-bottom: 0.25rem;
611
+ }
612
+
613
+ .mb-2 {
614
+ margin-bottom: 0.5rem;
615
+ }
616
+
617
+ .mb-3 {
618
+ margin-bottom: 0.75rem;
619
+ }
620
+
621
+ .mb-5 {
622
+ margin-bottom: 1.25rem;
623
+ }
624
+
625
+ .ml-2 {
626
+ margin-left: 0.5rem;
627
+ }
628
+
629
+ .ml-4 {
630
+ margin-left: 1rem;
631
+ }
632
+
633
+ .mr-2 {
634
+ margin-right: 0.5rem;
635
+ }
636
+
637
+ .mr-3 {
638
+ margin-right: 0.75rem;
639
+ }
640
+
641
+ .mr-5 {
642
+ margin-right: 1.25rem;
643
+ }
644
+
645
+ .ms-1 {
646
+ -webkit-margin-start: 0.25rem;
647
+ margin-inline-start: 0.25rem;
648
+ }
649
+
650
+ .mt-0 {
651
+ margin-top: 0px;
652
+ }
653
+
654
+ .mt-0\.5 {
655
+ margin-top: 0.125rem;
656
+ }
657
+
658
+ .mt-1 {
659
+ margin-top: 0.25rem;
660
+ }
661
+
662
+ .mt-3 {
663
+ margin-top: 0.75rem;
664
+ }
665
+
666
+ .mt-4 {
667
+ margin-top: 1rem;
668
+ }
669
+
670
+ .block {
671
+ display: block;
672
+ }
673
+
674
+ .inline {
675
+ display: inline;
676
+ }
677
+
678
+ .flex {
679
+ display: flex;
680
+ }
681
+
682
+ .inline-flex {
683
+ display: inline-flex;
684
+ }
685
+
686
+ .hidden {
687
+ display: none;
688
+ }
689
+
690
+ .h-1 {
691
+ height: 0.25rem;
692
+ }
693
+
694
+ .h-14 {
695
+ height: 3.5rem;
696
+ }
697
+
698
+ .h-4 {
699
+ height: 1rem;
700
+ }
701
+
702
+ .h-7 {
703
+ height: 1.75rem;
704
+ }
705
+
706
+ .h-full {
707
+ height: 100%;
708
+ }
709
+
710
+ .max-h-\[20rem\] {
711
+ max-height: 20rem;
712
+ }
713
+
714
+ .min-h-full {
715
+ min-height: 100%;
716
+ }
717
+
718
+ .min-h-screen {
719
+ min-height: 100vh;
720
+ }
721
+
722
+ .w-4 {
723
+ width: 1rem;
724
+ }
725
+
726
+ .w-7 {
727
+ width: 1.75rem;
728
+ }
729
+
730
+ .w-\[1px\] {
731
+ width: 1px;
732
+ }
733
+
734
+ .w-full {
735
+ width: 100%;
736
+ }
737
+
738
+ .max-w-md {
739
+ max-width: 28rem;
740
+ }
741
+
742
+ .scale-100 {
743
+ --tw-scale-x: 1;
744
+ --tw-scale-y: 1;
745
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
746
+ }
747
+
748
+ .scale-95 {
749
+ --tw-scale-x: .95;
750
+ --tw-scale-y: .95;
751
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
752
+ }
753
+
754
+ .transform {
755
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
756
+ }
757
+
758
+ @keyframes spin {
759
+
760
+ to {
761
+ transform: rotate(360deg);
762
+ }
763
+ }
764
+
765
+ .animate-spin {
766
+ animation: spin 1s linear infinite;
767
+ }
768
+
769
+ .flex-row {
770
+ flex-direction: row;
771
+ }
772
+
773
+ .flex-row-reverse {
774
+ flex-direction: row-reverse;
775
+ }
776
+
777
+ .flex-col {
778
+ flex-direction: column;
779
+ }
780
+
781
+ .items-center {
782
+ align-items: center;
783
+ }
784
+
785
+ .justify-center {
786
+ justify-content: center;
787
+ }
788
+
789
+ .justify-between {
790
+ justify-content: space-between;
791
+ }
792
+
793
+ .space-x-2> :not([hidden])~ :not([hidden]) {
794
+ --tw-space-x-reverse: 0;
795
+ margin-right: calc(0.5rem * var(--tw-space-x-reverse));
796
+ margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
797
+ }
798
+
799
+ .overflow-hidden {
800
+ overflow: hidden;
801
+ }
802
+
803
+ .overflow-y-auto {
804
+ overflow-y: auto;
805
+ }
806
+
807
+ .whitespace-nowrap {
808
+ white-space: nowrap;
809
+ }
810
+
811
+ .rounded-2xl {
812
+ border-radius: 1rem;
813
+ }
814
+
815
+ .rounded-full {
816
+ border-radius: 9999px;
817
+ }
818
+
819
+ .rounded-lg {
820
+ border-radius: 0.5rem;
821
+ }
822
+
823
+ .rounded-md {
824
+ border-radius: 0.375rem;
825
+ }
826
+
827
+ .border {
828
+ border-width: 1px;
829
+ }
830
+
831
+ .border-gray-300 {
832
+ --tw-border-opacity: 1;
833
+ border-color: rgb(209 213 219 / var(--tw-border-opacity));
834
+ }
835
+
836
+ .border-gray-400 {
837
+ --tw-border-opacity: 1;
838
+ border-color: rgb(156 163 175 / var(--tw-border-opacity));
839
+ }
840
+
841
+ .border-transparent {
842
+ border-color: transparent;
843
+ }
844
+
845
+ .bg-black {
846
+ --tw-bg-opacity: 1;
847
+ background-color: rgb(0 0 0 / var(--tw-bg-opacity));
848
+ }
849
+
850
+ .bg-blue-500 {
851
+ --tw-bg-opacity: 1;
852
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity));
853
+ }
854
+
855
+ .bg-blue-600 {
856
+ --tw-bg-opacity: 1;
857
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
858
+ }
859
+
860
+ .bg-blue-700 {
861
+ --tw-bg-opacity: 1;
862
+ background-color: rgb(29 78 216 / var(--tw-bg-opacity));
863
+ }
864
+
865
+ .bg-gray-200 {
866
+ --tw-bg-opacity: 1;
867
+ background-color: rgb(229 231 235 / var(--tw-bg-opacity));
868
+ }
869
+
870
+ .bg-gray-50 {
871
+ --tw-bg-opacity: 1;
872
+ background-color: rgb(249 250 251 / var(--tw-bg-opacity));
873
+ }
874
+
875
+ .bg-green-500 {
876
+ --tw-bg-opacity: 1;
877
+ background-color: rgb(34 197 94 / var(--tw-bg-opacity));
878
+ }
879
+
880
+ .bg-indigo-100 {
881
+ --tw-bg-opacity: 1;
882
+ background-color: rgb(224 231 255 / var(--tw-bg-opacity));
883
+ }
884
+
885
+ .bg-indigo-600 {
886
+ --tw-bg-opacity: 1;
887
+ background-color: rgb(79 70 229 / var(--tw-bg-opacity));
888
+ }
889
+
890
+ .bg-red-500 {
891
+ --tw-bg-opacity: 1;
892
+ background-color: rgb(239 68 68 / var(--tw-bg-opacity));
893
+ }
894
+
895
+ .bg-slate-200 {
896
+ --tw-bg-opacity: 1;
897
+ background-color: rgb(226 232 240 / var(--tw-bg-opacity));
898
+ }
899
+
900
+ .bg-white {
901
+ --tw-bg-opacity: 1;
902
+ background-color: rgb(255 255 255 / var(--tw-bg-opacity));
903
+ }
904
+
905
+ .bg-opacity-25 {
906
+ --tw-bg-opacity: 0.25;
907
+ }
908
+
909
+ .p-2 {
910
+ padding: 0.5rem;
911
+ }
912
+
913
+ .p-2\.5 {
914
+ padding: 0.625rem;
915
+ }
916
+
917
+ .p-4 {
918
+ padding: 1rem;
919
+ }
920
+
921
+ .p-6 {
922
+ padding: 1.5rem;
923
+ }
924
+
925
+ .px-1 {
926
+ padding-left: 0.25rem;
927
+ padding-right: 0.25rem;
928
+ }
929
+
930
+ .px-2 {
931
+ padding-left: 0.5rem;
932
+ padding-right: 0.5rem;
933
+ }
934
+
935
+ .px-4 {
936
+ padding-left: 1rem;
937
+ padding-right: 1rem;
938
+ }
939
+
940
+ .px-5 {
941
+ padding-left: 1.25rem;
942
+ padding-right: 1.25rem;
943
+ }
944
+
945
+ .py-2 {
946
+ padding-top: 0.5rem;
947
+ padding-bottom: 0.5rem;
948
+ }
949
+
950
+ .py-2\.5 {
951
+ padding-top: 0.625rem;
952
+ padding-bottom: 0.625rem;
953
+ }
954
+
955
+ .text-left {
956
+ text-align: left;
957
+ }
958
+
959
+ .text-center {
960
+ text-align: center;
961
+ }
962
+
963
+ .text-right {
964
+ text-align: right;
965
+ }
966
+
967
+ .align-middle {
968
+ vertical-align: middle;
969
+ }
970
+
971
+ .text-5xl {
972
+ font-size: 3rem;
973
+ line-height: 1;
974
+ }
975
+
976
+ .text-lg {
977
+ font-size: 1.125rem;
978
+ line-height: 1.75rem;
979
+ }
980
+
981
+ .text-sm {
982
+ font-size: 0.875rem;
983
+ line-height: 1.25rem;
984
+ }
985
+
986
+ .font-extrabold {
987
+ font-weight: 800;
988
+ }
989
+
990
+ .font-medium {
991
+ font-weight: 500;
992
+ }
993
+
994
+ .font-semibold {
995
+ font-weight: 600;
996
+ }
997
+
998
+ .leading-6 {
999
+ line-height: 1.5rem;
1000
+ }
1001
+
1002
+ .tracking-tight {
1003
+ letter-spacing: -0.025em;
1004
+ }
1005
+
1006
+ .text-gray-500 {
1007
+ --tw-text-opacity: 1;
1008
+ color: rgb(107 114 128 / var(--tw-text-opacity));
1009
+ }
1010
+
1011
+ .text-gray-900 {
1012
+ --tw-text-opacity: 1;
1013
+ color: rgb(17 24 39 / var(--tw-text-opacity));
1014
+ }
1015
+
1016
+ .text-indigo-100 {
1017
+ --tw-text-opacity: 1;
1018
+ color: rgb(224 231 255 / var(--tw-text-opacity));
1019
+ }
1020
+
1021
+ .text-indigo-900 {
1022
+ --tw-text-opacity: 1;
1023
+ color: rgb(49 46 129 / var(--tw-text-opacity));
1024
+ }
1025
+
1026
+ .text-slate-500 {
1027
+ --tw-text-opacity: 1;
1028
+ color: rgb(100 116 139 / var(--tw-text-opacity));
1029
+ }
1030
+
1031
+ .text-slate-900 {
1032
+ --tw-text-opacity: 1;
1033
+ color: rgb(15 23 42 / var(--tw-text-opacity));
1034
+ }
1035
+
1036
+ .text-white {
1037
+ --tw-text-opacity: 1;
1038
+ color: rgb(255 255 255 / var(--tw-text-opacity));
1039
+ }
1040
+
1041
+ .underline {
1042
+ text-decoration-line: underline;
1043
+ }
1044
+
1045
+ .opacity-0 {
1046
+ opacity: 0;
1047
+ }
1048
+
1049
+ .opacity-100 {
1050
+ opacity: 1;
1051
+ }
1052
+
1053
+ .shadow-xl {
1054
+ --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
1055
+ --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);
1056
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
1057
+ }
1058
+
1059
+ .shadow-black\/5 {
1060
+ --tw-shadow-color: rgb(0 0 0 / 0.05);
1061
+ --tw-shadow: var(--tw-shadow-colored);
1062
+ }
1063
+
1064
+ .ring-1 {
1065
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
1066
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
1067
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
1068
+ }
1069
+
1070
+ .ring-slate-700\/10 {
1071
+ --tw-ring-color: rgb(51 65 85 / 0.1);
1072
+ }
1073
+
1074
+ .filter {
1075
+ filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
1076
+ }
1077
+
1078
+ .transition-all {
1079
+ transition-property: all;
1080
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
1081
+ transition-duration: 150ms;
1082
+ }
1083
+
1084
+ .duration-100 {
1085
+ transition-duration: 100ms;
1086
+ }
1087
+
1088
+ .duration-200 {
1089
+ transition-duration: 200ms;
1090
+ }
1091
+
1092
+ .duration-300 {
1093
+ transition-duration: 300ms;
1094
+ }
1095
+
1096
+ .ease-in {
1097
+ transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
1098
+ }
1099
+
1100
+ .ease-out {
1101
+ transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
1102
+ }
1103
+
1104
+ html,
1105
+ body,
1106
+ #root {
1107
+ height: 100%;
1108
+ }
1109
+
1110
+ audio::-webkit-media-controls-panel {
1111
+ background-color: white;
1112
+ }
1113
+
1114
+ .container {
1115
+ width: 41rem
1116
+ /* 656px */
1117
+ ;
1118
+ max-width: 95vw;
1119
+ }
1120
+
1121
+ .hover\:bg-blue-800:hover {
1122
+ --tw-bg-opacity: 1;
1123
+ background-color: rgb(30 64 175 / var(--tw-bg-opacity));
1124
+ }
1125
+
1126
+ .hover\:bg-green-600:hover {
1127
+ --tw-bg-opacity: 1;
1128
+ background-color: rgb(22 163 74 / var(--tw-bg-opacity));
1129
+ }
1130
+
1131
+ .hover\:bg-indigo-200:hover {
1132
+ --tw-bg-opacity: 1;
1133
+ background-color: rgb(199 210 254 / var(--tw-bg-opacity));
1134
+ }
1135
+
1136
+ .hover\:bg-indigo-50:hover {
1137
+ --tw-bg-opacity: 1;
1138
+ background-color: rgb(238 242 255 / var(--tw-bg-opacity));
1139
+ }
1140
+
1141
+ .hover\:bg-indigo-500:hover {
1142
+ --tw-bg-opacity: 1;
1143
+ background-color: rgb(99 102 241 / var(--tw-bg-opacity));
1144
+ }
1145
+
1146
+ .hover\:bg-red-600:hover {
1147
+ --tw-bg-opacity: 1;
1148
+ background-color: rgb(220 38 38 / var(--tw-bg-opacity));
1149
+ }
1150
+
1151
+ .hover\:text-indigo-600:hover {
1152
+ --tw-text-opacity: 1;
1153
+ color: rgb(79 70 229 / var(--tw-text-opacity));
1154
+ }
1155
+
1156
+ .focus\:border-blue-500:focus {
1157
+ --tw-border-opacity: 1;
1158
+ border-color: rgb(59 130 246 / var(--tw-border-opacity));
1159
+ }
1160
+
1161
+ .focus\:outline-none:focus {
1162
+ outline: 2px solid transparent;
1163
+ outline-offset: 2px;
1164
+ }
1165
+
1166
+ .focus\:ring-4:focus {
1167
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
1168
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color);
1169
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
1170
+ }
1171
+
1172
+ .focus\:ring-blue-300:focus {
1173
+ --tw-ring-opacity: 1;
1174
+ --tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity));
1175
+ }
1176
+
1177
+ .focus\:ring-blue-500:focus {
1178
+ --tw-ring-opacity: 1;
1179
+ --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
1180
+ }
1181
+
1182
+ .focus\:ring-green-300:focus {
1183
+ --tw-ring-opacity: 1;
1184
+ --tw-ring-color: rgb(134 239 172 / var(--tw-ring-opacity));
1185
+ }
1186
+
1187
+ .focus-visible\:ring-2:focus-visible {
1188
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
1189
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
1190
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
1191
+ }
1192
+
1193
+ .focus-visible\:ring-indigo-500:focus-visible {
1194
+ --tw-ring-opacity: 1;
1195
+ --tw-ring-color: rgb(99 102 241 / var(--tw-ring-opacity));
1196
+ }
1197
+
1198
+ .focus-visible\:ring-offset-2:focus-visible {
1199
+ --tw-ring-offset-width: 2px;
1200
+ }
1201
+
1202
+ @media (prefers-color-scheme: dark) {
1203
+
1204
+ .dark\:border-gray-600 {
1205
+ --tw-border-opacity: 1;
1206
+ border-color: rgb(75 85 99 / var(--tw-border-opacity));
1207
+ }
1208
+
1209
+ .dark\:bg-blue-600 {
1210
+ --tw-bg-opacity: 1;
1211
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
1212
+ }
1213
+
1214
+ .dark\:bg-gray-700 {
1215
+ --tw-bg-opacity: 1;
1216
+ background-color: rgb(55 65 81 / var(--tw-bg-opacity));
1217
+ }
1218
+
1219
+ .dark\:bg-green-600 {
1220
+ --tw-bg-opacity: 1;
1221
+ background-color: rgb(22 163 74 / var(--tw-bg-opacity));
1222
+ }
1223
+
1224
+ .dark\:text-white {
1225
+ --tw-text-opacity: 1;
1226
+ color: rgb(255 255 255 / var(--tw-text-opacity));
1227
+ }
1228
+
1229
+ .dark\:placeholder-gray-400::-moz-placeholder {
1230
+ --tw-placeholder-opacity: 1;
1231
+ color: rgb(156 163 175 / var(--tw-placeholder-opacity));
1232
+ }
1233
+
1234
+ .dark\:placeholder-gray-400::placeholder {
1235
+ --tw-placeholder-opacity: 1;
1236
+ color: rgb(156 163 175 / var(--tw-placeholder-opacity));
1237
+ }
1238
+
1239
+ .dark\:hover\:bg-blue-700:hover {
1240
+ --tw-bg-opacity: 1;
1241
+ background-color: rgb(29 78 216 / var(--tw-bg-opacity));
1242
+ }
1243
+
1244
+ .dark\:hover\:bg-green-700:hover {
1245
+ --tw-bg-opacity: 1;
1246
+ background-color: rgb(21 128 61 / var(--tw-bg-opacity));
1247
+ }
1248
+
1249
+ .dark\:focus\:border-blue-500:focus {
1250
+ --tw-border-opacity: 1;
1251
+ border-color: rgb(59 130 246 / var(--tw-border-opacity));
1252
+ }
1253
+
1254
+ .dark\:focus\:ring-blue-500:focus {
1255
+ --tw-ring-opacity: 1;
1256
+ --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
1257
+ }
1258
+
1259
+ .dark\:focus\:ring-blue-800:focus {
1260
+ --tw-ring-opacity: 1;
1261
+ --tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity));
1262
+ }
1263
+
1264
+ .dark\:focus\:ring-green-800:focus {
1265
+ --tw-ring-opacity: 1;
1266
+ --tw-ring-color: rgb(22 101 52 / var(--tw-ring-opacity));
1267
+ }
1268
+ }
1269
+
1270
+ @media (min-width: 640px) {
1271
+
1272
+ .sm\:text-2xl {
1273
+ font-size: 1.5rem;
1274
+ line-height: 2rem;
1275
+ }
1276
+
1277
+ .sm\:text-7xl {
1278
+ font-size: 4.5rem;
1279
+ line-height: 1;
1280
+ }
1281
+ }
templates/base.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ <meta http-equiv="X-UA-Compatible" content="ie=edge">
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/tailwind.css') }}">
8
+ {% block head %}{% endblock %}
9
+ </head>
10
+ <body>
11
+ {% block body %}{% endblock %}
12
+ </body>
13
+ </html>
14
+ {% block script %}{% endblock %}
templates/index.html ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block head %}
4
+ <title>Whisper Web - Index</title>
5
+ {% endblock %}
6
+
7
+ {% block body %}
8
+ <div id="root">
9
+ <div class="flex justify-center items-center min-h-screen">
10
+ <div class="container flex flex-col justify-center items-center">
11
+ <h1 class="text-5xl font-extrabold tracking-tight text-slate-900 sm:text-7xl text-center">Whisper Web</h1>
12
+ <h2 class="mt-3 mb-5 px-4 text-center text-1xl font-semibold tracking-tight text-slate-900 sm:text-2xl">
13
+ Speech-to-Text</h2>
14
+ <div
15
+ class="flex flex-col justify-center items-center rounded-lg bg-white shadow-xl shadow-black/5 ring-1 ring-slate-700/10">
16
+ <div class="flex flex-row space-x-2 py-2 w-full px-2">
17
+ <button id="uploadButton"
18
+ class="flex items-center justify-center rounded-lg p-2 bg-blue text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 transition-all duration-200">
19
+ <div class="w-7 h-7"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
20
+ stroke-width="1.5" stroke="currentColor">
21
+ <path stroke-linecap="round" stroke-linejoin="round"
22
+ d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776">
23
+ </path>
24
+ </svg></div>
25
+ <div class="ml-2 break-text text-center text-md w-30">From file</div>
26
+ </button>
27
+ <div class="w-[1px] bg-slate-200"></div>
28
+ <button id="recordButton"
29
+ class="flex items-center justify-center rounded-lg p-2 bg-blue text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 transition-all duration-200">
30
+ <div class="w-7 h-7"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
31
+ stroke-width="1.5" stroke="currentColor">
32
+ <path stroke-linecap="round" stroke-linejoin="round"
33
+ d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z">
34
+ </path>
35
+ </svg></div>
36
+ <div id="recordText" class="ml-2 break-text text-center text-md w-30">Record</div>
37
+ </button>
38
+ </div>
39
+ <div class="w-full bg-gray-200 rounded-full h-1 dark:bg-gray-700">
40
+ <div class="bg-blue-600 h-1 rounded-full transition-all duration-100" style="width: 0%;"></div>
41
+ </div>
42
+ </div>
43
+
44
+ <!-- Hidden playback and transcribe sections -->
45
+ <div id="playback" class="flex relative z-10 p-4 w-full hidden">
46
+ <audio id="audioPlayer" controls
47
+ class="w-full h-14 rounded-lg bg-white shadow-xl shadow-black/5 ring-1 ring-slate-700/10">
48
+ <source id="audioSource" type="audio/mpeg" src="">
49
+ </audio>
50
+ </div>
51
+ <div id="transcribe" class="relative w-full flex justify-center items-center hidden">
52
+ <form action="/transcribe" method="POST" enctype="multipart/form-data">
53
+ <input type="file" name="file" id="fileInput" accept=".mp3" class="hidden">
54
+ <button type="submit"
55
+ class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 inline-flex items-center">
56
+ Transcribe Audio
57
+ </button>
58
+ </form>
59
+ </div>
60
+
61
+ <div class="w-full flex flex-col my-2 p-4 max-h-[20rem] overflow-y-auto"></div>
62
+
63
+ <!-- Text Input for Manual Lyrics Prediction -->
64
+ <div class="w-full mt-8" id="input-text">
65
+ <form action="/predict-text" method="POST" class="flex flex-col space-y-4">
66
+ <label for="lyricsInput" class="text-lg font-semibold text-slate-700">Or paste your lyric
67
+ here:</label>
68
+ <textarea name="lyrics" id="lyricsInput" rows="5"
69
+ class="w-full rounded-lg border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
70
+ placeholder="Type or paste Indonesian lyrics..."></textarea>
71
+ <br>
72
+ <button type="submit"
73
+ class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
74
+ Predict Age Group
75
+ </button>
76
+ </form>
77
+ </div>
78
+ </div>
79
+ <div class="absolute bottom-4">By Michael Natanael</div>
80
+ </div>
81
+ </div>
82
+ {% endblock %}
83
+
84
+ {% block script %}
85
+ <script>
86
+ let mediaRecorder;
87
+ let audioChunks = [];
88
+ let recordingInterval;
89
+ let seconds = 0;
90
+
91
+ // Handle file upload
92
+ document.getElementById("uploadButton").addEventListener("click", function () {
93
+ document.getElementById("fileInput").click();
94
+ });
95
+
96
+ document.getElementById("fileInput").addEventListener("change", function (event) {
97
+ if (event.target.files.length > 0) {
98
+ let file = event.target.files[0];
99
+ let fileURL = URL.createObjectURL(file);
100
+ updateAudioPlayer(fileURL);
101
+ }
102
+ });
103
+
104
+ // Handle audio recording
105
+ document.getElementById("recordButton").addEventListener("click", async function () {
106
+ if (!mediaRecorder || mediaRecorder.state === "inactive") {
107
+ startRecording();
108
+ } else {
109
+ stopRecording();
110
+ }
111
+ });
112
+
113
+ async function startRecording() {
114
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
115
+ mediaRecorder = new MediaRecorder(stream);
116
+
117
+ mediaRecorder.ondataavailable = event => {
118
+ audioChunks.push(event.data);
119
+ };
120
+
121
+ mediaRecorder.onstop = () => {
122
+ clearInterval(recordingInterval); // Stop timer
123
+ seconds = 0; // Reset timer
124
+
125
+ const audioBlob = new Blob(audioChunks, { type: 'audio/mpeg' });
126
+ const audioUrl = URL.createObjectURL(audioBlob);
127
+
128
+ // Update the file input with recorded audio
129
+ const file = new File([audioBlob], "recording.mp3", { type: "audio/mpeg" });
130
+ const fileInput = document.getElementById("fileInput");
131
+ const dataTransfer = new DataTransfer();
132
+ dataTransfer.items.add(file);
133
+ fileInput.files = dataTransfer.files;
134
+
135
+ updateAudioPlayer(audioUrl);
136
+ audioChunks = []; // Reset chunks
137
+ };
138
+
139
+ mediaRecorder.start();
140
+ startTimer();
141
+ document.getElementById("recordText").innerHTML = "Stop Recording (00:00)";
142
+ }
143
+
144
+ function startTimer() {
145
+ recordingInterval = setInterval(() => {
146
+ seconds++;
147
+ let minutes = Math.floor(seconds / 60);
148
+ let remainingSeconds = seconds % 60;
149
+ let timeString = `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
150
+
151
+ document.getElementById("recordText").innerHTML = `Stop Recording (${timeString})`;
152
+ }, 1000);
153
+ }
154
+
155
+ function stopRecording() {
156
+ mediaRecorder.stop();
157
+ document.getElementById("recordText").innerHTML = "Record";
158
+ }
159
+
160
+ // Function to update audio player and show playback & transcribe sections
161
+ function updateAudioPlayer(audioUrl) {
162
+ document.getElementById("audioSource").src = audioUrl;
163
+ document.getElementById("audioPlayer").load(); // Refresh the audio player
164
+ document.getElementById("playback").classList.remove("hidden");
165
+ document.getElementById("transcribe").classList.remove("hidden");
166
+ document.getElementById("input-text").classList.add("hidden");
167
+ }
168
+ </script>
169
+ {% endblock %}
templates/transcribe.html ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block head %}
4
+ <title>Whisper Web - Transcription</title>
5
+ {% endblock %}
6
+
7
+ {% block body %}
8
+ <div class="flex justify-center items-center min-h-screen">
9
+ <div class="container text-center">
10
+ <h1 class="text-4xl font-bold mb-6">Transcription Result</h1>
11
+ <p class="text-xl mb-4">{{ task }}</p>
12
+
13
+ <h2 class="text-2xl font-semibold text-blue-600 mt-4">Predicted Age Group:</h2>
14
+ <p class="text-lg">{{ prediction }}</p>
15
+
16
+ <div class="mt-4 text-left inline-block">
17
+ <h3 class="text-md font-semibold">Class Probabilities:</h3>
18
+ <ul class="mt-2">
19
+ {% for label, prob in probabilities %}
20
+ <li>{{ label }}: {{ prob }}</li>
21
+ {% endfor %}
22
+ </ul>
23
+ </div>
24
+
25
+ {% if total_time %}
26
+ <div class="mt-4 text-center text-sm text-gray-500">
27
+ ⏱️ Total Processing Time: <strong>{{ total_time }}</strong>
28
+ </div>
29
+ {% endif %}
30
+ </div>
31
+ </div>
32
+ {% endblock %}