44 DISORDERED_SPEECH_GENERATION_PROMPT ,
55 DISORDER_DESCRIPTION ,
66 USE_AUDIO_IN_VIDEO ,
7+ SAMPLE_RATE ,
8+ VERIFICATION_PROMPT ,
79)
810from transformers import (
911 Qwen2_5OmniForConditionalGeneration ,
2123import re
2224import os
2325import 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
2539def 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
132155def 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+
156210def 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