Manubett1234 commited on
Commit
1c19314
Β·
verified Β·
1 Parent(s): 1883889

Upload 15 files

Browse files
__pycache__/extract.cpython-312.pyc ADDED
Binary file (4.1 kB). View file
 
app.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pickle
3
+ import joblib
4
+ import numpy as np
5
+ from flask_cors import CORS
6
+ from flask import Flask, request, render_template, jsonify
7
+ from werkzeug.utils import secure_filename
8
+ from extract import extract_features # Import feature extractor
9
+
10
+ # Initialize Flask app
11
+ app = Flask(__name__)
12
+ CORS(app) # Allow all cross-origin requests
13
+
14
+ # Set upload folder and allowed file types
15
+ UPLOAD_FOLDER = "uploads"
16
+ ALLOWED_EXTENSIONS = {"wav", "mp3", "ogg", "m4a"}
17
+ app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
18
+ os.makedirs(UPLOAD_FOLDER, exist_ok=True)
19
+
20
+ # Load trained model, scaler, and feature list
21
+ model_path = "models/gender_model_lr.pkl"
22
+ scaler_path = "models/scaler_gender_model_lr.pkl"
23
+ feature_list_path = "models/feature_list.pkl"
24
+
25
+ model = joblib.load(model_path)
26
+ scaler = joblib.load(scaler_path)
27
+ with open(feature_list_path, "rb") as f:
28
+ feature_list = pickle.load(f)
29
+
30
+ print("βœ… Model, Scaler, and Feature List Loaded Successfully!")
31
+
32
+ # Function to check valid file extensions
33
+ def allowed_file(filename):
34
+ return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
35
+
36
+ # Route to render the HTML interface
37
+ @app.route("/")
38
+ def index():
39
+ return render_template("index.html")
40
+
41
+ # Route to handle file upload and prediction
42
+ @app.route("/predict", methods=["POST"])
43
+ def predict():
44
+ if "audio" not in request.files:
45
+ print("❌ No file uploaded")
46
+ return jsonify({"error": "No file uploaded"}), 400
47
+
48
+ file = request.files["audio"]
49
+ print(f"πŸ“₯ Received file: {file.filename}, Type: {file.content_type}") # βœ… Debugging line
50
+
51
+ if file.filename == "":
52
+ return jsonify({"error": "No selected file"}), 400
53
+
54
+ if file and allowed_file(file.filename):
55
+ filename = secure_filename(file.filename)
56
+ filepath = os.path.join(app.config["UPLOAD_FOLDER"], filename)
57
+ file.save(filepath)
58
+
59
+ print(f"🟒 Processing file: {filename}")
60
+
61
+ try:
62
+ # Extract features
63
+ features = extract_features(filepath)
64
+ if features is None:
65
+ return jsonify({"error": "Feature extraction failed"}), 500
66
+
67
+ print(f"🟒 Extracted {len(features)} features.")
68
+
69
+ # Scale features
70
+ features_scaled = scaler.transform([features])
71
+ print("🟒 Features scaled successfully.")
72
+
73
+ # Predict gender
74
+ prediction = model.predict(features_scaled)[0]
75
+ confidence = model.predict_proba(features_scaled)[0]
76
+ print("🟒 Prediction completed.")
77
+
78
+ # Format response
79
+ result = {
80
+ "gender": "Female" if prediction == 1 else "Male",
81
+ "confidence": float(max(confidence)),
82
+ "age_group": "Unknown" # Temporary fix to avoid breaking frontend
83
+ }
84
+
85
+ print(f"βœ… Result: {result}")
86
+
87
+ return jsonify(result)
88
+
89
+ except Exception as e:
90
+ print(f"❌ Error: {e}")
91
+ return jsonify({"error": str(e)}), 500
92
+ finally:
93
+ os.remove(filepath) # Delete temp file
94
+
95
+ return jsonify({"error": "Invalid file format"}), 400
96
+
97
+ # Run Flask app
98
+ if __name__ == "__main__":
99
+ app.run(debug=True, use_reloader=False) # Disable reloader
extract.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import librosa
2
+ import numpy as np
3
+
4
+ def extract_features(file_path):
5
+ try:
6
+ print(f"🟒 Processing file: {file_path}")
7
+
8
+ # Load audio with fixed sample rate
9
+ y, sr = librosa.load(file_path, sr=16000)
10
+
11
+ # Trim or pad audio to exactly 5 seconds
12
+ target_length = sr * 5
13
+ if len(y) > target_length:
14
+ start_sample = np.random.randint(0, len(y) - target_length)
15
+ y = y[start_sample:start_sample + target_length]
16
+ elif len(y) < target_length:
17
+ y = np.pad(y, (0, target_length - len(y)), mode='constant')
18
+
19
+ print("βœ… Audio loaded and standardized (5s, 16kHz)")
20
+
21
+ # Extract features
22
+ mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
23
+ chroma = librosa.feature.chroma_stft(y=y, sr=sr)
24
+ spec_contrast = librosa.feature.spectral_contrast(y=y, sr=sr)
25
+ zcr = librosa.feature.zero_crossing_rate(y)
26
+ rms = librosa.feature.rms(y=y)
27
+ centroid = librosa.feature.spectral_centroid(y=y, sr=sr)
28
+ bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr)
29
+ rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr)
30
+ hnr = librosa.effects.harmonic(y)
31
+ pitches, _ = librosa.piptrack(y=y, sr=sr)
32
+
33
+ # Aggregate features (mean + std for each feature)
34
+ features = {
35
+ "mfcc": np.concatenate([np.mean(mfcc, axis=1), np.std(mfcc, axis=1)]),
36
+ "chroma": np.concatenate([np.mean(chroma, axis=1), np.std(chroma, axis=1)]),
37
+ "spectral_contrast": np.concatenate([np.mean(spec_contrast, axis=1), np.std(spec_contrast, axis=1)]),
38
+ "zcr": [np.mean(zcr), np.std(zcr)],
39
+ "rms": [np.mean(rms), np.std(rms)],
40
+ "centroid": [np.mean(centroid), np.std(centroid)],
41
+ "bandwidth": [np.mean(bandwidth), np.std(bandwidth)],
42
+ "rolloff": [np.mean(rolloff), np.std(rolloff)],
43
+ "hnr": [np.mean(hnr), np.std(hnr)],
44
+ "pitch": [np.mean(pitches), np.std(pitches)]
45
+ }
46
+
47
+ # Flatten all feature arrays into a single list
48
+ feature_vector = []
49
+ for value in features.values():
50
+ feature_vector.extend(value)
51
+
52
+ print(f"βœ… Features successfully extracted and formatted (Total: {len(feature_vector)} features)")
53
+ return feature_vector
54
+ except Exception as e:
55
+ print(f"❌ Error extracting features from {file_path}: {e}")
56
+ return None
features.csv ADDED
The diff for this file is too large to render. See raw diff
 
