Add application file
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- README.md +4 -4
- filler_count/__init__.py +0 -0
- filler_count/__pycache__/__init__.cpython-312.pyc +0 -0
- filler_count/__pycache__/filler_score.cpython-312.pyc +0 -0
- filler_count/filler_score.py +24 -0
- fluency/__init__.py +13 -0
- fluency/__pycache__/__init__.cpython-312.pyc +0 -0
- fluency/__pycache__/compute_fluency.cpython-312.pyc +0 -0
- fluency/__pycache__/filler_analyzer.cpython-312.pyc +0 -0
- fluency/__pycache__/fluency.cpython-312.pyc +0 -0
- fluency/__pycache__/fluency_api.cpython-312.pyc +0 -0
- fluency/__pycache__/main.cpython-312.pyc +0 -0
- fluency/compute_fluency.py +106 -0
- fluency/filler_analyzer.py +100 -0
- fluency/fluency.py +149 -0
- fluency/fluency_api.py +22 -0
- fluency/main.py +49 -0
- requirements.txt +19 -0
- tone_modulation/__init__.py +0 -0
- tone_modulation/__pycache__/__init__.cpython-312.pyc +0 -0
- tone_modulation/__pycache__/sds.cpython-312.pyc +0 -0
- tone_modulation/__pycache__/tone_api.cpython-312.pyc +0 -0
- tone_modulation/sds.py +385 -0
- tone_modulation/tone_api.py +23 -0
- transcribe.py +24 -0
- vcs/__init__.py +0 -0
- vcs/__pycache__/__init__.cpython-312.pyc +0 -0
- vcs/__pycache__/compute_vcs.cpython-312.pyc +0 -0
- vcs/__pycache__/main.cpython-312.pyc +0 -0
- vcs/__pycache__/vcs.cpython-312.pyc +0 -0
- vcs/__pycache__/vcs_api.cpython-312.pyc +0 -0
- vcs/compute_vcs.py +117 -0
- vcs/main.py +49 -0
- vcs/vcs.py +176 -0
- vcs/vcs_api.py +21 -0
- vers/__init__.py +0 -0
- vers/__pycache__/__init__.cpython-312.pyc +0 -0
- vers/__pycache__/compute_vers_score.cpython-312.pyc +0 -0
- vers/__pycache__/filler_analyzer.cpython-312.pyc +0 -0
- vers/__pycache__/find_valence.cpython-312.pyc +0 -0
- vers/__pycache__/main.cpython-312.pyc +0 -0
- vers/__pycache__/vers.cpython-312.pyc +0 -0
- vers/__pycache__/vers_api.cpython-312.pyc +0 -0
- vers/compute_vers_score.py +85 -0
- vers/filler_analyzer.py +101 -0
- vers/find_valence.py +100 -0
- vers/main.py +16 -0
- vers/vers.py +118 -0
- vers/vers_api.py +44 -0
- ves/__init__.py +0 -0
README.md
CHANGED
@@ -1,8 +1,8 @@
|
|
1 |
---
|
2 |
-
title:
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: docker
|
7 |
pinned: false
|
8 |
license: mit
|
|
|
1 |
---
|
2 |
+
title: Voice Deploy
|
3 |
+
emoji: 🏢
|
4 |
+
colorFrom: green
|
5 |
+
colorTo: gray
|
6 |
sdk: docker
|
7 |
pinned: false
|
8 |
license: mit
|
filler_count/__init__.py
ADDED
File without changes
|
filler_count/__pycache__/__init__.cpython-312.pyc
ADDED
Binary file (138 Bytes). View file
|
|
filler_count/__pycache__/filler_score.cpython-312.pyc
ADDED
Binary file (1.6 kB). View file
|
|
filler_count/filler_score.py
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import re
|
2 |
+
import whisper
|
3 |
+
|
4 |
+
def analyze_fillers(file_path: str, model_size: str = "base") -> dict:
|
5 |
+
try:
|
6 |
+
FILLER_WORDS = ["um", "uh", "hmm", "ah", "er", "eh", "like", "you know", "well"]
|
7 |
+
|
8 |
+
model = whisper.load_model(model_size)
|
9 |
+
result = model.transcribe(file_path, word_timestamps=False, fp16=False)
|
10 |
+
transcript = result["text"]
|
11 |
+
|
12 |
+
pattern = r"\b(" + "|".join(FILLER_WORDS) + r")\b"
|
13 |
+
matches = re.findall(pattern, transcript.lower())
|
14 |
+
|
15 |
+
filler_counts = {filler: matches.count(filler) for filler in FILLER_WORDS}
|
16 |
+
total_fillers = sum(filler_counts.values())
|
17 |
+
|
18 |
+
return {
|
19 |
+
# "transcript": transcript,
|
20 |
+
"filler_counts": {k: v for k, v in filler_counts.items() if v > 0},
|
21 |
+
"total_fillers": total_fillers
|
22 |
+
}
|
23 |
+
except Exception as e:
|
24 |
+
raise RuntimeError(f"Error during analysis: {str(e)}")
|
fluency/__init__.py
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# fluency/__init__.py
|
2 |
+
from .fluency import calc_srs, calculate_pas, calculate_fluency, get_fluency_insight
|
3 |
+
from .filler_analyzer import detect_fillers
|
4 |
+
from .compute_fluency import compute_fluency_score
|
5 |
+
|
6 |
+
__all__ = [
|
7 |
+
'calc_srs',
|
8 |
+
'calculate_pas',
|
9 |
+
'calculate_fluency',
|
10 |
+
'get_fluency_insight',
|
11 |
+
'detect_fillers',
|
12 |
+
'compute_fluency_score'
|
13 |
+
]
|
fluency/__pycache__/__init__.cpython-312.pyc
ADDED
Binary file (434 Bytes). View file
|
|
fluency/__pycache__/compute_fluency.cpython-312.pyc
ADDED
Binary file (3.66 kB). View file
|
|
fluency/__pycache__/filler_analyzer.cpython-312.pyc
ADDED
Binary file (4.32 kB). View file
|
|
fluency/__pycache__/fluency.cpython-312.pyc
ADDED
Binary file (5.85 kB). View file
|
|
fluency/__pycache__/fluency_api.cpython-312.pyc
ADDED
Binary file (868 Bytes). View file
|
|
fluency/__pycache__/main.cpython-312.pyc
ADDED
Binary file (2.58 kB). View file
|
|
fluency/compute_fluency.py
ADDED
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Compute fluency score from audio file using SRS and PAS calculations
|
3 |
+
"""
|
4 |
+
|
5 |
+
import librosa
|
6 |
+
import numpy as np
|
7 |
+
from typing import Dict, Any, Union
|
8 |
+
from .fluency import calc_srs, calculate_pas, calculate_fluency, get_fluency_insight
|
9 |
+
from .filler_analyzer import detect_fillers
|
10 |
+
|
11 |
+
def compute_fluency_score(file_path: str, whisper_model) -> Dict[str, Any]:
|
12 |
+
"""
|
13 |
+
Compute fluency score and its components from a speech sample.
|
14 |
+
|
15 |
+
Args:
|
16 |
+
file_path (str): Path to the audio file.
|
17 |
+
whisper_model: Transcription model (e.g., OpenAI Whisper or faster-whisper)
|
18 |
+
|
19 |
+
Returns:
|
20 |
+
dict: A dictionary containing fluency score, SRS, PAS, and component scores.
|
21 |
+
"""
|
22 |
+
# Transcribe audio
|
23 |
+
result = whisper_model.transcribe(file_path)
|
24 |
+
transcript = result.get("text", "").strip()
|
25 |
+
segments = result.get("segments", [])
|
26 |
+
|
27 |
+
# Validate early
|
28 |
+
if not transcript or not segments:
|
29 |
+
raise ValueError("Empty transcript or segments from Whisper.")
|
30 |
+
|
31 |
+
# Detect filler words
|
32 |
+
filler_count, _ = detect_fillers(transcript)
|
33 |
+
|
34 |
+
# Load audio
|
35 |
+
y, sr = librosa.load(file_path, sr=None)
|
36 |
+
duration = len(y) / sr if sr else 0.0
|
37 |
+
if duration <= 0:
|
38 |
+
raise ValueError("Audio duration invalid or zero.")
|
39 |
+
|
40 |
+
# Calculate pitch variation (in semitones)
|
41 |
+
f0, voiced_flags, voiced_probs = librosa.pyin(
|
42 |
+
y, sr=sr, fmin=80, fmax=400, frame_length=1024, hop_length=256, fill_na=np.nan)
|
43 |
+
voiced_f0 = f0[~np.isnan(f0)]
|
44 |
+
pitch_variation = 0.0
|
45 |
+
if voiced_f0.size > 0:
|
46 |
+
median_f0 = np.nanmedian(voiced_f0)
|
47 |
+
median_f0 = max(median_f0, 1e-6)
|
48 |
+
semitone_diffs = 12 * np.log2(voiced_f0 / median_f0)
|
49 |
+
pitch_variation = float(np.nanstd(semitone_diffs))
|
50 |
+
|
51 |
+
# Analyze pauses
|
52 |
+
long_pause_count = 0
|
53 |
+
if segments:
|
54 |
+
for i in range(len(segments) - 1):
|
55 |
+
pause_dur = segments[i + 1]["start"] - segments[i]["end"]
|
56 |
+
if pause_dur > 1.0:
|
57 |
+
long_pause_count += 1
|
58 |
+
# Check beginning and end pauses
|
59 |
+
if segments[0]["start"] > 1.0:
|
60 |
+
long_pause_count += 1
|
61 |
+
if duration - segments[-1]["end"] > 1.0:
|
62 |
+
long_pause_count += 1
|
63 |
+
|
64 |
+
# Calculate WPM
|
65 |
+
word_count = len(transcript.split())
|
66 |
+
words_per_min = (word_count / duration) * 60.0 if duration > 0 else 0.0
|
67 |
+
|
68 |
+
# Calculate SRS - Speech Rate Stability
|
69 |
+
srs_score = calc_srs(
|
70 |
+
wpm=words_per_min,
|
71 |
+
filler_count=filler_count,
|
72 |
+
long_pause_count=long_pause_count,
|
73 |
+
pitch_variation=pitch_variation
|
74 |
+
)
|
75 |
+
|
76 |
+
# Calculate PAS - Pause Appropriateness Score
|
77 |
+
pas_result = calculate_pas(
|
78 |
+
transcript=transcript,
|
79 |
+
segments=segments,
|
80 |
+
filler_count=filler_count,
|
81 |
+
duration=duration
|
82 |
+
)
|
83 |
+
pas_score = pas_result["PAS"]
|
84 |
+
|
85 |
+
# Calculate final fluency score
|
86 |
+
fluency_result = calculate_fluency(srs=srs_score, pas=pas_score)
|
87 |
+
fluency_score = fluency_result["score"]
|
88 |
+
insight = get_fluency_insight(fluency_score)
|
89 |
+
|
90 |
+
# Build and return comprehensive result
|
91 |
+
return {
|
92 |
+
"fluency_score": fluency_score,
|
93 |
+
"insight": insight,
|
94 |
+
"SRS": srs_score,
|
95 |
+
"PAS": pas_score,
|
96 |
+
"components": {
|
97 |
+
"wpm": words_per_min,
|
98 |
+
"filler_count": filler_count,
|
99 |
+
"long_pause_count": long_pause_count,
|
100 |
+
"pitch_variation": pitch_variation,
|
101 |
+
"word_count": word_count,
|
102 |
+
"duration": duration,
|
103 |
+
"pas_components": pas_result
|
104 |
+
},
|
105 |
+
"transcript": transcript
|
106 |
+
}
|
fluency/filler_analyzer.py
ADDED
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Define filler words for English, Hindi, Tamil (in both Latin and native scripts)
|
2 |
+
# Mapping each variant to a common label (usually the Latin script for insight reporting)
|
3 |
+
FILLER_VARIANTS = {
|
4 |
+
# English fillers
|
5 |
+
"um": "um", "uh": "uh", "hmm": "hmm", "ah": "ah", "er": "er",
|
6 |
+
"umm": "um", "uhh": "uh", "mmm": "hmm",
|
7 |
+
"like": "like", "you know": "you know", "so": "so", "well": "well",
|
8 |
+
# Hindi fillers (Devanagari and transliteration)
|
9 |
+
"मतलब": "matlab", "matlab": "matlab",
|
10 |
+
"क्या कहते हैं": "kya kehte hain", "kya kehte hain": "kya kehte hain",
|
11 |
+
"वो ना": "wo na", "woh na": "wo na", "wo na": "wo na",
|
12 |
+
"ऐसा है": "aisa hai", "aisa hai": "aisa hai",
|
13 |
+
"हाँ": "haan", "haan": "haan", "हा": "haan", # "हा" might appear as a shorter "haan"
|
14 |
+
"अच्छा": "acha", "acha": "acha",
|
15 |
+
# Tamil fillers (Tamil script and transliteration)
|
16 |
+
"பாத்தீங்கனா": "paatheenga-na", "paatheenga na": "paatheenga-na", "paatheenga-na": "paatheenga-na",
|
17 |
+
"அப்பரம்": "apparam", "apparam": "apparam",
|
18 |
+
"என்ன": "enna", "enna": "enna"
|
19 |
+
}
|
20 |
+
|
21 |
+
def detect_fillers(transcript):
|
22 |
+
"""
|
23 |
+
Detects filler words in the transcript.
|
24 |
+
|
25 |
+
Args:
|
26 |
+
transcript: Full transcript text
|
27 |
+
|
28 |
+
Returns:
|
29 |
+
tuple: (filler_count, filler_occurrences)
|
30 |
+
"""
|
31 |
+
transcript_lower = transcript.lower()
|
32 |
+
filler_count = 0
|
33 |
+
# Track which specific fillers were used (for insight examples)
|
34 |
+
filler_occurrences = {}
|
35 |
+
|
36 |
+
for variant, label in FILLER_VARIANTS.items():
|
37 |
+
if variant in transcript_lower:
|
38 |
+
count = transcript_lower.count(variant)
|
39 |
+
if count > 0:
|
40 |
+
filler_count += count
|
41 |
+
# Accumulate count for the normalized label
|
42 |
+
filler_occurrences[label] = filler_occurrences.get(label, 0) + count
|
43 |
+
|
44 |
+
return filler_count, filler_occurrences
|
45 |
+
|
46 |
+
def analyze_filler_words(filler_count, filler_occurrences, duration):
|
47 |
+
"""
|
48 |
+
Analyzes filler word usage in speech.
|
49 |
+
|
50 |
+
Args:
|
51 |
+
filler_count: Total count of filler words
|
52 |
+
filler_occurrences: Dictionary of specific filler words and their counts
|
53 |
+
duration: Duration of the audio in seconds
|
54 |
+
|
55 |
+
Returns:
|
56 |
+
dict: Contains the filler words score and insight text
|
57 |
+
"""
|
58 |
+
# Extract top examples for insights
|
59 |
+
filler_examples = []
|
60 |
+
if filler_occurrences:
|
61 |
+
# Sort by frequency
|
62 |
+
sorted_fillers = sorted(filler_occurrences.items(), key=lambda x: x[1], reverse=True)
|
63 |
+
for label, count in sorted_fillers[:2]:
|
64 |
+
filler_examples.append(label)
|
65 |
+
|
66 |
+
# Compute fillers per minute as a gauge
|
67 |
+
filler_per_min = (filler_count / duration) * 60.0 if duration > 0 else 0.0
|
68 |
+
|
69 |
+
if filler_count == 0:
|
70 |
+
filler_score = 10
|
71 |
+
elif filler_per_min < 1:
|
72 |
+
filler_score = 9
|
73 |
+
elif filler_per_min < 3:
|
74 |
+
filler_score = 8
|
75 |
+
elif filler_per_min < 5:
|
76 |
+
filler_score = 6
|
77 |
+
elif filler_per_min < 10:
|
78 |
+
filler_score = 4
|
79 |
+
else:
|
80 |
+
filler_score = 2
|
81 |
+
|
82 |
+
filler_score = max(0, filler_score)
|
83 |
+
|
84 |
+
# Generate insight text based on the score and examples
|
85 |
+
if filler_count == 0:
|
86 |
+
insight = "No filler words (um, ah, etc.) were detected, keeping the speech very clear."
|
87 |
+
elif filler_count <= 2:
|
88 |
+
example = filler_examples[0] if filler_examples else "um"
|
89 |
+
insight = f"Only a couple of filler words (e.g., '{example}') were used, which had minimal impact."
|
90 |
+
elif filler_count <= 5:
|
91 |
+
examples = ", ".join(f"'{ex}'" for ex in filler_examples) if filler_examples else "filler words"
|
92 |
+
insight = f"Some filler words {examples} were used occasionally; reducing them could improve clarity."
|
93 |
+
else:
|
94 |
+
examples = ", ".join(f"'{ex}'" for ex in filler_examples) if filler_examples else "'um'"
|
95 |
+
insight = f"Frequent filler words such as {examples} were detected, which can distract the audience and suggest uncertainty."
|
96 |
+
|
97 |
+
return {
|
98 |
+
"score": int(filler_score),
|
99 |
+
"insight": insight
|
100 |
+
}
|
fluency/fluency.py
ADDED
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
|
3 |
+
import spacy
|
4 |
+
from typing import List, Dict
|
5 |
+
|
6 |
+
def calc_srs(wpm, filler_count, long_pause_count, pitch_variation):
|
7 |
+
"""
|
8 |
+
Speech Rate Stability (SRS): Reflects the consistency of the speaker's pace and rhythm.
|
9 |
+
|
10 |
+
Args:
|
11 |
+
wpm (float): Words per minute
|
12 |
+
filler_count (int): Number of filler words ("um", "uh", etc.)
|
13 |
+
long_pause_count (int): Number of pauses longer than 1 second
|
14 |
+
pitch_variation (float): Standard deviation of pitch in semitones
|
15 |
+
|
16 |
+
Returns:
|
17 |
+
float: SRS score between 0-100
|
18 |
+
|
19 |
+
Requires:
|
20 |
+
- Words per Minute Consistency: Regularity in speech speed.
|
21 |
+
- Absence of Sudden Speed Shifts: Smooth transitions without erratic tempo changes.
|
22 |
+
"""
|
23 |
+
ideal_wpm = 150
|
24 |
+
wpm_deviation = min(30, abs(wpm - ideal_wpm)) # Cap at 30 WPM deviation
|
25 |
+
wpm_consistency = max(0, 100 - (wpm_deviation * 1.67)) # 100-50 for max deviation
|
26 |
+
|
27 |
+
# Sudden Speech Shift Penalty
|
28 |
+
filler_penalty = min(filler_count / 10, 1.0)
|
29 |
+
pause_penalty = min(long_pause_count / 5, 1.0)
|
30 |
+
pitch_penalty = min(pitch_variation / 3.0, 1.0) # High variation → unstable
|
31 |
+
|
32 |
+
# Combine into absence of sudden shifts
|
33 |
+
stability = (1 - ((filler_penalty + pause_penalty + pitch_penalty) / 3)) * 100
|
34 |
+
|
35 |
+
# Final SRS Score
|
36 |
+
SRS = (0.45 * wpm_consistency) + (0.55 * stability)
|
37 |
+
return min(100, max(0, SRS))
|
38 |
+
|
39 |
+
|
40 |
+
def calculate_pas(transcript: str, segments: List[Dict], filler_count: int, duration: float) -> Dict[str, float]:
|
41 |
+
"""
|
42 |
+
Calculate the Pause Appropriateness Score (PAS) and its components.
|
43 |
+
|
44 |
+
Args:
|
45 |
+
transcript (str): Full transcript text
|
46 |
+
segments (List[Dict]): List of transcript segments with start/end times
|
47 |
+
filler_count (int): Number of filler words detected
|
48 |
+
duration (float): Total duration of audio in seconds
|
49 |
+
|
50 |
+
Returns:
|
51 |
+
Dict[str, float]: Dictionary with NPP, AFW, and PAS scores
|
52 |
+
"""
|
53 |
+
if not transcript or not segments or duration <= 0:
|
54 |
+
raise ValueError("Transcript, segments, and duration must be valid")
|
55 |
+
|
56 |
+
nlp = spacy.load("en_core_web_sm")
|
57 |
+
doc = nlp(transcript)
|
58 |
+
|
59 |
+
words = transcript.split()
|
60 |
+
total_words = len(words)
|
61 |
+
if total_words == 0:
|
62 |
+
raise ValueError("No words found in transcript")
|
63 |
+
|
64 |
+
# Calculate Avoidance of Filler Words (AFW)
|
65 |
+
filler_rate = filler_count / total_words if total_words > 0 else 0.0
|
66 |
+
if filler_rate >= 0.10:
|
67 |
+
afw = 0.0
|
68 |
+
elif filler_rate <= 0.0:
|
69 |
+
afw = 100.0
|
70 |
+
else:
|
71 |
+
afw = 100.0 - (filler_rate * 1000)
|
72 |
+
afw = max(0.0, min(100.0, afw))
|
73 |
+
|
74 |
+
# Calculate Natural Pause Placement (NPP)
|
75 |
+
total_pauses = 0
|
76 |
+
natural_pauses = 0
|
77 |
+
segment_texts = [seg["text"].strip() for seg in segments]
|
78 |
+
segment_starts = [seg["start"] for seg in segments]
|
79 |
+
segment_ends = [seg["end"] for seg in segments]
|
80 |
+
|
81 |
+
for i in range(len(segments) - 1):
|
82 |
+
pause_dur = segment_starts[i + 1] - segment_ends[i]
|
83 |
+
if pause_dur > 0.5:
|
84 |
+
total_pauses += 1
|
85 |
+
if segment_texts[i] and segment_texts[i][-1] in ".!?,":
|
86 |
+
natural_pauses += 1
|
87 |
+
|
88 |
+
# Check initial and final pauses
|
89 |
+
if segment_starts[0] > 0.5:
|
90 |
+
total_pauses += 1
|
91 |
+
if duration - segment_ends[-1] > 0.5:
|
92 |
+
total_pauses += 1
|
93 |
+
if segment_texts[-1] and segment_texts[-1][-1] in ".!?":
|
94 |
+
natural_pauses += 1
|
95 |
+
|
96 |
+
npp = 100.0 if total_pauses == 0 else (natural_pauses / total_pauses) * 100.0
|
97 |
+
|
98 |
+
# Calculate final PAS
|
99 |
+
pas = (0.4 * npp) + (0.6 * afw)
|
100 |
+
|
101 |
+
return {
|
102 |
+
"NPP": npp,
|
103 |
+
"AFW": afw,
|
104 |
+
"PAS": pas
|
105 |
+
}
|
106 |
+
|
107 |
+
|
108 |
+
def calculate_fluency(srs: float, pas: float) -> Dict[str, float]:
|
109 |
+
"""
|
110 |
+
Calculate fluency score based on Speech Rate Stability and Pause Appropriateness Score.
|
111 |
+
|
112 |
+
Args:
|
113 |
+
srs (float): Speech Rate Stability score (0-100)
|
114 |
+
pas (float): Pause Appropriateness Score (0-100)
|
115 |
+
|
116 |
+
Returns:
|
117 |
+
Dict[str, float]: Dictionary with fluency score (0-100) and component contributions
|
118 |
+
"""
|
119 |
+
# Equal weighting of SRS and PAS for fluency
|
120 |
+
fluency_score = (0.5 * srs) + (0.5 * pas)
|
121 |
+
|
122 |
+
|
123 |
+
return {
|
124 |
+
"score": fluency_score,
|
125 |
+
"SRS_contribution": 0.5 * srs,
|
126 |
+
"PAS_contribution": 0.5 * pas
|
127 |
+
}
|
128 |
+
|
129 |
+
|
130 |
+
def get_fluency_insight(fluency_score: float) -> str:
|
131 |
+
"""
|
132 |
+
Generate insight text based on the fluency score.
|
133 |
+
|
134 |
+
Args:
|
135 |
+
fluency_score (float): The calculated fluency score (0-100)
|
136 |
+
|
137 |
+
Returns:
|
138 |
+
str: Insight text explaining the score
|
139 |
+
"""
|
140 |
+
if fluency_score >= 85:
|
141 |
+
return "Excellent fluency with very consistent pacing and natural pauses. Speech flows effortlessly."
|
142 |
+
elif fluency_score >= 70:
|
143 |
+
return "Good fluency with generally stable speech rate and appropriate pauses. Some minor inconsistencies."
|
144 |
+
elif fluency_score >= 50:
|
145 |
+
return "Moderate fluency with occasional disruptions in speech flow. Consider working on pace stability and pause placement."
|
146 |
+
elif fluency_score >= 30:
|
147 |
+
return "Below average fluency with noticeable disruptions. Focus on reducing filler words and maintaining consistent pace."
|
148 |
+
else:
|
149 |
+
return "Speech fluency needs significant improvement. Work on maintaining consistent pace, reducing long pauses, and eliminating filler words."
|
fluency/fluency_api.py
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import whisper
|
2 |
+
from .compute_fluency import compute_fluency_score
|
3 |
+
|
4 |
+
def main(file_path: str, model_size: str = "base") -> dict:
|
5 |
+
try:
|
6 |
+
|
7 |
+
whisper_model = whisper.load_model(model_size)
|
8 |
+
|
9 |
+
results = compute_fluency_score(file_path, whisper_model)
|
10 |
+
|
11 |
+
# Structure response
|
12 |
+
response = {
|
13 |
+
"fluency_score": round(results['fluency_score'], 2)
|
14 |
+
# "insight": results["insight"],
|
15 |
+
# "SRS": round(results["SRS"], 2),
|
16 |
+
# "PAS": round(results["PAS"], 2),
|
17 |
+
# "transcript": results["transcript"]
|
18 |
+
}
|
19 |
+
return response
|
20 |
+
|
21 |
+
except Exception as e:
|
22 |
+
raise RuntimeError(f"Error during analysis: {str(e)}")
|
fluency/main.py
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import whisper
|
3 |
+
from .compute_fluency import compute_fluency_score
|
4 |
+
|
5 |
+
def main():
|
6 |
+
"""
|
7 |
+
Main function to run fluency analysis on audio files
|
8 |
+
"""
|
9 |
+
# Fixed parameters - modify these values directly in the code
|
10 |
+
audio_file = r"D:\Intern\shankh\audio_samples\obama_short.wav" # Path to your audio file
|
11 |
+
model_size = "base" # Whisper model size (tiny, base, small, medium, large)
|
12 |
+
verbose = True # Whether to print detailed results
|
13 |
+
|
14 |
+
try:
|
15 |
+
# Load whisper model
|
16 |
+
print(f"Loading Whisper model ({model_size})...")
|
17 |
+
whisper_model = whisper.load_model(model_size)
|
18 |
+
|
19 |
+
# Calculate fluency score
|
20 |
+
print(f"Analyzing fluency for {audio_file}...")
|
21 |
+
results = compute_fluency_score(audio_file, whisper_model)
|
22 |
+
|
23 |
+
# Print summary results
|
24 |
+
print("\nFluency Analysis Results:")
|
25 |
+
print(f"- Fluency Score: {results['fluency_score']:.2f}/100")
|
26 |
+
print(f"- Insight: {results['insight']}")
|
27 |
+
print(f"- Speech Rate Stability (SRS): {results['SRS']:.2f}/100")
|
28 |
+
print(f"- Pause Appropriateness (PAS): {results['PAS']:.2f}/100")
|
29 |
+
|
30 |
+
# Print verbose results if enabled
|
31 |
+
if verbose:
|
32 |
+
print("\nDetailed Metrics:")
|
33 |
+
print(f"- Words per minute: {results['components']['wpm']:.1f}")
|
34 |
+
print(f"- Filler word count: {results['components']['filler_count']}")
|
35 |
+
print(f"- Long pauses: {results['components']['long_pause_count']}")
|
36 |
+
print(f"- Pitch variation: {results['components']['pitch_variation']:.2f} semitones")
|
37 |
+
print(f"- Natural Pause Placement: {results['components']['pas_components']['NPP']:.2f}/100")
|
38 |
+
print(f"- Avoidance of Filler Words: {results['components']['pas_components']['AFW']:.2f}/100")
|
39 |
+
|
40 |
+
# Print first 100 characters of transcript
|
41 |
+
transcript_preview = results['transcript'][:] + "..." if len(results['transcript']) > 100 else results['transcript']
|
42 |
+
print(f"\nTranscript preview: {transcript_preview}")
|
43 |
+
|
44 |
+
except Exception as e:
|
45 |
+
print(f"Error during analysis: {str(e)}")
|
46 |
+
return 1
|
47 |
+
|
48 |
+
if __name__ == "__main__":
|
49 |
+
exit(main())
|
requirements.txt
CHANGED
@@ -1,2 +1,21 @@
|
|
|
|
1 |
fastapi
|
2 |
uvicorn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
fastapi
|
3 |
uvicorn
|
4 |
+
python-multipart
|
5 |
+
|
6 |
+
|
7 |
+
librosa
|
8 |
+
soundfile
|
9 |
+
pyworld
|
10 |
+
scipy
|
11 |
+
|
12 |
+
|
13 |
+
openai-whisper==20240930
|
14 |
+
spacy==3.8.5
|
15 |
+
en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
16 |
+
|
17 |
+
|
18 |
+
|
19 |
+
numpy
|
20 |
+
tqdm
|
21 |
+
requests
|
tone_modulation/__init__.py
ADDED
File without changes
|
tone_modulation/__pycache__/__init__.cpython-312.pyc
ADDED
Binary file (141 Bytes). View file
|
|
tone_modulation/__pycache__/sds.cpython-312.pyc
ADDED
Binary file (5.49 kB). View file
|
|
tone_modulation/__pycache__/tone_api.cpython-312.pyc
ADDED
Binary file (686 Bytes). View file
|
|
tone_modulation/sds.py
ADDED
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import scipy.signal
|
3 |
+
import numpy as np
|
4 |
+
import librosa
|
5 |
+
import pyworld as pw
|
6 |
+
|
7 |
+
# def compute_pitch_variation(file_path):
|
8 |
+
# # Step 1: Load audio
|
9 |
+
# y, sr = librosa.load(file_path, sr=None)
|
10 |
+
# y = y.astype(np.float64) # pyworld expects float64
|
11 |
+
|
12 |
+
# # Step 2: Extract pitch (F0)
|
13 |
+
# _f0, t = pw.dio(y, sr) # Fast initial pitch estimation
|
14 |
+
# f0 = pw.stonemask(y, _f0, t, sr) # Refinement step
|
15 |
+
|
16 |
+
# # Step 3: Filter voiced frames
|
17 |
+
# voiced_f0 = f0[f0 > 0]
|
18 |
+
|
19 |
+
# # Handle empty case
|
20 |
+
# if voiced_f0.size == 0:
|
21 |
+
# return {
|
22 |
+
# "pitch_mean": 0.0,
|
23 |
+
# "pitch_std": 0.0,
|
24 |
+
# "pitch_range": 0.0,
|
25 |
+
# "semitone_std": 0.0,
|
26 |
+
# "pitch_variation_score": 0.0
|
27 |
+
# }
|
28 |
+
|
29 |
+
# # Step 4: Basic statistics
|
30 |
+
# pitch_mean = np.mean(voiced_f0)
|
31 |
+
# pitch_std = np.std(voiced_f0)
|
32 |
+
# pitch_range = np.max(voiced_f0) - np.min(voiced_f0)
|
33 |
+
|
34 |
+
# print(pitch_mean)
|
35 |
+
# print(f'voiced_f0: {voiced_f0}')
|
36 |
+
# # Step 5: Compute semitone-based variation (better for human perception)
|
37 |
+
# median_f0 = np.median(voiced_f0)
|
38 |
+
# if median_f0 <= 0:
|
39 |
+
# median_f0 = 1e-6 # Avoid division by zero
|
40 |
+
|
41 |
+
# semitone_diffs = 12 * np.log2(voiced_f0 / median_f0)
|
42 |
+
# semitone_std = np.std(semitone_diffs)
|
43 |
+
# print(semitone_std)
|
44 |
+
|
45 |
+
# # Step 6: Scale semitone_std to a 0–100 score (tunable)
|
46 |
+
# # For example: semitone_std of 0 → 0 score, ≥6 semitones → 100 score
|
47 |
+
# pitch_variation_score = np.clip((semitone_std / 6.0) * 100, 0, 100)
|
48 |
+
|
49 |
+
# return {
|
50 |
+
# "pitch_mean": pitch_mean,
|
51 |
+
# "pitch_std": pitch_std,
|
52 |
+
# "pitch_range": pitch_range,
|
53 |
+
# "semitone_std": semitone_std,
|
54 |
+
# "pitch_variation_score": pitch_variation_score
|
55 |
+
# }
|
56 |
+
# def compute_intonation_range(file_path):
|
57 |
+
# # Step 1: Load and prepare audio
|
58 |
+
# y, sr = librosa.load(file_path, sr=None)
|
59 |
+
# y = y.astype(np.float64)
|
60 |
+
|
61 |
+
# # Step 2: Extract F0
|
62 |
+
# _f0, t = pw.dio(y, sr)
|
63 |
+
# f0 = pw.stonemask(y, _f0, t, sr)
|
64 |
+
|
65 |
+
|
66 |
+
|
67 |
+
# # Step 3: Filter voiced frames
|
68 |
+
# voiced_f0 = f0[f0 > 0]
|
69 |
+
# if voiced_f0.size == 0:
|
70 |
+
# return 0.0
|
71 |
+
|
72 |
+
# voiced_f0 = voiced_f0[(voiced_f0 > np.percentile(voiced_f0, 5)) &
|
73 |
+
# (voiced_f0 < np.percentile(voiced_f0, 95))]
|
74 |
+
|
75 |
+
# # Step 4: Compute intonation range (in semitones)
|
76 |
+
# f0_min = np.min(voiced_f0)
|
77 |
+
# f0_max = np.max(voiced_f0)
|
78 |
+
# if f0_min <= 0:
|
79 |
+
# f0_min = 1e-6 # to avoid log error
|
80 |
+
# intonation_range = 12 * np.log2(f0_max / f0_min)
|
81 |
+
|
82 |
+
# # range into scores:
|
83 |
+
|
84 |
+
# max_range = 12.0
|
85 |
+
# normalized = min(intonation_range, max_range) / max_range
|
86 |
+
# score = normalized * 100
|
87 |
+
# return round(score, 2), intonation_range
|
88 |
+
|
89 |
+
|
90 |
+
|
91 |
+
# def compute_pitch_variation(file_path):
|
92 |
+
# # Step 1: Load audio
|
93 |
+
# y, sr = librosa.load(file_path, sr=None)
|
94 |
+
|
95 |
+
# # Step 2: Extract pitch using librosa.pyin (YIN-based)
|
96 |
+
# f0, voiced_flags, voiced_probs = librosa.pyin(
|
97 |
+
# y,
|
98 |
+
# sr=sr,
|
99 |
+
# fmin=80,
|
100 |
+
# fmax=400,
|
101 |
+
# frame_length=1105,
|
102 |
+
# hop_length=256,
|
103 |
+
# fill_na=np.nan
|
104 |
+
# )
|
105 |
+
|
106 |
+
# # Step 3: Filter voiced frames
|
107 |
+
# voiced_f0 = f0[~np.isnan(f0)]
|
108 |
+
|
109 |
+
|
110 |
+
# voiced_f0 = voiced_f0[
|
111 |
+
# (voiced_f0 > np.percentile(voiced_f0, 5)) &
|
112 |
+
# (voiced_f0 < np.percentile(voiced_f0, 95))
|
113 |
+
# ]
|
114 |
+
|
115 |
+
# # Handle empty case
|
116 |
+
# if voiced_f0.size == 0:
|
117 |
+
# return {
|
118 |
+
# "pitch_mean": 0.0,
|
119 |
+
# "pitch_std": 0.0,
|
120 |
+
# "pitch_range": 0.0,
|
121 |
+
# "semitone_std": 0.0,
|
122 |
+
# "pitch_variation_score": 0.0
|
123 |
+
# }
|
124 |
+
|
125 |
+
# # Step 4: Basic statistics
|
126 |
+
# pitch_mean = float(np.mean(voiced_f0))
|
127 |
+
# pitch_std = float(np.std(voiced_f0))
|
128 |
+
# pitch_range = float(np.max(voiced_f0) - np.min(voiced_f0))
|
129 |
+
|
130 |
+
|
131 |
+
# # Step 5: Compute semitone-based variation
|
132 |
+
# median_f0 = np.median(voiced_f0)
|
133 |
+
# if median_f0 <= 0:
|
134 |
+
# median_f0 = 1e-6
|
135 |
+
|
136 |
+
# semitone_diffs = 12 * np.log2(voiced_f0 / median_f0)
|
137 |
+
# semitone_std = float(np.std(semitone_diffs))
|
138 |
+
|
139 |
+
|
140 |
+
# # Step 6: Scale to 0–100 score
|
141 |
+
# pitch_variation_score = float(np.clip((semitone_std / 6.0) * 100, 0, 100))
|
142 |
+
# return {
|
143 |
+
# "pitch_mean": pitch_mean,
|
144 |
+
# "pitch_std": pitch_std,
|
145 |
+
# "pitch_range": pitch_range,
|
146 |
+
# "semitone_std": semitone_std,
|
147 |
+
# "pitch_variation_score": pitch_variation_score
|
148 |
+
# }
|
149 |
+
|
150 |
+
# def compute_intonation_range(file_path):
|
151 |
+
# # Step 1: Load and prepare audio
|
152 |
+
# y, sr = librosa.load(file_path, sr=None)
|
153 |
+
|
154 |
+
# # Step 2: Extract F0 using librosa.pyin
|
155 |
+
# f0, voiced_flags, voiced_probs = librosa.pyin(
|
156 |
+
# y,
|
157 |
+
# sr=sr,
|
158 |
+
# fmin=80,
|
159 |
+
# fmax=400,
|
160 |
+
# frame_length=1105, # ensures two periods of fmin fit
|
161 |
+
# hop_length=256,
|
162 |
+
# fill_na=np.nan
|
163 |
+
# )
|
164 |
+
|
165 |
+
# # Step 3: Filter voiced frames
|
166 |
+
# voiced_f0 = f0[~np.isnan(f0)]
|
167 |
+
# if voiced_f0.size == 0:
|
168 |
+
# return 0.0, 0.0
|
169 |
+
|
170 |
+
# # Optional: remove outliers (5th to 95th percentile)
|
171 |
+
# voiced_f0 = voiced_f0[
|
172 |
+
# (voiced_f0 > np.percentile(voiced_f0, 5)) &
|
173 |
+
# (voiced_f0 < np.percentile(voiced_f0, 95))
|
174 |
+
# ]
|
175 |
+
|
176 |
+
# # Step 4: Compute intonation range in semitones
|
177 |
+
# f0_min = np.min(voiced_f0)
|
178 |
+
# f0_max = np.max(voiced_f0)
|
179 |
+
# if f0_min <= 0:
|
180 |
+
# f0_min = 1e-6
|
181 |
+
|
182 |
+
# intonation_range = 12 * np.log2(f0_max / f0_min)
|
183 |
+
|
184 |
+
# # Step 5: Normalize and convert to score out of 100
|
185 |
+
# max_range = 12.0 # ~1 octave
|
186 |
+
# normalized = min(intonation_range, max_range) / max_range
|
187 |
+
# score = normalized * 100
|
188 |
+
|
189 |
+
# return round(score, 2), float(intonation_range)
|
190 |
+
|
191 |
+
|
192 |
+
|
193 |
+
# def compute_speech_rhythm_variability(file_path):
|
194 |
+
# """
|
195 |
+
# Computes the speech rhythm variability score from an audio file.
|
196 |
+
# The method estimates tempo consistency across time using onset intervals.
|
197 |
+
|
198 |
+
# Returns:
|
199 |
+
# score (float): Normalized rhythm variability score out of 100.
|
200 |
+
# raw_std (float): Raw standard deviation of inter-onset intervals.
|
201 |
+
# """
|
202 |
+
# # Step 1: Load audio
|
203 |
+
# y, sr = librosa.load(file_path, sr=None)
|
204 |
+
|
205 |
+
# # Step 2: Onset detection
|
206 |
+
# onset_env = librosa.onset.onset_strength(y=y, sr=sr)
|
207 |
+
# onsets = librosa.onset.onset_detect(onset_envelope=onset_env, sr=sr, units='time')
|
208 |
+
|
209 |
+
# if len(onsets) < 2:
|
210 |
+
# return 0.0, 0.0 # Not enough onsets to compute rhythm
|
211 |
+
|
212 |
+
# # Step 3: Compute inter-onset intervals (IOIs) as rhythm proxy
|
213 |
+
# iois = np.diff(onsets)
|
214 |
+
|
215 |
+
# # Optional: Remove outliers (5th–95th percentile)
|
216 |
+
# ioi_clean = iois[(iois > np.percentile(iois, 5)) & (iois < np.percentile(iois, 95))]
|
217 |
+
# if len(ioi_clean) < 2:
|
218 |
+
# return 0.0, 0.0
|
219 |
+
|
220 |
+
# # Step 4: Compute variability — standard deviation of IOIs
|
221 |
+
# raw_std = np.std(ioi_clean)
|
222 |
+
|
223 |
+
# # Step 5: Normalize raw_std to 0–100 score
|
224 |
+
# # Lower std = more consistent rhythm → higher score
|
225 |
+
# min_std = 0.05 # near-perfect rhythm (tight pacing)
|
226 |
+
# max_std = 0.6 # highly irregular rhythm
|
227 |
+
|
228 |
+
# # Clamp and reverse-score
|
229 |
+
# clamped_std = np.clip(raw_std, min_std, max_std)
|
230 |
+
# normalized = 1 - (clamped_std - min_std) / (max_std - min_std)
|
231 |
+
# score = normalized * 100
|
232 |
+
|
233 |
+
# return round(score, 2), round(float(raw_std), 4)
|
234 |
+
|
235 |
+
|
236 |
+
# def calc_sds(file_path):
|
237 |
+
|
238 |
+
# # sds = 0.35 * pitch_variation + 0.35 * intonation_range + 0.3 * speech_rhythm_variability
|
239 |
+
|
240 |
+
# pitch_variation = compute_pitch_variation(file_path)
|
241 |
+
# intonation_range = compute_intonation_range(file_path)
|
242 |
+
# speech_rhythm_variability = compute_speech_rhythm_variability(file_path)
|
243 |
+
# # print(f"Speech Rhythm Variability Score: {speech_rhythm_variability}")
|
244 |
+
# # print(f"Speech Rhythm Variability Score: {speech_rhythm_variability}")
|
245 |
+
# # print(f"Speech Rhythm Variability Score: {speech_rhythm_variability}")
|
246 |
+
|
247 |
+
# sds = 0.35 * pitch_variation['pitch_variation_score'] + 0.35 * intonation_range[0] + 0.3 * speech_rhythm_variability[0]
|
248 |
+
# return round(sds, 2)
|
249 |
+
|
250 |
+
# path = r'D:\Intern\shankh\audio_samples\anga.wav'
|
251 |
+
|
252 |
+
# result = calc_sds(path)
|
253 |
+
# print(f"SDS: {result}")
|
254 |
+
|
255 |
+
import numpy as np
|
256 |
+
import librosa
|
257 |
+
import pyworld
|
258 |
+
|
259 |
+
def compute_pitch_variation(file_path):
|
260 |
+
# Step 1: Load audio
|
261 |
+
y, sr = librosa.load(file_path, sr=None)
|
262 |
+
|
263 |
+
# Step 2: Extract pitch using pyworld
|
264 |
+
_f0, t = pyworld.harvest(y.astype(np.float64), sr, f0_floor=80.0, f0_ceil=400.0, frame_period=1000 * 256 / sr)
|
265 |
+
f0 = pyworld.stonemask(y.astype(np.float64), _f0, t, sr)
|
266 |
+
|
267 |
+
# Step 3: Filter voiced frames
|
268 |
+
voiced_f0 = f0[f0 > 0]
|
269 |
+
|
270 |
+
# Remove outliers (5th to 95th percentile)
|
271 |
+
voiced_f0 = voiced_f0[
|
272 |
+
(voiced_f0 > np.percentile(voiced_f0, 5)) &
|
273 |
+
(voiced_f0 < np.percentile(voiced_f0, 95))
|
274 |
+
]
|
275 |
+
|
276 |
+
if voiced_f0.size == 0:
|
277 |
+
return {
|
278 |
+
"pitch_mean": 0.0,
|
279 |
+
"pitch_std": 0.0,
|
280 |
+
"pitch_range": 0.0,
|
281 |
+
"semitone_std": 0.0,
|
282 |
+
"pitch_variation_score": 0.0
|
283 |
+
}
|
284 |
+
|
285 |
+
# Step 4: Basic statistics
|
286 |
+
pitch_mean = float(np.mean(voiced_f0))
|
287 |
+
pitch_std = float(np.std(voiced_f0))
|
288 |
+
pitch_range = float(np.max(voiced_f0) - np.min(voiced_f0))
|
289 |
+
|
290 |
+
# Step 5: Semitone-based variation
|
291 |
+
median_f0 = np.median(voiced_f0)
|
292 |
+
if median_f0 <= 0:
|
293 |
+
median_f0 = 1e-6
|
294 |
+
semitone_diffs = 12 * np.log2(voiced_f0 / median_f0)
|
295 |
+
semitone_std = float(np.std(semitone_diffs))
|
296 |
+
|
297 |
+
# Step 6: Scaled variation score
|
298 |
+
pitch_variation_score = float(np.clip((semitone_std / 6.0) * 100, 0, 100))
|
299 |
+
|
300 |
+
return {
|
301 |
+
"pitch_mean": pitch_mean,
|
302 |
+
"pitch_std": pitch_std,
|
303 |
+
"pitch_range": pitch_range,
|
304 |
+
"semitone_std": semitone_std,
|
305 |
+
"pitch_variation_score": pitch_variation_score
|
306 |
+
}
|
307 |
+
|
308 |
+
|
309 |
+
def compute_intonation_range(file_path):
|
310 |
+
# Step 1: Load audio
|
311 |
+
y, sr = librosa.load(file_path, sr=None)
|
312 |
+
|
313 |
+
# Step 2: Extract pitch using pyworld
|
314 |
+
_f0, t = pyworld.harvest(y.astype(np.float64), sr, f0_floor=80.0, f0_ceil=400.0, frame_period=1000 * 256 / sr)
|
315 |
+
f0 = pyworld.stonemask(y.astype(np.float64), _f0, t, sr)
|
316 |
+
|
317 |
+
# Step 3: Filter voiced frames
|
318 |
+
voiced_f0 = f0[f0 > 0]
|
319 |
+
if voiced_f0.size == 0:
|
320 |
+
return 0.0, 0.0
|
321 |
+
|
322 |
+
# Remove outliers
|
323 |
+
voiced_f0 = voiced_f0[
|
324 |
+
(voiced_f0 > np.percentile(voiced_f0, 5)) &
|
325 |
+
(voiced_f0 < np.percentile(voiced_f0, 95))
|
326 |
+
]
|
327 |
+
if voiced_f0.size == 0:
|
328 |
+
return 0.0, 0.0
|
329 |
+
|
330 |
+
# Step 4: Compute intonation range
|
331 |
+
f0_min = np.min(voiced_f0)
|
332 |
+
f0_max = np.max(voiced_f0)
|
333 |
+
if f0_min <= 0:
|
334 |
+
f0_min = 1e-6
|
335 |
+
intonation_range = 12 * np.log2(f0_max / f0_min)
|
336 |
+
|
337 |
+
# Step 5: Normalize
|
338 |
+
max_range = 12.0
|
339 |
+
normalized = min(intonation_range, max_range) / max_range
|
340 |
+
score = normalized * 100
|
341 |
+
|
342 |
+
return round(score, 2), float(intonation_range)
|
343 |
+
|
344 |
+
|
345 |
+
def compute_speech_rhythm_variability(file_path):
|
346 |
+
"""
|
347 |
+
Computes the speech rhythm variability score from an audio file.
|
348 |
+
The method estimates tempo consistency across time using onset intervals.
|
349 |
+
"""
|
350 |
+
y, sr = librosa.load(file_path, sr=None)
|
351 |
+
|
352 |
+
# Step 2: Onset detection
|
353 |
+
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
|
354 |
+
onsets = librosa.onset.onset_detect(onset_envelope=onset_env, sr=sr, units='time')
|
355 |
+
|
356 |
+
if len(onsets) < 2:
|
357 |
+
return 0.0, 0.0
|
358 |
+
|
359 |
+
iois = np.diff(onsets)
|
360 |
+
|
361 |
+
ioi_clean = iois[(iois > np.percentile(iois, 5)) & (iois < np.percentile(iois, 95))]
|
362 |
+
if len(ioi_clean) < 2:
|
363 |
+
return 0.0, 0.0
|
364 |
+
|
365 |
+
raw_std = np.std(ioi_clean)
|
366 |
+
|
367 |
+
min_std = 0.05
|
368 |
+
max_std = 0.6
|
369 |
+
clamped_std = np.clip(raw_std, min_std, max_std)
|
370 |
+
normalized = 1 - (clamped_std - min_std) / (max_std - min_std)
|
371 |
+
score = normalized * 100
|
372 |
+
|
373 |
+
return round(score, 2), round(float(raw_std), 4)
|
374 |
+
|
375 |
+
|
376 |
+
def calc_sds(file_path):
|
377 |
+
pitch_variation = compute_pitch_variation(file_path)
|
378 |
+
intonation_range = compute_intonation_range(file_path)
|
379 |
+
speech_rhythm_variability = compute_speech_rhythm_variability(file_path)
|
380 |
+
|
381 |
+
sds = 0.35 * pitch_variation['pitch_variation_score'] + \
|
382 |
+
0.35 * intonation_range[0] + \
|
383 |
+
0.3 * speech_rhythm_variability[0]
|
384 |
+
|
385 |
+
return round(sds, 2)
|
tone_modulation/tone_api.py
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
from .sds import calc_sds
|
3 |
+
|
4 |
+
import logging
|
5 |
+
logger = logging.getLogger(__name__)
|
6 |
+
|
7 |
+
def main(file_path: str) -> dict:
|
8 |
+
logger.info(f"Starting tone analysis for: {file_path}")
|
9 |
+
try:
|
10 |
+
|
11 |
+
|
12 |
+
results = calc_sds(file_path)
|
13 |
+
|
14 |
+
# Structure response
|
15 |
+
response = {
|
16 |
+
"speech_dynamism_score" : round(results, 2),
|
17 |
+
}
|
18 |
+
logger.info("Tone analysis complete")
|
19 |
+
return response
|
20 |
+
|
21 |
+
except Exception as e:
|
22 |
+
logger.error(f"Tone analysis failed internally: {e}", exc_info=True)
|
23 |
+
raise RuntimeError(f"Error during analysis: {str(e)}")
|
transcribe.py
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# using whisper to transcribe audio files
|
2 |
+
|
3 |
+
import whisper
|
4 |
+
import os
|
5 |
+
|
6 |
+
def transcribe_audio(file_path, model_size="base"):
|
7 |
+
"""
|
8 |
+
Transcribe audio file using Whisper model.
|
9 |
+
|
10 |
+
Args:
|
11 |
+
file_path (str): Path to the audio file.
|
12 |
+
model_size (str): Size of the Whisper model to use. Options are "tiny", "base", "small", "medium", "large".
|
13 |
+
|
14 |
+
Returns:
|
15 |
+
str: Transcription of the audio file.
|
16 |
+
"""
|
17 |
+
# Load the Whisper model
|
18 |
+
model = whisper.load_model(model_size)
|
19 |
+
|
20 |
+
# Transcribe the audio file
|
21 |
+
result = model.transcribe(file_path, fp16=False)
|
22 |
+
|
23 |
+
# Return the transcription
|
24 |
+
return result["text"]
|
vcs/__init__.py
ADDED
File without changes
|
vcs/__pycache__/__init__.cpython-312.pyc
ADDED
Binary file (129 Bytes). View file
|
|
vcs/__pycache__/compute_vcs.cpython-312.pyc
ADDED
Binary file (4.24 kB). View file
|
|
vcs/__pycache__/main.cpython-312.pyc
ADDED
Binary file (2.57 kB). View file
|
|
vcs/__pycache__/vcs.cpython-312.pyc
ADDED
Binary file (6.73 kB). View file
|
|
vcs/__pycache__/vcs_api.cpython-312.pyc
ADDED
Binary file (865 Bytes). View file
|
|
vcs/compute_vcs.py
ADDED
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Compute Voice Clarity Score from audio file
|
3 |
+
"""
|
4 |
+
|
5 |
+
import librosa
|
6 |
+
import numpy as np
|
7 |
+
from typing import Dict, Any
|
8 |
+
from .vcs import calculate_voice_clarity_score, get_clarity_insight
|
9 |
+
|
10 |
+
def compute_voice_clarity_score(file_path: str, whisper_model) -> Dict[str, Any]:
|
11 |
+
"""
|
12 |
+
Compute Voice Clarity Score and its components from a speech sample.
|
13 |
+
|
14 |
+
Args:
|
15 |
+
file_path (str): Path to the audio file.
|
16 |
+
whisper_model: Transcription model (e.g., OpenAI Whisper or faster-whisper)
|
17 |
+
|
18 |
+
Returns:
|
19 |
+
dict: A dictionary containing Voice Clarity Score and component scores.
|
20 |
+
"""
|
21 |
+
# Transcribe audio
|
22 |
+
result = whisper_model.transcribe(file_path)
|
23 |
+
transcript = result.get("text", "").strip()
|
24 |
+
segments = result.get("segments", [])
|
25 |
+
|
26 |
+
# Validate early
|
27 |
+
if not transcript or not segments:
|
28 |
+
raise ValueError("Empty transcript or segments from Whisper.")
|
29 |
+
|
30 |
+
# Load audio
|
31 |
+
y, sr = librosa.load(file_path, sr=None)
|
32 |
+
duration = len(y) / sr if sr else 0.0
|
33 |
+
if duration <= 0:
|
34 |
+
raise ValueError("Audio duration invalid or zero.")
|
35 |
+
|
36 |
+
# Calculate Voice Clarity Score
|
37 |
+
clarity_result = calculate_voice_clarity_score(y, sr, segments)
|
38 |
+
|
39 |
+
# Add transcript to results
|
40 |
+
clarity_result["transcript"] = transcript
|
41 |
+
|
42 |
+
# Add word count and duration info for reference
|
43 |
+
word_count = len(transcript.split())
|
44 |
+
clarity_result["components"]["word_count"] = word_count
|
45 |
+
clarity_result["components"]["duration"] = duration
|
46 |
+
|
47 |
+
return clarity_result
|
48 |
+
|
49 |
+
def analyze_voice_quality(file_path: str, whisper_model) -> Dict[str, Any]:
|
50 |
+
"""
|
51 |
+
Comprehensive voice quality analysis including clarity.
|
52 |
+
|
53 |
+
Args:
|
54 |
+
file_path (str): Path to the audio file
|
55 |
+
whisper_model: Transcription model
|
56 |
+
|
57 |
+
Returns:
|
58 |
+
Dict[str, Any]: Complete voice quality analysis
|
59 |
+
"""
|
60 |
+
# Get Voice Clarity Score
|
61 |
+
clarity_results = compute_voice_clarity_score(file_path, whisper_model)
|
62 |
+
vcs = clarity_results["VCS"]
|
63 |
+
|
64 |
+
# Load audio for additional analysis
|
65 |
+
y, sr = librosa.load(file_path, sr=None)
|
66 |
+
|
67 |
+
# Calculate additional voice quality metrics
|
68 |
+
|
69 |
+
# Voice stability - based on pitch (F0) stability
|
70 |
+
f0, voiced_flags, voiced_probs = librosa.pyin(
|
71 |
+
y, sr=sr, fmin=80, fmax=400, frame_length=1024, hop_length=256, fill_na=np.nan)
|
72 |
+
voiced_f0 = f0[~np.isnan(f0)]
|
73 |
+
|
74 |
+
pitch_stability = 0.0
|
75 |
+
if voiced_f0.size > 0:
|
76 |
+
# Calculate coefficient of variation (lower is more stable)
|
77 |
+
cv = np.std(voiced_f0) / np.mean(voiced_f0) if np.mean(voiced_f0) > 0 else float('inf')
|
78 |
+
# Convert to score (0-100)
|
79 |
+
pitch_stability = max(0, min(100, 100 - (cv * 100)))
|
80 |
+
|
81 |
+
# Voice resonance - based on spectral bandwidth
|
82 |
+
bandwidth = np.mean(librosa.feature.spectral_bandwidth(y=y, sr=sr))
|
83 |
+
# Normalize (ideal range is around 1500-2500 Hz for speech)
|
84 |
+
if bandwidth < 1000:
|
85 |
+
resonance_score = max(0, bandwidth / 1000 * 70) # Too narrow
|
86 |
+
elif bandwidth <= 2500:
|
87 |
+
resonance_score = 70 + ((bandwidth - 1000) / 1500 * 30) # Optimal range
|
88 |
+
else:
|
89 |
+
resonance_score = max(0, 100 - ((bandwidth - 2500) / 2500 * 50)) # Too wide
|
90 |
+
|
91 |
+
# Voice strength - based on RMS energy
|
92 |
+
rms = np.mean(librosa.feature.rms(y=y))
|
93 |
+
# Normalize (typical speech RMS values range from 0.01 to 0.2)
|
94 |
+
strength_score = min(100, max(0, rms / 0.2 * 100))
|
95 |
+
|
96 |
+
# Combine additional metrics
|
97 |
+
additional_metrics = {
|
98 |
+
"pitch_stability": pitch_stability,
|
99 |
+
"voice_resonance": resonance_score,
|
100 |
+
"voice_strength": strength_score
|
101 |
+
}
|
102 |
+
|
103 |
+
# Add to results
|
104 |
+
combined_results = {
|
105 |
+
"VCS": vcs,
|
106 |
+
"insight": clarity_results["insight"],
|
107 |
+
"components": {
|
108 |
+
**clarity_results["components"],
|
109 |
+
**additional_metrics
|
110 |
+
},
|
111 |
+
"transcript": clarity_results["transcript"]
|
112 |
+
}
|
113 |
+
|
114 |
+
return combined_results
|
115 |
+
|
116 |
+
# Ensure the functions are exposed when imported
|
117 |
+
__all__ = ['compute_voice_clarity_score', 'analyze_voice_quality']
|
vcs/main.py
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import whisper
|
3 |
+
from .compute_vcs import analyze_voice_quality
|
4 |
+
|
5 |
+
def main():
|
6 |
+
"""
|
7 |
+
Main function to run voice clarity analysis on audio files
|
8 |
+
"""
|
9 |
+
# Fixed parameters - modify these values directly in the code
|
10 |
+
audio_file = r"D:\Intern\shankh\audio_samples\obama_short.wav" # Path to your audio file
|
11 |
+
model_size = "base" # Whisper model size (tiny, base, small, medium, large)
|
12 |
+
verbose = True # Whether to print detailed results
|
13 |
+
|
14 |
+
try:
|
15 |
+
# Load whisper model
|
16 |
+
print(f"Loading Whisper model ({model_size})...")
|
17 |
+
whisper_model = whisper.load_model(model_size)
|
18 |
+
|
19 |
+
# Calculate voice clarity score
|
20 |
+
print(f"Analyzing voice clarity for {audio_file}...")
|
21 |
+
results = analyze_voice_quality(audio_file, whisper_model)
|
22 |
+
|
23 |
+
# Print summary results
|
24 |
+
print("\nVoice Quality Analysis Results:")
|
25 |
+
print(f"- Voice Clarity Score (VCS): {results['VCS']:.2f}/100")
|
26 |
+
print(f"- Insight: {results['insight']}")
|
27 |
+
print(f"- Articulation: {results['components']['articulation']:.2f}/100")
|
28 |
+
print(f"- Enunciation: {results['components']['enunciation']:.2f}/100")
|
29 |
+
print(f"- Speech Pause Control: {results['components']['speech_pause_control']:.2f}/100")
|
30 |
+
|
31 |
+
# Print verbose results if enabled
|
32 |
+
if verbose:
|
33 |
+
print("\nDetailed Metrics:")
|
34 |
+
print(f"- Pitch Stability: {results['components']['pitch_stability']:.2f}/100")
|
35 |
+
print(f"- Voice Resonance: {results['components']['voice_resonance']:.2f}/100")
|
36 |
+
print(f"- Voice Strength: {results['components']['voice_strength']:.2f}/100")
|
37 |
+
print(f"- Word Count: {results['components']['word_count']}")
|
38 |
+
print(f"- Duration: {results['components']['duration']:.2f} seconds")
|
39 |
+
|
40 |
+
# Print first 100 characters of transcript
|
41 |
+
transcript_preview = results['transcript'][:] + "..." if len(results['transcript']) > 100 else results['transcript']
|
42 |
+
print(f"\nTranscript preview: {transcript_preview}")
|
43 |
+
|
44 |
+
except Exception as e:
|
45 |
+
print(f"Error during analysis: {str(e)}")
|
46 |
+
return 1
|
47 |
+
|
48 |
+
if __name__ == "__main__":
|
49 |
+
exit(main())
|
vcs/vcs.py
ADDED
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Voice Clarity Score calculation module
|
3 |
+
"""
|
4 |
+
|
5 |
+
import librosa
|
6 |
+
import numpy as np
|
7 |
+
from typing import Dict, Any, List
|
8 |
+
import soundfile as sf
|
9 |
+
|
10 |
+
def calculate_articulation(y: np.ndarray, sr: int) -> float:
|
11 |
+
"""
|
12 |
+
Calculate articulation quality based on spectral contrast.
|
13 |
+
|
14 |
+
Articulation refers to how clearly individual phonemes are produced.
|
15 |
+
|
16 |
+
Args:
|
17 |
+
y (np.ndarray): Audio signal
|
18 |
+
sr (int): Sample rate
|
19 |
+
|
20 |
+
Returns:
|
21 |
+
float: Articulation score (0-100)
|
22 |
+
"""
|
23 |
+
# Extract spectral contrast
|
24 |
+
# Higher contrast between peaks and valleys in the spectrum generally correlates with clearer articulation
|
25 |
+
S = np.abs(librosa.stft(y))
|
26 |
+
contrast = librosa.feature.spectral_contrast(S=S, sr=sr)
|
27 |
+
|
28 |
+
# Average across frequency bands and frames
|
29 |
+
mean_contrast = np.mean(contrast)
|
30 |
+
|
31 |
+
# Normalize to 0-100 scale (empirically determined range)
|
32 |
+
# Typical values range from 10-50 dB
|
33 |
+
min_contrast = 10
|
34 |
+
max_contrast = 50
|
35 |
+
normalized_contrast = min(100, max(0, (mean_contrast - min_contrast) / (max_contrast - min_contrast) * 100))
|
36 |
+
|
37 |
+
return normalized_contrast
|
38 |
+
|
39 |
+
def calculate_enunciation(y: np.ndarray, sr: int) -> float:
|
40 |
+
"""
|
41 |
+
Calculate enunciation quality based on formant clarity and spectral flatness.
|
42 |
+
|
43 |
+
Enunciation is the precision in pronouncing vowels and consonants.
|
44 |
+
|
45 |
+
Args:
|
46 |
+
y (np.ndarray): Audio signal
|
47 |
+
sr (int): Sample rate
|
48 |
+
|
49 |
+
Returns:
|
50 |
+
float: Enunciation score (0-100)
|
51 |
+
"""
|
52 |
+
# Compute spectral flatness - lower values indicate clearer formants and better enunciation
|
53 |
+
flatness = np.mean(librosa.feature.spectral_flatness(y=y))
|
54 |
+
|
55 |
+
# Compute spectral centroid - related to "brightness" or articulation clarity
|
56 |
+
centroid = np.mean(librosa.feature.spectral_centroid(y=y, sr=sr))
|
57 |
+
|
58 |
+
# Normalize flatness (lower is better for speech) - range typically 0.01-0.5
|
59 |
+
norm_flatness = max(0, min(100, (0.5 - flatness) / 0.5 * 100))
|
60 |
+
|
61 |
+
# Normalize centroid (mid-range is better for clear speech) - typically 1000-4000 Hz for clear speech
|
62 |
+
ideal_centroid = 2500 # Hz
|
63 |
+
centroid_deviation = abs(centroid - ideal_centroid) / 2000 # Normalized by expected deviation
|
64 |
+
norm_centroid = max(0, min(100, (1 - centroid_deviation) * 100))
|
65 |
+
|
66 |
+
# Combine the two metrics (with more weight on flatness)
|
67 |
+
enunciation_score = (0.7 * norm_flatness) + (0.3 * norm_centroid)
|
68 |
+
|
69 |
+
return enunciation_score
|
70 |
+
|
71 |
+
def calculate_speech_pause_control(segments: List[Dict]) -> float:
|
72 |
+
"""
|
73 |
+
Calculate how effectively pauses are integrated in speech.
|
74 |
+
|
75 |
+
Speech pause control refers to the natural vs. abrupt pauses in speech.
|
76 |
+
|
77 |
+
Args:
|
78 |
+
segments (List[Dict]): List of transcript segments with timing information
|
79 |
+
|
80 |
+
Returns:
|
81 |
+
float: Speech pause control score (0-100)
|
82 |
+
"""
|
83 |
+
if len(segments) < 2:
|
84 |
+
return 100.0 # Not enough segments to evaluate pauses
|
85 |
+
|
86 |
+
pause_durations = []
|
87 |
+
for i in range(len(segments) - 1):
|
88 |
+
pause_dur = segments[i + 1]["start"] - segments[i]["end"]
|
89 |
+
if pause_dur > 0.05: # Only consider actual pauses
|
90 |
+
pause_durations.append(pause_dur)
|
91 |
+
|
92 |
+
if not pause_durations:
|
93 |
+
return 100.0 # No significant pauses detected
|
94 |
+
|
95 |
+
# Calculate the standard deviation of pause durations
|
96 |
+
# More consistent pauses indicate better control
|
97 |
+
pause_std = np.std(pause_durations)
|
98 |
+
|
99 |
+
# Calculate proportion of very long pauses (potentially awkward)
|
100 |
+
long_pauses = sum(1 for d in pause_durations if d > 2.0)
|
101 |
+
long_pause_ratio = long_pauses / len(pause_durations) if pause_durations else 0
|
102 |
+
|
103 |
+
# Normalize std dev (lower is better, but not too low)
|
104 |
+
# Ideal range is around 0.2-0.5 seconds
|
105 |
+
if pause_std < 0.1:
|
106 |
+
std_score = 70 # Too consistent might sound robotic
|
107 |
+
elif pause_std < 0.5:
|
108 |
+
std_score = 100 - ((pause_std - 0.1) / 0.4 * 30) # Scale 70-100
|
109 |
+
else:
|
110 |
+
std_score = max(0, 70 - ((pause_std - 0.5) / 2.0 * 70)) # Scale down from 70
|
111 |
+
|
112 |
+
# Penalize for too many long pauses
|
113 |
+
long_pause_penalty = long_pause_ratio * 50
|
114 |
+
|
115 |
+
# Final score
|
116 |
+
pause_control_score = max(0, min(100, std_score - long_pause_penalty))
|
117 |
+
|
118 |
+
return pause_control_score
|
119 |
+
|
120 |
+
def calculate_voice_clarity_score(y: np.ndarray, sr: int, segments: List[Dict]) -> Dict[str, Any]:
|
121 |
+
"""
|
122 |
+
Calculate the Voice Clarity Score (VCS) and its components.
|
123 |
+
|
124 |
+
VCS reflects the clarity and intelligibility of speech.
|
125 |
+
|
126 |
+
Args:
|
127 |
+
y (np.ndarray): Audio signal
|
128 |
+
sr (int): Sample rate
|
129 |
+
segments (List[Dict]): List of transcript segments with timing information
|
130 |
+
|
131 |
+
Returns:
|
132 |
+
Dict[str, Any]: Dictionary with VCS and component scores
|
133 |
+
"""
|
134 |
+
# Calculate component scores
|
135 |
+
articulation_score = calculate_articulation(y, sr)
|
136 |
+
enunciation_score = calculate_enunciation(y, sr)
|
137 |
+
speech_pause_control_score = calculate_speech_pause_control(segments)
|
138 |
+
|
139 |
+
# Calculate Voice Clarity Score using the formula from the paper
|
140 |
+
vcs = (0.45 * articulation_score) + (0.35 * enunciation_score) + (0.2 * speech_pause_control_score)
|
141 |
+
|
142 |
+
# Create result dictionary
|
143 |
+
result = {
|
144 |
+
"VCS": vcs,
|
145 |
+
"components": {
|
146 |
+
"articulation": articulation_score,
|
147 |
+
"enunciation": enunciation_score,
|
148 |
+
"speech_pause_control": speech_pause_control_score
|
149 |
+
}
|
150 |
+
}
|
151 |
+
|
152 |
+
# Add interpretation
|
153 |
+
result["insight"] = get_clarity_insight(vcs)
|
154 |
+
|
155 |
+
return result
|
156 |
+
|
157 |
+
def get_clarity_insight(vcs: float) -> str:
|
158 |
+
"""
|
159 |
+
Generate insight text based on the Voice Clarity Score.
|
160 |
+
|
161 |
+
Args:
|
162 |
+
vcs (float): Voice Clarity Score (0-100)
|
163 |
+
|
164 |
+
Returns:
|
165 |
+
str: Insight text explaining the score
|
166 |
+
"""
|
167 |
+
if vcs >= 85:
|
168 |
+
return "Excellent voice clarity with precise articulation and well-controlled pauses. Speech is highly intelligible and pleasant to listen to."
|
169 |
+
elif vcs >= 70:
|
170 |
+
return "Good voice clarity with clear pronunciation and generally appropriate pauses. Minor improvements could enhance overall clarity."
|
171 |
+
elif vcs >= 50:
|
172 |
+
return "Moderate voice clarity with some articulation issues or irregular pauses. Focus on clearer pronunciation and more natural pausing."
|
173 |
+
elif vcs >= 30:
|
174 |
+
return "Below average clarity with noticeable articulation problems or awkward pausing patterns. Consider speech exercises to improve clarity."
|
175 |
+
else:
|
176 |
+
return "Speech clarity needs significant improvement. Articulation is unclear and pausing patterns disrupt intelligibility. Speech therapy exercises may be beneficial."
|
vcs/vcs_api.py
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import whisper
|
2 |
+
from .compute_vcs import analyze_voice_quality
|
3 |
+
|
4 |
+
def main(file_path: str, model_size: str = "base") -> dict:
|
5 |
+
try:
|
6 |
+
|
7 |
+
whisper_model = whisper.load_model(model_size)
|
8 |
+
|
9 |
+
results = analyze_voice_quality(file_path, whisper_model)
|
10 |
+
|
11 |
+
# Structure response
|
12 |
+
response = {
|
13 |
+
"Voice Clarity Sore": round(results['VCS'], 2)
|
14 |
+
# "Articulation": round(results['components']['articulation'],2),
|
15 |
+
# "Enunciation": round(results['components']['enunciation'],2),
|
16 |
+
# "Speech Pause Control": round(results['components']['speech_pause_control'],2),
|
17 |
+
}
|
18 |
+
return response
|
19 |
+
|
20 |
+
except Exception as e:
|
21 |
+
raise RuntimeError(f"Error during analysis: {str(e)}")
|
vers/__init__.py
ADDED
File without changes
|
vers/__pycache__/__init__.cpython-312.pyc
ADDED
Binary file (130 Bytes). View file
|
|
vers/__pycache__/compute_vers_score.cpython-312.pyc
ADDED
Binary file (3.68 kB). View file
|
|
vers/__pycache__/filler_analyzer.cpython-312.pyc
ADDED
Binary file (4.32 kB). View file
|
|
vers/__pycache__/find_valence.cpython-312.pyc
ADDED
Binary file (311 Bytes). View file
|
|
vers/__pycache__/main.cpython-312.pyc
ADDED
Binary file (714 Bytes). View file
|
|
vers/__pycache__/vers.cpython-312.pyc
ADDED
Binary file (5.01 kB). View file
|
|
vers/__pycache__/vers_api.cpython-312.pyc
ADDED
Binary file (1.84 kB). View file
|
|
vers/compute_vers_score.py
ADDED
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from .vers import calc_vers
|
2 |
+
import librosa
|
3 |
+
import numpy as np
|
4 |
+
import math
|
5 |
+
from .filler_analyzer import detect_fillers
|
6 |
+
from .find_valence import get_valence_score
|
7 |
+
|
8 |
+
def compute_vers_score(file_path: str, whisper_model) -> dict:
|
9 |
+
"""
|
10 |
+
Compute VERS (Vocal Emotional Regulation Score) and its components from a speech sample.
|
11 |
+
"""
|
12 |
+
result = whisper_model.transcribe(file_path)
|
13 |
+
transcript = result.get("text", "").strip()
|
14 |
+
segments = result.get("segments", [])
|
15 |
+
|
16 |
+
|
17 |
+
|
18 |
+
# Filler count
|
19 |
+
filler_count, _ = detect_fillers(transcript)
|
20 |
+
|
21 |
+
# Load audio
|
22 |
+
y, sr = librosa.load(file_path, sr=None)
|
23 |
+
duration = len(y) / sr if sr else 0.0
|
24 |
+
|
25 |
+
# Volume (RMS)
|
26 |
+
rms = librosa.feature.rms(y=y)[0]
|
27 |
+
mean_rms = float(np.mean(rms))
|
28 |
+
mean_volume_db = 20 * math.log10(mean_rms + 1e-6) if mean_rms > 0 else -80.0
|
29 |
+
volume_std = np.std(20 * np.log10(rms + 1e-6))
|
30 |
+
|
31 |
+
# Max volume
|
32 |
+
vol_max = np.max(np.abs(y)) if y.size > 0 else 0.0
|
33 |
+
vol_max_db = 20 * math.log10(vol_max + 1e-6) if vol_max > 0 else -80.0
|
34 |
+
|
35 |
+
# Pitch variation
|
36 |
+
f0, voiced_flags, voiced_probs = librosa.pyin(
|
37 |
+
y, sr=sr, fmin=80, fmax=400, frame_length=1024, hop_length=256, fill_na=np.nan)
|
38 |
+
voiced_f0 = f0[~np.isnan(f0)]
|
39 |
+
pitch_variation = 0.0
|
40 |
+
if voiced_f0.size > 0:
|
41 |
+
median_f0 = np.nanmedian(voiced_f0)
|
42 |
+
median_f0 = max(median_f0, 1e-6)
|
43 |
+
semitone_diffs = 12 * np.log2(voiced_f0 / median_f0)
|
44 |
+
pitch_variation = float(np.nanstd(semitone_diffs))
|
45 |
+
|
46 |
+
# Pause analysis
|
47 |
+
total_speaking_time = 0.0
|
48 |
+
long_pause_count = 0
|
49 |
+
if segments:
|
50 |
+
for seg in segments:
|
51 |
+
total_speaking_time += (seg["end"] - seg["start"])
|
52 |
+
for i in range(len(segments) - 1):
|
53 |
+
pause_dur = segments[i+1]["start"] - segments[i]["end"]
|
54 |
+
if pause_dur > 1.0:
|
55 |
+
long_pause_count += 1
|
56 |
+
first_start = segments[0]["start"]
|
57 |
+
last_end = segments[-1]["end"]
|
58 |
+
if first_start > 1.0:
|
59 |
+
long_pause_count += 1
|
60 |
+
if duration - last_end > 1.0:
|
61 |
+
long_pause_count += 1
|
62 |
+
|
63 |
+
# WPM
|
64 |
+
words = transcript.split()
|
65 |
+
word_count = len(words)
|
66 |
+
words_per_min = (word_count / duration) * 60.0 if duration > 0 else 0.0
|
67 |
+
|
68 |
+
|
69 |
+
valence_scores = get_valence_score(file_path)
|
70 |
+
|
71 |
+
# Calculate VERS
|
72 |
+
vers_result = calc_vers(
|
73 |
+
filler_count=filler_count,
|
74 |
+
long_pause_count=long_pause_count,
|
75 |
+
pitch_variation=pitch_variation,
|
76 |
+
mean_volume_db=mean_volume_db,
|
77 |
+
vol_max_db=vol_max_db,
|
78 |
+
wpm=words_per_min,
|
79 |
+
volume_std=volume_std,
|
80 |
+
valence_scores=valence_scores
|
81 |
+
)
|
82 |
+
|
83 |
+
# Include transcript optionally
|
84 |
+
vers_result["transcript"] = transcript
|
85 |
+
return vers_result
|
vers/filler_analyzer.py
ADDED
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Define filler words for English, Hindi, Tamil (in both Latin and native scripts)
|
2 |
+
# Mapping each variant to a common label (usually the Latin script for insight reporting)
|
3 |
+
FILLER_VARIANTS = {
|
4 |
+
# English fillers
|
5 |
+
"um": "um", "uh": "uh", "hmm": "hmm", "ah": "ah", "er": "er",
|
6 |
+
"umm": "um", "uhh": "uh", "mmm": "hmm",
|
7 |
+
"like": "like", "you know": "you know", "so": "so", "well": "well",
|
8 |
+
# Hindi fillers (Devanagari and transliteration)
|
9 |
+
"मतलब": "matlab", "matlab": "matlab",
|
10 |
+
"क्या कहते हैं": "kya kehte hain", "kya kehte hain": "kya kehte hain",
|
11 |
+
"वो ना": "wo na", "woh na": "wo na", "wo na": "wo na",
|
12 |
+
"ऐसा है": "aisa hai", "aisa hai": "aisa hai",
|
13 |
+
"हाँ": "haan", "haan": "haan", "हा": "haan", # "हा" might appear as a shorter "haan"
|
14 |
+
"अच्छा": "acha", "acha": "acha",
|
15 |
+
# Tamil fillers (Tamil script and transliteration)
|
16 |
+
"பாத்தீங்கனா": "paatheenga-na", "paatheenga na": "paatheenga-na", "paatheenga-na": "paatheenga-na",
|
17 |
+
"அப்பரம்": "apparam", "apparam": "apparam",
|
18 |
+
"என்ன": "enna", "enna": "enna"
|
19 |
+
}
|
20 |
+
|
21 |
+
def detect_fillers(transcript):
|
22 |
+
"""
|
23 |
+
Detects filler words in the transcript.
|
24 |
+
|
25 |
+
Args:
|
26 |
+
transcript: Full transcript text
|
27 |
+
|
28 |
+
Returns:
|
29 |
+
tuple: (filler_count, filler_occurrences)
|
30 |
+
"""
|
31 |
+
transcript_lower = transcript.lower()
|
32 |
+
filler_count = 0
|
33 |
+
# Track which specific fillers were used (for insight examples)
|
34 |
+
filler_occurrences = {}
|
35 |
+
|
36 |
+
for variant, label in FILLER_VARIANTS.items():
|
37 |
+
if variant in transcript_lower:
|
38 |
+
count = transcript_lower.count(variant)
|
39 |
+
if count > 0:
|
40 |
+
filler_count += count
|
41 |
+
# Accumulate count for the normalized label
|
42 |
+
filler_occurrences[label] = filler_occurrences.get(label, 0) + count
|
43 |
+
|
44 |
+
return filler_count, filler_occurrences
|
45 |
+
|
46 |
+
|
47 |
+
def analyze_filler_words(filler_count, filler_occurrences, duration):
|
48 |
+
"""
|
49 |
+
Analyzes filler word usage in speech.
|
50 |
+
|
51 |
+
Args:
|
52 |
+
filler_count: Total count of filler words
|
53 |
+
filler_occurrences: Dictionary of specific filler words and their counts
|
54 |
+
duration: Duration of the audio in seconds
|
55 |
+
|
56 |
+
Returns:
|
57 |
+
dict: Contains the filler words score and insight text
|
58 |
+
"""
|
59 |
+
# Extract top examples for insights
|
60 |
+
filler_examples = []
|
61 |
+
if filler_occurrences:
|
62 |
+
# Sort by frequency
|
63 |
+
sorted_fillers = sorted(filler_occurrences.items(), key=lambda x: x[1], reverse=True)
|
64 |
+
for label, count in sorted_fillers[:2]:
|
65 |
+
filler_examples.append(label)
|
66 |
+
|
67 |
+
# Compute fillers per minute as a gauge
|
68 |
+
filler_per_min = (filler_count / duration) * 60.0 if duration > 0 else 0.0
|
69 |
+
|
70 |
+
if filler_count == 0:
|
71 |
+
filler_score = 10
|
72 |
+
elif filler_per_min < 1:
|
73 |
+
filler_score = 9
|
74 |
+
elif filler_per_min < 3:
|
75 |
+
filler_score = 8
|
76 |
+
elif filler_per_min < 5:
|
77 |
+
filler_score = 6
|
78 |
+
elif filler_per_min < 10:
|
79 |
+
filler_score = 4
|
80 |
+
else:
|
81 |
+
filler_score = 2
|
82 |
+
|
83 |
+
filler_score = max(0, filler_score)
|
84 |
+
|
85 |
+
# Generate insight text based on the score and examples
|
86 |
+
if filler_count == 0:
|
87 |
+
insight = "No filler words (um, ah, etc.) were detected, keeping the speech very clear."
|
88 |
+
elif filler_count <= 2:
|
89 |
+
example = filler_examples[0] if filler_examples else "um"
|
90 |
+
insight = f"Only a couple of filler words (e.g., '{example}') were used, which had minimal impact."
|
91 |
+
elif filler_count <= 5:
|
92 |
+
examples = ", ".join(f"'{ex}'" for ex in filler_examples) if filler_examples else "filler words"
|
93 |
+
insight = f"Some filler words {examples} were used occasionally; reducing them could improve clarity."
|
94 |
+
else:
|
95 |
+
examples = ", ".join(f"'{ex}'" for ex in filler_examples) if filler_examples else "'um'"
|
96 |
+
insight = f"Frequent filler words such as {examples} were detected, which can distract the audience and suggest uncertainty."
|
97 |
+
|
98 |
+
return {
|
99 |
+
"score": int(filler_score),
|
100 |
+
"insight": insight
|
101 |
+
}
|
vers/find_valence.py
ADDED
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# from transformers.models.wav2vec2 import Wav2Vec2Model, Wav2Vec2FeatureExtractor
|
2 |
+
# import torchaudio
|
3 |
+
# import torch
|
4 |
+
# import torch.nn as nn
|
5 |
+
|
6 |
+
|
7 |
+
|
8 |
+
def get_valence_score(file_path):
|
9 |
+
# class VADPredictor(nn.Module):
|
10 |
+
# """Model to predict VAD Scores"""
|
11 |
+
# def __init__(self, pretrained_model_name="facebook/wav2vec2-base-960h", freeze_feature_extractor=True):
|
12 |
+
# super(VADPredictor, self).__init__()
|
13 |
+
|
14 |
+
# self.wav2vec2 = Wav2Vec2Model.from_pretrained(pretrained_model_name)
|
15 |
+
|
16 |
+
# if freeze_feature_extractor:
|
17 |
+
# for param in self.wav2vec2.feature_extractor.parameters():
|
18 |
+
# param.requires_grad = False
|
19 |
+
|
20 |
+
# hidden_size = self.wav2vec2.config.hidden_size
|
21 |
+
|
22 |
+
# self.valence_layers = nn.Sequential(
|
23 |
+
# nn.Linear(hidden_size, 256),
|
24 |
+
# nn.ReLU(),
|
25 |
+
# nn.Dropout(0.3),
|
26 |
+
# nn.Linear(256,64),
|
27 |
+
# nn.Linear(64,1)
|
28 |
+
# )
|
29 |
+
# self.arousal_layers = nn.Sequential(
|
30 |
+
# nn.Linear(hidden_size, 256),
|
31 |
+
# nn.ReLU(),
|
32 |
+
# nn.Dropout(0.3),
|
33 |
+
# nn.Linear(256,64),
|
34 |
+
# nn.Linear(64,1)
|
35 |
+
# )
|
36 |
+
# self.dominance_layers = nn.Sequential(
|
37 |
+
# nn.Linear(hidden_size, 256),
|
38 |
+
# nn.ReLU(),
|
39 |
+
# nn.Dropout(0.3),
|
40 |
+
# nn.Linear(256,64),
|
41 |
+
# nn.Linear(64,1)
|
42 |
+
# )
|
43 |
+
|
44 |
+
# def forward(self, input_values, attention_mask=None):
|
45 |
+
# outputs = self.wav2vec2(input_values, attention_mask=attention_mask)
|
46 |
+
# last_hidden_state = outputs.last_hidden_state
|
47 |
+
# pooled_output = torch.mean(last_hidden_state, dim=1)
|
48 |
+
|
49 |
+
# valence = self.valence_layers(pooled_output)
|
50 |
+
# arousal = self.arousal_layers(pooled_output)
|
51 |
+
# dominance = self.dominance_layers(pooled_output)
|
52 |
+
|
53 |
+
# return {
|
54 |
+
# 'valence': valence.squeeze(-1),
|
55 |
+
# 'arousal': arousal.squeeze(-1),
|
56 |
+
# 'dominance': dominance.squeeze(-1)
|
57 |
+
# }
|
58 |
+
|
59 |
+
|
60 |
+
# model = VADPredictor()
|
61 |
+
# model.load_state_dict(torch.load(r"D:\Intern\shankh\DUMP\vad_predictor_model.pt", map_location=torch.device("cpu")))
|
62 |
+
# model.eval()
|
63 |
+
|
64 |
+
# feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained("facebook/wav2vec2-base-960h")
|
65 |
+
|
66 |
+
# # Load and process audio
|
67 |
+
# file_path = file_path
|
68 |
+
# waveform, sr = torchaudio.load(file_path)
|
69 |
+
|
70 |
+
# # Convert to mono
|
71 |
+
# if waveform.shape[0] > 1:
|
72 |
+
# waveform = waveform.mean(dim=0, keepdim=True)
|
73 |
+
|
74 |
+
# # Resample to 16000 Hz
|
75 |
+
# if sr != 16000:
|
76 |
+
# resampler = torchaudio.transforms.Resample(sr, 16000)
|
77 |
+
# waveform = resampler(waveform)
|
78 |
+
# sr = 16000
|
79 |
+
|
80 |
+
# # Normalize
|
81 |
+
# waveform = waveform / waveform.abs().max()
|
82 |
+
|
83 |
+
# # Parameters
|
84 |
+
# segment_sec = 1
|
85 |
+
# segment_samples = int(segment_sec * sr)
|
86 |
+
|
87 |
+
# valence_scores = []
|
88 |
+
|
89 |
+
# # Inference per segment
|
90 |
+
# with torch.no_grad():
|
91 |
+
# for start in range(0, waveform.shape[1] - segment_samples + 1, segment_samples):
|
92 |
+
# segment = waveform[:, start:start+segment_samples]
|
93 |
+
# input_values = feature_extractor(segment.squeeze().numpy(), sampling_rate=16000, return_tensors="pt").input_values
|
94 |
+
# output = model(input_values)
|
95 |
+
# val = output['valence'].item()
|
96 |
+
# valence_scores.append(val)
|
97 |
+
valence_scores = 5.0
|
98 |
+
|
99 |
+
return valence_scores
|
100 |
+
|
vers/main.py
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
from .compute_vers_score import compute_vers_score
|
3 |
+
import whisper
|
4 |
+
|
5 |
+
|
6 |
+
|
7 |
+
whisper_model = whisper.load_model("base")
|
8 |
+
|
9 |
+
test_result = compute_vers_score(r"D:\Intern\shankh\audio_samples\obama_short.wav", whisper_model)
|
10 |
+
|
11 |
+
print("VERS Score:", test_result["VERS"])
|
12 |
+
print("ESS:", test_result["ESS"])
|
13 |
+
print("LCS:", test_result["LCS"])
|
14 |
+
print("SRS:", test_result["SRS"])
|
15 |
+
print("Insight:", test_result["insight"])
|
16 |
+
print("Transcript:", test_result["transcript"])
|
vers/vers.py
ADDED
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import numpy as np
|
2 |
+
|
3 |
+
def calc_ess(pitch_variation, vol_max_db, mean_volume_db, valence_scores):
|
4 |
+
"""
|
5 |
+
Emotional Stability Score(ESS) : Measures the consistency of the speaker's emotional tone, reflecting their ability to regulate emotions during speech.
|
6 |
+
|
7 |
+
Requires:
|
8 |
+
Tonal Steadiness: The lack of extreme fluctuations in emotional tone.
|
9 |
+
Absence of Sudden Loudness Spikes: Indicates controlled expression without abrupt emotional shifts.
|
10 |
+
Valence Stability: Consistency in the overall positive or negative tone across the speech.
|
11 |
+
"""
|
12 |
+
# calculate tonal steadiness
|
13 |
+
tonal_steadiness = max(0, 100 - (pitch_variation * 10))
|
14 |
+
|
15 |
+
# calculate loudness spikes
|
16 |
+
spike = max(0, vol_max_db - mean_volume_db - 15)
|
17 |
+
spike_ratio = min(spike / 30, 1.0) # Normalize with typical loudness range
|
18 |
+
stability = 1 - spike_ratio
|
19 |
+
loudness_stability = stability * 100
|
20 |
+
|
21 |
+
# calculate valence stability
|
22 |
+
valence_stability = 100 - (np.std(valence_scores) * 20)
|
23 |
+
|
24 |
+
ESS = (0.45 * float(tonal_steadiness)) + (0.35 * float(loudness_stability)) + (0.2 * float(valence_stability))
|
25 |
+
print(f" tonal_steadiness: {tonal_steadiness}, loudness_stability: {loudness_stability}, valence_stability: {valence_stability}")
|
26 |
+
return ESS
|
27 |
+
|
28 |
+
def calc_lcs(volume_std, vol_max_db, mean_volume_db):
|
29 |
+
"""
|
30 |
+
Loudness Control Score (LCS): Evaluates how well the speaker manages volume
|
31 |
+
|
32 |
+
Requires:
|
33 |
+
- Volume Stability: Consistency in speech amplitude.
|
34 |
+
- Controlled Emphasis: The ability to modulate loudness smoothly for emphasis rather than abrupt changes.
|
35 |
+
"""
|
36 |
+
vol_stability = max(0, 100 - (volume_std * 5)) # Scale std for speech (5 dB std = 75)
|
37 |
+
|
38 |
+
# Controlled Emphasis (45%)
|
39 |
+
emphasis_spike = max(0, vol_max_db - mean_volume_db - 3)
|
40 |
+
spike_ratio = min(emphasis_spike / 15, 1.0) # Normalize to 15 dB range
|
41 |
+
emphasis_control = (1 - spike_ratio) * 100
|
42 |
+
|
43 |
+
# Combine scores
|
44 |
+
lcs = 0.55 * vol_stability + 0.45 * emphasis_control
|
45 |
+
print(f"vol_stability: {vol_stability}, emphasis_control: {emphasis_control}")
|
46 |
+
return min(100, max(0, lcs))
|
47 |
+
|
48 |
+
def calc_srs(wpm, filler_count, long_pause_count, pitch_variation):
|
49 |
+
"""
|
50 |
+
Speech Rate Stability (SRS): Reflects the consistency of the speaker's pace and rhythm.
|
51 |
+
|
52 |
+
Requires:
|
53 |
+
- Words per Minute Consistency: Regularity in speech speed.
|
54 |
+
- Absence of Sudden Speed Shifts: Smooth transitions without erratic tempo changes.
|
55 |
+
"""
|
56 |
+
ideal_wpm = 150
|
57 |
+
wpm_deviation = min(30, abs(wpm - ideal_wpm)) # Cap at 30 WPM deviation
|
58 |
+
wpm_consistency = max(0, 100 - (wpm_deviation * 1.67)) # 100-50 for max deviation
|
59 |
+
|
60 |
+
# Sudden Speech Shift Penalty
|
61 |
+
filler_penalty = min(filler_count / 10, 1.0)
|
62 |
+
pause_penalty = min(long_pause_count / 5, 1.0)
|
63 |
+
pitch_penalty = min(pitch_variation / 3.0, 1.0) # High variation → unstable
|
64 |
+
|
65 |
+
# Combine into absence of sudden shifts
|
66 |
+
stability = (1 - ((filler_penalty + pause_penalty + pitch_penalty) / 3)) * 100
|
67 |
+
|
68 |
+
# Final SRS Score
|
69 |
+
SRS = (0.45 * wpm_consistency) + (0.55 * stability)
|
70 |
+
print(f"wpm_consistency: {wpm_consistency}, stability: {stability}")
|
71 |
+
return min(100, max(0, SRS))
|
72 |
+
|
73 |
+
def calc_vers(filler_count, long_pause_count, pitch_variation, mean_volume_db, vol_max_db, wpm, volume_std, valence_scores):
|
74 |
+
ESS = calc_ess(pitch_variation, vol_max_db, mean_volume_db, valence_scores)
|
75 |
+
LCS = calc_lcs(volume_std, vol_max_db, mean_volume_db)
|
76 |
+
SRS = calc_srs(wpm, filler_count, long_pause_count, pitch_variation)
|
77 |
+
|
78 |
+
# Calculate the VERS score using the formula
|
79 |
+
VERS = (0.5 * ESS) + (0.3 * LCS) + (0.2 * SRS) # This would be value from 0 to 100
|
80 |
+
|
81 |
+
if VERS > 0 and VERS < 50:
|
82 |
+
insight = """Poor regulation—noticeable swings in tone and uncontrolled
|
83 |
+
emotional expression. Feedback: Consider exercises and professional
|
84 |
+
coaching to stabilize your emotional delivery."""
|
85 |
+
elif VERS >= 50 and VERS < 80:
|
86 |
+
insight = """Moderate regulation—occasional fluctuations or abrupt changes.
|
87 |
+
Feedback: Work on smoothing out volume changes and maintaining a steady tone."""
|
88 |
+
elif VERS >= 80 and VERS <= 100:
|
89 |
+
insight = """Excellent regulation—steady tone and controlled volume dynamics.
|
90 |
+
Feedback: Continue using techniques that maintain emotional balance."""
|
91 |
+
else:
|
92 |
+
insight = "Invalid score calculated"
|
93 |
+
|
94 |
+
return {
|
95 |
+
"VERS": int(VERS),
|
96 |
+
"ESS": round(ESS, 1),
|
97 |
+
"LCS": round(LCS, 1),
|
98 |
+
"SRS": round(SRS, 1),
|
99 |
+
"insight": insight
|
100 |
+
}
|
101 |
+
|
102 |
+
# # Test input
|
103 |
+
# test_result = calc_vers(
|
104 |
+
# filler_count=4,
|
105 |
+
# long_pause_count=2,
|
106 |
+
# pitch_variation=3.2,
|
107 |
+
# mean_volume_db=65,
|
108 |
+
# vol_max_db=82,
|
109 |
+
# wpm=148,
|
110 |
+
# volume_std=4.1,
|
111 |
+
# valence_scores=[5.2, 5.5, 4.9]
|
112 |
+
# )
|
113 |
+
|
114 |
+
# print("VERS Score:", test_result["VERS"])
|
115 |
+
# print("ESS:", test_result["ESS"])
|
116 |
+
# print("LCS:", test_result["LCS"])
|
117 |
+
# print("SRS:", test_result["SRS"])
|
118 |
+
# print("Insight:", test_result["insight"])
|
vers/vers_api.py
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import whisper
|
2 |
+
import numpy as np
|
3 |
+
from .compute_vers_score import compute_vers_score
|
4 |
+
|
5 |
+
def convert_numpy_types(obj):
|
6 |
+
"""Convert NumPy types to Python native types for JSON serialization."""
|
7 |
+
if isinstance(obj, np.integer):
|
8 |
+
return int(obj)
|
9 |
+
elif isinstance(obj, np.floating):
|
10 |
+
return float(obj)
|
11 |
+
elif isinstance(obj, np.ndarray):
|
12 |
+
return obj.tolist()
|
13 |
+
elif isinstance(obj, dict):
|
14 |
+
return {k: convert_numpy_types(v) for k, v in obj.items()}
|
15 |
+
elif isinstance(obj, list):
|
16 |
+
return [convert_numpy_types(i) for i in obj]
|
17 |
+
else:
|
18 |
+
return obj
|
19 |
+
|
20 |
+
def main(file_path: str, model_size: str = "base") -> dict:
|
21 |
+
try:
|
22 |
+
# Load whisper model
|
23 |
+
whisper_model = whisper.load_model(model_size)
|
24 |
+
|
25 |
+
# Compute VERS score
|
26 |
+
results = compute_vers_score(file_path, whisper_model)
|
27 |
+
|
28 |
+
# Convert any NumPy types to native Python types
|
29 |
+
results = convert_numpy_types(results)
|
30 |
+
|
31 |
+
# Structure response with rounded values
|
32 |
+
# (using Python's built-in round function which returns Python native float)
|
33 |
+
response = {
|
34 |
+
"VERS Score": round(results['VERS'], 2)
|
35 |
+
# "ESS": round(results['ESS'], 2),
|
36 |
+
# "LCS": round(results['LCS'], 2),
|
37 |
+
# "SRS": round(results['SRS'], 2),
|
38 |
+
# "Insight": results['insight'],
|
39 |
+
}
|
40 |
+
|
41 |
+
return response
|
42 |
+
|
43 |
+
except Exception as e:
|
44 |
+
raise RuntimeError(f"Error during analysis: {str(e)}")
|
ves/__init__.py
ADDED
File without changes
|