Skip to content

Commit 6ca63fc

Browse files
committed
Add child-voice processing and disordered voice verification
1 parent 3ce8ced commit 6ca63fc

2 files changed

Lines changed: 236 additions & 10 deletions

File tree

finetune/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
SYSTEM_PROMPT = "You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, capable of perceiving auditory and visual inputs, as well as generating text and speech."
5757
NORMAL_SPEECH_GENERATION_PROMPT = "Repeat exactly the following sentence and do not alter it. Return only the sentence and nothing else. The sentence is: {transcript}"
5858
DISORDERED_SPEECH_GENERATION_PROMPT = """Assuming you are a child with speech disordered. {disorder_description} Generate a corresponding transcript with the `{disorder_type} disorder` when the child say: "{transcript}". Return only the transcript text and nothing else."""
59+
VERIFICATION_PROMPT = """You are a highly experienced Speech-Language Pathologist (SLP). An audio recording will be provided, typically consisting of a speech prompt from a pathologist followed by a child's repetition. The prompt the child is trying to repeat is as follows: {words}. Based on your professional expertise: 1. Assess the child's speech in the recording for signs of typical development or potential speech-language disorder. 2. Conclude your analysis with one of the following labels only: 'typically_developing' or 'speech_disorder'. 3. Provide your response as the correct label without any additional explanation, commentary, or unnecessary text.""" # noqa: E501
5960