models/feature_list.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:89684fc58e221ad3db3297bb778f0d6e2e29a3a9cebf2aeadbb8b259dfde5db4
3
+ size 1050
models/gender_model_lr.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9f41099b986d8c0257fb683b11ffd362db279a856050c1cc5f0a41affbbbe63e
3
+ size 1300
models/gender_model_rf.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8b176461a30fd2ff9aa512efe5c47c0e68e8c98eced4f11361543dbc01625042
3
+ size 8615017
models/gender_model_svm.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b63369fa0c52524cfe50643a330f4689eeca01b0073c9d035e1c4ef04c37608e
3
+ size 1050972
models/scaler_gender_model_lr.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:53dddf6e8eb645af405c4329c6cdbec249dff72cd1b747756b8808dad8ded445
3
+ size 3451
models/scaler_gender_model_rf.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:53dddf6e8eb645af405c4329c6cdbec249dff72cd1b747756b8808dad8ded445
3
+ size 3451
models/scaler_gender_model_svm.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:53dddf6e8eb645af405c4329c6cdbec249dff72cd1b747756b8808dad8ded445
3
+ size 3451
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ flask
2
+ flask-cors
3
+ numpy
4
+ librosa
5
+ joblib
6
+ scikit-learn
7
+ soundfile
8
+ praat-parselmouth
9
+ scipy
static/script.js ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_URL = 'http://localhost:5000';
2
+
3
+ // Page Navigation
4
+ function showPage(pageId) {
5
+ document.querySelectorAll('.page').forEach(page => page.classList.add('hidden'));
6
+ document.getElementById(pageId).classList.remove('hidden');
7
+ }
8
+
9
+ // Drag & Drop Handling
10
+ const dropZone = document.getElementById('drop-zone');
11
+ const fileInput = document.getElementById('file-input');
12
+ const analyzeBtn = document.getElementById('analyze-btn');
13
+ const audioPlayback = document.getElementById('audio-playback');
14
+ let audioFile = null;
15
+
16
+ dropZone.addEventListener('dragover', (e) => {
17
+ e.preventDefault();
18
+ dropZone.classList.add('drag-over');
19
+ });
20
+
21
+ dropZone.addEventListener('dragleave', () => {
22
+ dropZone.classList.remove('drag-over');
23
+ });
24
+
25
+ dropZone.addEventListener('drop', (e) => {
26
+ e.preventDefault();
27
+ dropZone.classList.remove('drag-over');
28
+
29
+ if (e.dataTransfer.files.length > 0) {
30
+ handleFile(e.dataTransfer.files[0]);
31
+ }
32
+ });
33
+
34
+ fileInput.addEventListener('change', (e) => {
35
+ handleFile(e.target.files[0]);
36
+ });
37
+
38
+ function handleFile(file) {
39
+ if (file && file.type.startsWith('audio/')) {
40
+ audioFile = file;
41
+ analyzeBtn.disabled = false;
42
+ audioPlayback.src = URL.createObjectURL(file);
43
+ audioPlayback.classList.remove('hidden');
44
+ } else {
45
+ showError('Please upload a valid audio file');
46
+ }
47
+ }
48
+
49
+ // Voice Recording
50
+ let mediaRecorder;
51
+ let audioChunks = [];
52
+ const recordBtn = document.getElementById('record-btn');
53
+ const recordingStatus = document.getElementById('recording-status');
54
+
55
+ recordBtn.addEventListener('click', async () => {
56
+ try {
57
+ if (recordBtn.textContent === '🎀 Record Voice') {
58
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
59
+ mediaRecorder = new MediaRecorder(stream);
60
+ audioChunks = [];
61
+
62
+ mediaRecorder.ondataavailable = (e) => {
63
+ audioChunks.push(e.data);
64
+ };
65
+
66
+ mediaRecorder.onstop = () => {
67
+ audioFile = new Blob(audioChunks, { type: 'audio/wav' });
68
+ analyzeBtn.disabled = false;
69
+ audioPlayback.src = URL.createObjectURL(audioFile);
70
+ audioPlayback.classList.remove('hidden');
71
+ recordingStatus.textContent = 'Recording saved!';
72
+ };
73
+
74
+ mediaRecorder.start();
75
+ recordBtn.textContent = '⏹ Stop Recording';
76
+ recordingStatus.textContent = 'Recording...';
77
+ } else {
78
+ mediaRecorder.stop();
79
+ recordBtn.textContent = '🎀 Record Voice';
80
+ }
81
+ } catch (error) {
82
+ showError('Unable to access microphone');
83
+ }
84
+ });
85
+
86
+ // Processing & Prediction
87
+ analyzeBtn.addEventListener('click', async () => {
88
+ showPage('processing-page');
89
+ const progressBar = document.getElementById('progress-bar');
90
+
91
+ try {
92
+ const formData = new FormData();
93
+ formData.append('audio', audioFile, "recorded_audio.wav");
94
+
95
+ const response = await fetch(`${API_URL}/predict`, {
96
+ method: 'POST',
97
+ body: formData
98
+ });
99
+
100
+ if (!response.ok) throw new Error('Prediction failed');
101
+
102
+ const results = await response.json();
103
+ progressBar.style.width = '100%';
104
+ displayResults(results);
105
+ } catch (error) {
106
+ showError('Analysis failed. Please try again.');
107
+ }
108
+ });
109
+
110
+ function displayResults(results) {
111
+ document.getElementById('age-result').textContent = results.age_group;
112
+ document.getElementById('gender-result').textContent = results.gender;
113
+ showPage('results-page');
114
+ }
115
+
116
+ function showError(message) {
117
+ document.getElementById('error-message').textContent = message;
118
+ showPage('error-page');
119
+ }
120
+
121
+ function downloadReport() {
122
+ const results = {
123
+ age: document.getElementById('age-result').textContent,
124
+ gender: document.getElementById('gender-result').textContent,
125
+ timestamp: new Date().toISOString()
126
+ };
127
+
128
+ const blob = new Blob([JSON.stringify(results, null, 2)], { type: 'application/json' });
129
+ const url = URL.createObjectURL(blob);
130
+ const a = document.createElement('a');
131
+ a.href = url;
132
+ a.download = 'voice-analysis-report.json';
133
+ a.click();
134
+ }
static/styles.css ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Color Theme */
2
+ :root {
3
+ --primary-color: #008080; /* Teal */
4
+ --secondary-color: #005f5f;
5
+ --error-color: #f44336;
6
+ }
7
+
8
+ /* Reset & Base Styles */
9
+ body {
10
+ font-family: 'Arial', sans-serif;
11
+ margin: 0;
12
+ padding: 0;
13
+ background-color: #f0f8f8;
14
+ }
15
+
16
+ /* Sticky Header */
17
+ .sticky-header {
18
+ position: sticky;
19
+ top: 0;
20
+ background-color: var(--primary-color);
21
+ color: white;
22
+ padding: 10px 20px;
23
+ display: flex;
24
+ justify-content: space-between;
25
+ align-items: center;
26
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
27
+ z-index: 1000;
28
+ }
29
+
30
+ .sticky-header h1 {
31
+ margin: 0;
32
+ font-size: 20px;
33
+ }
34
+
35
+ .sticky-header nav ul {
36
+ list-style: none;
37
+ margin: 0;
38
+ padding: 0;
39
+ display: flex;
40
+ }
41
+
42
+ .sticky-header nav ul li {
43
+ margin: 0 10px;
44
+ }
45
+
46
+ .sticky-header nav ul li a {
47
+ color: white;
48
+ text-decoration: none;
49
+ font-weight: bold;
50
+ }
51
+
52
+ .sticky-header nav ul li a:hover {
53
+ text-decoration: underline;
54
+ }
55
+
56
+ /* Page Layout */
57
+ .page {
58
+ max-width: 800px;
59
+ margin: 50px auto;
60
+ padding: 20px;
61
+ background: white;
62
+ border-radius: 8px;
63
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
64
+ }
65
+
66
+ /* Hidden Pages */
67
+ .hidden {
68
+ display: none;
69
+ }
70
+
71
+ /* Upload Section */
72
+ .upload-container {
73
+ border: 2px dashed var(--primary-color);
74
+ padding: 20px;
75
+ text-align: center;
76
+ margin: 20px 0;
77
+ cursor: pointer;
78
+ transition: all 0.3s ease;
79
+ }
80
+
81
+ .upload-container.drag-over {
82
+ background: rgba(0, 128, 128, 0.1);
83
+ }
84
+
85
+ /* Progress Bar */
86
+ .progress-container {
87
+ width: 100%;
88
+ height: 20px;
89
+ background-color: #f0f0f0;
90
+ border-radius: 10px;
91
+ overflow: hidden;
92
+ margin: 20px 0;
93
+ }
94
+
95
+ .progress-bar {
96
+ width: 0%;
97
+ height: 100%;
98
+ background-color: var(--primary-color);
99
+ transition: width 0.3s ease;
100
+ }
101
+
102
+ /* Results */
103
+ .results-container {
104
+ display: flex;
105
+ justify-content: space-around;
106
+ margin: 20px 0;
107
+ }
108
+
109
+ .result-card {
110
+ padding: 20px;
111
+ border: 1px solid #ddd;
112
+ border-radius: 8px;
113
+ text-align: center;
114
+ background: #e0f7f7;
115
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
116
+ }
117
+
118
+ /* Buttons */
119
+ button {
120
+ padding: 10px 20px;
121
+ border: none;
122
+ border-radius: 4px;
123
+ background-color: var(--primary-color);
124
+ color: white;
125
+ cursor: pointer;
126
+ transition: background-color 0.3s;
127
+ font-size: 16px;
128
+ }
129
+
130
+ button:hover {
131
+ background-color: var(--secondary-color);
132
+ }
133
+
134
+ button:disabled {
135
+ background-color: #cccccc;
136
+ cursor: not-allowed;
137
+ }
138
+
139
+ /* Charts */
140
+ .charts-container {
141
+ display: flex;
142
+ flex-wrap: wrap;
143
+ justify-content: space-around;
144
+ gap: 20px;
145
+ margin: 20px 0;
146
+ }
147
+
148
+ .chart-wrapper {
149
+ background: white;
150
+ padding: 15px;
151
+ border-radius: 8px;
152
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
153
+ }
templates/index.html ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Voice Age & Gender Predictor</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
8
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
9
+ </head>
10
+ <body>
11
+
12
+ <!-- Header -->
13
+ <header class="sticky-header">
14
+ <h1>Voice Age & Gender Predictor</h1>
15
+ <nav>
16
+ <ul>
17
+ <li><a href="#" onclick="showPage('landing-page')">Home</a></li>
18
+ <li><a href="#" onclick="showPage('upload-page')">Upload</a></li>
19
+ <li><a href="#" onclick="showPage('results-page')">Results</a></li>
20
+ </ul>
21
+ </nav>
22
+ </header>
23
+
24
+ <!-- Landing Page -->
25
+ <div class="page" id="landing-page">
26
+ <h2>Discover Your Voice Characteristics</h2>
27
+ <p>Analyze your voice with AI technology.</p>
28
+ <button onclick="showPage('upload-page')">Start Analysis</button>
29
+ </div>
30
+
31
+ <!-- Upload Page -->
32
+ <div class="page hidden" id="upload-page">
33
+ <h2>Upload or Record Audio</h2>
34
+ <div class="upload-container" id="drop-zone">
35
+ <input type="file" id="file-input" accept="audio/*" />
36
+ <p>Drag & Drop or Click to Upload</p>
37
+ </div>
38
+ <div class="record-container">
39
+ <button id="record-btn">🎀 Record Voice</button>
40
+ <div id="recording-status"></div>
41
+ <audio id="audio-playback" controls class="hidden"></audio>
42
+ </div>
43
+ <button id="analyze-btn" disabled>Analyze Voice</button>
44
+ </div>
45
+
46
+ <!-- Processing Page -->
47
+ <div class="page hidden" id="processing-page">
48
+ <h2>Processing Audio...</h2>
49
+ <div class="progress-container">
50
+ <div class="progress-bar" id="progress-bar"></div>
51
+ </div>
52
+ <p id="processing-status">Analyzing your voice...</p>
53
+ </div>
54
+
55
+ <!-- Results Page -->
56
+ <div class="page hidden" id="results-page">
57
+ <h2>Analysis Results</h2>
58
+ <div class="results-container">
59
+ <div class="result-card">
60
+ <h3>Predicted Age</h3>
61
+ <p id="age-result"></p>
62
+ </div>
63
+ <div class="result-card">
64
+ <h3>Predicted Gender</h3>
65
+ <p id="gender-result"></p>
66
+ </div>
67
+ </div>
68
+ <div class="charts-container">
69
+ <canvas id="confidence-chart"></canvas>
70
+ <canvas id="gender-chart"></canvas>
71
+ <canvas id="age-chart"></canvas>
72
+ </div>
73
+ <div class="action-buttons">
74
+ <button onclick="showPage('upload-page')">πŸ”„ Try Again</button>
75
+ <button onclick="downloadReport()">πŸ“₯ Download Report</button>
76
+ </div>
77
+ </div>
78
+
79
+ <script src="{{ url_for('static', filename='script.js') }}"></script>
80
+ </body>
81
+ </html>