6061
DISORDER_DESCRIPTION = {
6162
"addition": "The children with `addition disorder` will insert of an extra sound into a word.",
@@ -66,3 +67,4 @@
6667

6768
# set use audio in video
6869
USE_AUDIO_IN_VIDEO = True
70+
SAMPLE_RATE = 24000

finetune/utils.py

Lines changed: 234 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
DISORDERED_SPEECH_GENERATION_PROMPT,
55
DISORDER_DESCRIPTION,
66
USE_AUDIO_IN_VIDEO,
7+
SAMPLE_RATE,
8+
VERIFICATION_PROMPT,
79
)
810
from transformers import (
911
Qwen2_5OmniForConditionalGeneration,
@@ -21,6 +23,18 @@
2123
import re
2224
import os
2325
import json
26+
import pyworld as pw
27+
import numpy as np
28+
import librosa
29+
import tempfile
30+
from scipy.signal import butter, lfilter, sosfilt
31+
32+
33+
def get_temp_file_path(suffix=".wav"):
34+
temp_dir = tempfile.gettempdir()
35+
filename = next(tempfile._get_candidate_names()) + suffix
36+
return os.path.join(temp_dir, filename)
37+
2438

2539
def load_model(model_name, attn_implementation=None):
2640
if "qwen2.5-omni" in model_name.lower():
@@ -37,7 +51,9 @@ def load_model(model_name, attn_implementation=None):
3751
return generation_config, model, processor
3852

3953

40-
def inference(model, processor, prompts, speaker="Ethan", return_audio=False, **kwargs):
54+
def inference(
55+
model, processor, prompts, audios=[], speaker="Ethan", return_audio=False, **kwargs
56+
):
4157
"""
4258
Perform inference using the model and processor.
4359
@@ -52,7 +68,7 @@ def inference(model, processor, prompts, speaker="Ethan", return_audio=False, **
5268
"""
5369

5470
conversations = []
55-
for prompt in prompts:
71+
for idx, prompt in enumerate(prompts):
5672
conversation = [
5773
{
5874
"role": "system",
@@ -67,6 +83,13 @@ def inference(model, processor, prompts, speaker="Ethan", return_audio=False, **
6783
],
6884
},
6985
]
86+
try:
87+
if len(audios) > idx and audios[idx] is not None:
88+
conversation[1]["content"].insert(
89+
0, {"type": "audio", "audio": audios[idx]}
90+
)
91+
except Exception as e:
92+
print(f"Error processing audio for prompt {idx}: {e}")
7093
conversations.append(conversation)
7194

7295
# Preparation for inference
@@ -126,7 +149,7 @@ def save_sound_file(audio, file_path):
126149
audio (np.ndarray): The audio data to save.
127150
file_path (str): The path where the audio file will be saved.
128151
"""
129-
sf.write(file_path, audio, samplerate=24000)
152+
sf.write(file_path, audio, samplerate=SAMPLE_RATE)
130153

131154

132155
def verify_transcript(transcripts):
@@ -153,6 +176,37 @@ def verify_transcript(transcripts):
153176
return verified
154177

155178

179+
def verify_speech(transcript, audio, label, model, processor, generation_config):
180+
"""
181+
Verify the speech by checking if the transcript matches the audio.
182+
183+
Args:
184+
transcript (str): The transcript to verify.
185+
audio_path (str): The path to the audio file.
186+
label (str): The expected label for the transcript.
187+
188+
Returns:
189+
bool: True if the transcript matches the audio, False otherwise.
190+
"""
191+
audio_path = get_temp_file_path(suffix=".wav")
192+
save_sound_file(audio, audio_path)
193+
output = inference(
194+
model,
195+
processor,
196+
prompts=[VERIFICATION_PROMPT.format(words=transcript)],
197+
audios=[audio_path],
198+
num_return_sequences=1,
199+
do_sample=False,
200+
max_new_tokens=8,
201+
return_audio=False,
202+
generation_config=generation_config,
203+
)
204+
if label.lower() in output[0].strip().lower():
205+
return True
206+
207+
return False
208+
209+
156210
def post_process_transcripts(transcripts):
157211
"""
158212
Post-process the transcripts to remove unnecessary characters and whitespace.
@@ -444,7 +498,8 @@ def create_dataset(model_name, num_samples, data_dir="data", iteration=0, seed=4
444498

445499
# Define prompts and disorder types
446500
list_of_disorder_types = list(DISORDER_DESCRIPTION.keys())
447-
list_speakers = ["Chelsie", "Ethan"]
501+
list_speakers = ["Chelsea", "Ethan"]
502+
genders = {"Chelsea": "female", "Ethan": "male"}
448503
trial = 1
449504

450505
# Generate normal speech transcripts
@@ -477,6 +532,7 @@ def create_dataset(model_name, num_samples, data_dir="data", iteration=0, seed=4
477532
)
478533
dc_transcript = post_process_transcripts(dc_transcript)
479534
if dc_transcript[0].strip().lower() == transcript.strip().lower():
535+
audio = adult_to_child_voice(audio, gender=genders[speaker])
480536
normal_audios.append(audio)
481537
has_correct_audio = True
482538
break
@@ -504,13 +560,18 @@ def create_dataset(model_name, num_samples, data_dir="data", iteration=0, seed=4
504560
)
505561

506562
# Generate disordered speech transcripts
507-
disordered_transcripts, disordered_original_transcripts, disordered_audios, disordered_types = [], [], [], []
563+
(
564+
disordered_transcripts,
565+
disordered_original_transcripts,
566+
disordered_audios,
567+
disordered_types,
568+
) = ([], [], [], [])
508569
for transcript in tqdm(
509570
normal_transcripts, desc="Generating disordered speech audios"
510571
):
511572
speaker = random.choice(list_speakers)
512573
disorder_type = random.choice(list_of_disorder_types)
513-
dc_transcripts, audios = inference(
574+
dc_transcripts, audio = inference(
514575
model,
515576
processor,
516577
prompts=[
@@ -527,11 +588,22 @@ def create_dataset(model_name, num_samples, data_dir="data", iteration=0, seed=4
527588
return_audio=True,
528589
)
529590
dc_transcripts = post_process_transcripts(dc_transcripts)
530-
diff_count = editdistance.eval(dc_transcripts[0].strip().lower(), transcript.strip().lower())
531-
if diff_count > 0 and diff_count <= 10:
591+
diff_count = editdistance.eval(
592+
dc_transcripts[0].strip().lower(), transcript.strip().lower()
593+
)
594+
audio = adult_to_child_voice(audio, gender=genders[speaker])
595+
audio_verified = verify_speech(
596+
transcript.strip().lower(),
597+
audio,
598+
"speech_disorder",
599+
model,
600+
processor,
601+
generation_config,
602+
)
603+
if diff_count > 0 and diff_count <= 10 and audio_verified:
532604
disordered_transcripts.append(dc_transcripts[0])
533605
disordered_original_transcripts.append(transcript)
534-
disordered_audios.append(audios)
606+
disordered_audios.append(audio)
535607
disordered_types.append(disorder_type)
536608
else:
537609
# If the disordered transcript is the same as the normal one, retry
@@ -565,7 +637,22 @@ def create_dataset(model_name, num_samples, data_dir="data", iteration=0, seed=4
565637
generation_config=generation_config,
566638
)
567639
dc_transcript = post_process_transcripts(dc_transcript)
568-
if dc_transcript[0].strip().lower() == disordered_transcript.strip().lower():
640+
if (
641+
dc_transcript[0].strip().lower()
642+
== disordered_transcript.strip().lower()
643+
):
644+
audio = adult_to_child_voice(audio, gender=genders[speaker])
645+
audio_verified = verify_speech(
646+
transcript.strip().lower(),
647+
audio,
648+
"speech_disorder",
649+
model,
650+
processor,
651+
generation_config,
652+
)
653+
if not audio_verified:
654+
continue
655+
569656
disordered_audios.append(audio)
570657
has_correct_audio = True
571658
break
@@ -614,3 +701,140 @@ def create_dataset(model_name, num_samples, data_dir="data", iteration=0, seed=4
614701
add_dataset_config(dataset_name, f"{dataset_name}/{dataset_name}.json")
615702

616703
return dataset_name
704+
705+
706+
def butter_bandpass(lowcut, highcut, fs, order=4):
707+
nyquist = fs / 2
708+
low = lowcut / nyquist
709+
high = highcut / nyquist
710+
b, a = butter(order, [low, high], btype="band")
711+
return b, a
712+
713+
714+
def butter_highpass(cutoff, sr, order=4):
715+
nyq = 0.5 * sr
716+
normal_cutoff = cutoff / nyq
717+
b, a = butter(order, normal_cutoff, btype="high", analog=False)
718+
return b, a
719+
720+
721+
def apply_eq(y, sr, gender):
722+
"""Apply high-pass filter at 200 Hz."""
723+
# High-pass filter at 200 Hz
724+
b, a = butter_highpass(200, sr, order=4)
725+
filtered = lfilter(b, a, y)
726+
return filtered
727+
728+
729+
def add_breathiness(y, sr, level=0.02):
730+
"""Add light breathiness by mixing filtered white noise."""
731+
noise = np.random.randn(len(y))
732+
b, a = butter_bandpass(3000, 8000, sr, order=4)
733+
breath = lfilter(b, a, noise)
734+
return y + (breath * level)
735+
736+
737+
def dynamic_pitch_modulation(y, sr, semitone_range=0.5, segment_length=0.2):
738+
"""Small random pitch changes every few hundred ms."""
739+
n_samples = len(y)
740+
seg_size = int(segment_length * sr)
741+
output = np.zeros_like(y)
742+
for start in range(0, n_samples, seg_size):
743+
end = min(start + seg_size, n_samples)
744+
pitch_shift_amt = np.random.uniform(-semitone_range, semitone_range)
745+
shifted = librosa.effects.pitch_shift(
746+
y[start:end], sr=sr, n_steps=pitch_shift_amt
747+
)
748+
output[start:end] = shifted[: end - start]
749+
return output
750+
751+
752+
def design_peaking_eq(f0, Q, gain_db, sr):
753+
"""Design a peaking EQ filter (second-order section)."""
754+
# Convert gain in dB to linear
755+
A = 10 ** (gain_db / 40)
756+
w0 = 2 * np.pi * f0 / sr
757+
alpha = np.sin(w0) / (2 * Q)
758+
759+
b0 = 1 + alpha * A
760+
b1 = -2 * np.cos(w0)
761+
b2 = 1 - alpha * A
762+
a0 = 1 + alpha / A
763+
a1 = -2 * np.cos(w0)
764+
a2 = 1 - alpha / A
765+
766+
# Normalize
767+
b = np.array([b0, b1, b2]) / a0
768+
a = np.array([1, a1 / a0, a2 / a0])
769+
770+
# Convert to SOS format for stability
771+
from scipy.signal import tf2sos
772+
773+
sos = tf2sos(b, a)
774+
return sos
775+
776+
777+
def apply_3band_eq(y, sr):
778+
# 100 Hz: +1 dB, Q=1
779+
sos1 = design_peaking_eq(100, Q=1, gain_db=1, sr=sr)
780+
# 900 Hz: -7 dB, Q=1
781+
sos2 = design_peaking_eq(900, Q=1, gain_db=-7, sr=sr)
782+
# 2500 Hz: -10 dB, Q=1
783+
sos3 = design_peaking_eq(2500, Q=1, gain_db=-10, sr=sr)
784+
785+
# Apply sequentially
786+
y_eq = sosfilt(sos1, y)
787+
y_eq = sosfilt(sos2, y_eq)
788+
y_eq = sosfilt(sos3, y_eq)
789+
return y_eq
790+
791+
792+
def adult_to_child_voice(input_sound, gender):
793+
"""
794+
Convert adult voice to child-like voice using pitch + formant shift.
795+
796+
Parameters:
797+
input_sound (str): Path to input sound file.
798+
output_wav (str): Path to save output WAV file.
799+
gender (str): 'male' or 'female'
800+
"""
801+
# Load audio
802+
x = apply_3band_eq(input_sound, sr=SAMPLE_RATE)
803+
if x.ndim > 1: # Convert stereo to mono
804+
x = np.mean(x, axis=1)
805+
806+
# WORLD analysis
807+
f0, timeaxis = pw.harvest(x, SAMPLE_RATE) # Fundamental frequency
808+
sp = pw.cheaptrick(x, f0, timeaxis, SAMPLE_RATE) # Spectral envelope
809+
ap = pw.d4c(x, f0, timeaxis, SAMPLE_RATE) # Aperiodicity
810+
811+
# Define pitch & formant scaling
812+
if gender.lower() == "male":
813+
pitch_scale = random.uniform(1.7, 1.9) # Shift pitch higher
814+
elif gender.lower() == "female":
815+
pitch_scale = random.uniform(1.2, 1.4)
816+
else:
817+
raise ValueError("gender must be 'male' or 'female'")
818+
819+
# Apply pitch shift
820+
f0 *= pitch_scale
821+
822+
# WORLD synthesis
823+
y = pw.synthesize(f0, sp, ap, SAMPLE_RATE)
824+
825+
# Step 1: Dynamic pitch modulation
826+
# y = dynamic_pitch_modulation(y, SAMPLE_RATE)
827+
828+
# Step 2: Spectral EQ (reduce lows, boost highs)
829+
y = apply_eq(y, SAMPLE_RATE, gender)
830+
831+
# Step 3: Add breathiness
832+
# y = add_breathiness(y, SAMPLE_RATE, level=random.uniform(0, 5e-4))
833+
834+
# Step 4: Speed up tempo (slightly)
835+
y = librosa.effects.time_stretch(y, rate=random.uniform(1.1, 1.2))
836+
837+
# Normalize to avoid clipping
838+
# y = y / np.max(np.abs(y) + 1e-6)
839+
840+
return y

0 commit comments

Comments
 (0)