أدوات استنساخ الصوت
يخطف استنساخ الصوت عناوين الأخبار، لكن تحت السحر تكمن مشكلة قياس: ما الذي يجعل صوتًا يبدو بصوت ذلك الشخص بالضبط؟ تبني هذه الأدوات النصف النزيه والقابل للتفسير من تلك المشكلة في numpy ، اقرأ الصوت كأرقام خام، وقِس درجة الصوت والطاقة لكل إطار، واختصر مقطعًا في ملف تعريف متحدث، وقارن ملفّي تعريف، وأخيرًا شكّل مقطعًا باتجاه إحصائيات صوت آخر. لن تُنتج صوتًا اصطناعيًا لمشهور هنا؛ ستفهم الأرقام التي يبدأ منها كل نظام استنساخ حقيقي.
هذا يفترض Python 101، وراحة مع numpy، وإلمامًا عابرًا بمعدل العينات والتردد ، لا شيء من تحليل البيانات يتجاوز ذلك مطلوب. هذا اختياري وغير مُقيَّم؛ راجع مشاريع من العالم الحقيقي للقائمة الكاملة والنامية.
شرط المسؤولية. استنساخ صوت دون موافقة هو انتحال شخصية، وفي العديد من الدول احتيال ، صُمّمت هذه الأدوات كأداة قياس ولا تشحن أي نموذج يعيد إنتاج شخص حقيقي من عينة. استخدمها على تسجيلاتك الخاصة، ومقاطع اصطناعية، ومواد مرجعية مُصنَّفة بوضوح. تذكّر ما يمكن لمستخرج الميزات أن يحمله: إحصائيات، لا هوية.
🎯 ما ستفعله
- قراءة ملف WAV في مصفوفة numpy مُطبَّعة وتفحّص شكلها.
- استخراج ميزات لكل إطار ، طاقة RMS، ومعدل تقاطع الصفر، ودرجة الصوت عبر الارتباط الذاتي.
- اختصار ميزات مقطع في ملف تعريف متحدث واحد.
- مقارنة متحدثَين بمقياس مسافة لإيجاد التطابق الأقرب.
- تشكيل درجة صوت مقطع مستهدف في نطاق صوت مرجعي وكتابة WAV.
أين تُشغّل هذا
محليًا باستخدام uv هو المسار الأساسي. إنه numpy فقط، لذا يُثبَّت بوضوح في أي مكان، وهو المسار الوحيد الذي تعيش فيه ملفات WAV الخاصة بك (تسجيلاتك، وصوت مرجعي مُصنَّف بوضوح) على قرص تشير إليه. العمل الصوتي الحقيقي عمل محلي.
Google Colab وKaggle Notebooks وBinder تشغّل كل خطوة بشكل متطابق ، numpy مثبّت مسبقًا والحساب بالأعداد العائمة نفسه في كل مكان. التحفظ الصادق: لا يملك دفتر الملاحظات ملفات المتحدث نفسه افتراضيًا، لذا يركّب دفتر الملاحظات المثال نغمات جيبية ومقاطع بأسلوب الصيغ لتوضيح استخراج الميزات (كما يفعل هذا الدليل أدناه)، بدلًا من التظاهر باستنساخ تسجيل حقيقي. استخدم الشارات لرؤية الميزات وملفات التعريف محسوبة من البداية للنهاية؛ وانتقل إلى uv المحلي عندما تريد توجيه الأدوات إلى صوت حقيقي مملوك أخلاقيًا.
الإعداد
أنشئ المشروع وثبّت المكتبة الوحيدة التي تُبنى عليها الأدوات.
uv init voice-cloning-toolkit
cd voice-cloning-toolkit
uv add numpy
uv run python -c "import numpy; print('ok')"
numpy هو محرك الصوت بأكمله: يتحول ملف WAV إلى مصفوفة float أحادية البعد، وكل ميزة في هذا المشروع ، الطاقة، ومعدل التقاطع، ودرجة الصوت ، تعبير numpy على تلك المصفوفة. wave (المستخدمة في الخطوة 1) تأتي مع Python وتتعامل مع حاوية WAV.
✅ قائمة التحقق
- ✅ انتهى
uv add numpyوطبعت جملة الفحصok. - ✅ يوجد مشروع
voice-cloning-toolkit/جديد معpyproject.toml.
الخطوة 1: اقرأ ملف WAV في مصفوفة numpy
تبدأ كل قياس في هذه الأدوات بالطريقة نفسها: يتحول ملف .wav على القرص إلى مصفوفة أحادية البعد من القيم من −1 إلى 1، واحدة لكل عينة. تكتب هذه الخطوة نغمة تجريبية صغيرة، وتقرأها مجددًا، وتتحقق من الحساب الذي يحوّل البايتات إلى صوت.
1.1 اكتب read_wav ونغمة تجريبية
👟 تلميح البداية : استخدم وحدة wave من المكتبة القياسية لفتح الحاوية، واستخرج framerate وعدد القنوات، وفكّ شفرة البايتات الخام بـ np.frombuffer، وطبّع قيم int16 إلى [-1, 1].
# voicekit.py
import wave
import numpy as np
def read_wav(path: str) -> tuple[np.ndarray, int]:
"""Return (float samples in [-1,1], sample_rate)."""
with wave.open(path, "rb") as wav:
sample_rate = wav.getframerate()
n_channels = wav.getnchannels()
frames = wav.readframes(wav.getnframes())
data = np.frombuffer(frames, dtype=np.int16).astype(np.float64)
if n_channels > 1:
data = data[::n_channels]
return data / 32768.0, sample_rate
SR = 22050
seconds = 2
t = np.linspace(0, seconds, SR * seconds, endpoint=False)
tone = 0.3 * np.sin(2 * np.pi * 220 * t)
with wave.open("demo.wav", "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2) # 16-bit = 2 bytes/sample
wav.setframerate(SR)
wav.writeframes((tone * 32767).astype(np.int16).tobytes())
data, sr = read_wav("demo.wav")
print("shape:", data.shape, "sr:", sr, "peak:", round(float(np.abs(data).max()), 3))
خط الأنابيب: يقرأ wave الحاوية (كم طولًا، وكم عرضًا، وكم قناة)؛ يعيد np.frombuffer تفسير سلسلة البايتات الخام كأرقام int16 دون نسخ؛ والقسمة على 32768.0 تعيد قياس نطاق 16-بِت الكامل إلى [-1, 1] بالأعداد العائمة ، اتفاقية الوحدات التي تشترك فيها كل مكتبات الصوت. الصوت الأحادي قفزة فهرس قائمة واحدة (data[::n_channels] على ملف ثنائي القناة يأخذ كل عينة أخرى). الجيب عند 220 Hz بنبرة باريتون متوسطة، وهو ما يجب أن تقيسه الخطوة 2.
🎯 الناتج المتوقع : shape: (44100,) sr: 22050 peak: 0.3 ، عينة واحدة لكل إطار بسعة ذروة 0.3 بالضبط.
🩹 إذا لم يعمل : إذا أبلغَ shape عن (88200,)، فقد أُسقطت setnchannels(1) أو أن read_wav لا تدمج الاستريو. إذا كانت الذروة 0.03، فالسعة 0.3 مقسومة على 100 تعني أن خطوة قياس int16 طبّعت مرتين. إذا قال wave.ERROR إن الملف ليس RIFF wave، فكتبت writeframes أعدادًا عائمة غير مشفّرة ، حوّلها إلى .astype(np.int16) أولًا.
1.2 تحقق من القارئ
✅ قائمة التحقق
- ✅ يوجد
demo.wavوتُعيدread_wav(44100 samples, 22050 Hz). - ✅ القيمة المطلقة للذروة تساوي
0.3التي كتبتها. - ✅ تحرير
SRوإعادة التشغيل يغيّران عدد العينات نسبيًا.
🤔 سؤال (أسئلة) سقراطي(ة)
- يخزّن الملف قيم
int16؛ يحوّلها القارئ إلى أعداد عائمة في[-1, 1]. لماذا يتجنّب مستخرج الميزات عمدًا الأعداد الصحيحة لحساب الطاقة ، ماذا يتحطم إذا حسبت RMS علىint16الخام ثم على الأعداد العائمة؟ - مقطع من ثانيتين عند 22 050 Hz هو 44 100 عينة. إذا كان الصوت فوريًا “صوتًا في لحظة زمنية”، فلماذا تحتاج الأدوات إلى إطارات (الخطوة 2) بدلًا من معاملة المصفوفة كلها كرقم واحد؟
الخطوة 2: استخرج الميزات لكل إطار
الصوت ليس درجة صوت واحدة ، إنه كفاف درجة يتغير 10 مرات في الثانية. تقطّع هذه الخطوة الصوت إلى إطارات صغيرة متداخلة وتقيس كلًا منها: ما مدى ارتفاعه (طاقة RMS)، وما مدى ضوضائه (معدل تقاطع الصفر)، وما الأساس الذي يزنه (درجة الصوت بالارتباط الذاتي).
2.1 أطّر الإشارة واحسب الطاقة والتقاطعات
👟 تلميح البداية : أطّر بنوافذ 20 ms وخطوات 10 ms (إعدادات الكلام القياسية)، ثم اجعل rms وzero_crossings دالتي numpy من سطر واحد.
# voicekit.py (continued)
def frame_signal(data: np.ndarray, frame_s: float = 0.02,
hop_s: float = 0.01, sr: int = SR):
frame_n = int(frame_s * sr)
hop_n = int(hop_s * sr)
frames = [data[i:i + frame_n]
for i in range(0, len(data) - frame_n + 1, hop_n)]
return np.array(frames)
def rms(segment: np.ndarray) -> float:
return float(np.sqrt(np.mean(segment ** 2)))
def zero_crossings(segment: np.ndarray) -> int:
return int(np.mean(np.diff(np.sign(segment)) != 0) * len(segment))
frames = frame_signal(data)
energies = np.array([rms(f) for f in frames])
crossings = np.array([zero_crossings(f) for f in frames])
print("frames:", frames.shape[0], "| mean energy:", round(float(energies.mean()), 4))
print("mean crossings/frame:", round(float(crossings.mean()), 1))
frame_signal هي هندسة تحليل الكلام الكلاسيكية: 20 ms لكل إطار، تنزلق للأمام 10 ms ، فتُقاس كل عينة أكثر من مرة، مما يبقي كفاف درجة الصوت ناعمًا. rms هو تعريف الصوت العالي (mean(segment²) ثم الجذر التربيعي)؛ يعدّ zero_crossings عدد المرات التي تمرّ خلالها الموجة بصفر، وهو وكيل رخيص للسطوع/الضوضاء ، حرف ‘s’ أزيز يعبر باستمرار، بينما ‘o’ دافئ نادرًا. طاقة جيب نقي عند 220 Hz ثابتة، وهو بالضبط ما يُظهره العرض التجريبي.
🎯 الناتج المتوقع : frames: 199 (ثانيتان بخطوات 10 ms)، وطاقة متوسطة قرب 0.21، ومتوسط تقاطعات/إطار قرب 9 (إطار 220 Hz يغطي 4.4 دورة، تقاطعان لكل دورة).
🩹 إذا لم يعمل : إذا كانت frames: 200، فحدّ + 1 في النطاق مفقود فانزلق الإطار الجزئي الأخير. إذا تغيّرت الطاقة بعنف بين الإطارات، فـi:i + frame_n به خطأ في التداخل وتتشارك الإطارات البيانات الخام بشكل غير متساوٍ. إذا قرأت التقاطعات ~440، فأنت تعدّ حافتَي كل دورة ، هذا ضعف المعدل المتوقع ويجب قسمته في zero_crossings.
2.2 قدّر درجة الصوت بالارتباط الذاتي
👟 تلميح البداية : اربط ذاتيًا الإطار المُركَّز، وقيّد البحث في التأخر إلى 80–400 Hz (نطاق الصوت البشري)، وابحث عن التأخر (بالعينات) الذي يبلغ الذروة، وحوّل lag → Hz بـ sr / lag.
# voicekit.py (continued)
def autocorr_pitch(segment: np.ndarray, sr: int = SR) -> float:
seg = segment - segment.mean()
corr = np.correlate(seg, seg, mode="full")[len(seg) - 1:]
min_lag = int(sr / 400) # highest pitch we accept
max_lag = int(sr / 80) # lowest pitch we accept
region = corr[min_lag:max_lag + 1]
if len(region) == 0 or region.max() <= 0:
return 0.0
peak_lag = min_lag + int(np.argmax(region))
return sr / peak_lag
pitches = np.array([autocorr_pitch(f) for f in frames])
voiced = pitches[pitches > 0]
print("median pitch:", round(float(np.median(voiced)), 1), "Hz")
يسأل الارتباط الذاتي سؤالًا بسيطًا: انزاح الإطار مقابل نفسه، وعند أي تأخر يبدو أشبه بجاره؟ لنغمة 220 Hz مأخوذة عند 22 050 Hz، دورة كاملة واحدة ~100 عينة، لذا يبلغ الارتباط الذروة عند تأخر ≈ 100 → sr / lag ≈ 220. تقييد min_lag/max_lag بالنطاق 80–400 Hz هو الجزء الذي يمنع إطارًا متنفسًا أو صامتًا من مطابقة ضوضاء عشوائية عند تأخر سخيف؛ أي شيء خارج نطاق الصوت البشري ليس درجة صوت تستحق الإبلاغ، وreturn 0.0 يميّز إطارًا غير مُصوَّت.
🎯 الناتج المتوقع : median pitch قريب جدًا من 220.0 Hz ، قاست الأدوات النغمة التي سُلّمت إليها.
🩹 إذا لم يعمل : إذا كانت درجة الصوت ~110 Hz، فوجد peak_lag الذروة الثانية (التوافق الأوكتافي الدقيق) لأن المنطقة المختارة واسعة جدًا ، ضيّق max_lag، أو خذ أول حد أقصى محلي، لا الحد العام. إذا كانت درجة الصوت 0.0 في كل مكان، فـregion.max() <= 0 يصفّي كل شيء لأن متوسط المقطع لم يُطرح. إذا كانت الترددات غير مستقرة بين الإطارات، فإطار 20 ms طويل جدًا ليجمع نغمتين مختلفتين ، قصّر frame_s.
2.3 تحقق من مجموعة الميزات
✅ قائمة التحقق
- ✅ إطارات ≈ 199، وطاقة متوسطة قرب 0.21، وتقاطعات قرب 18، ومتوسط درجة ≈ 220 Hz.
- ✅ الإطارات غير المُصوَّتة (الصمت) تُبلَّغ كـ
0.0، لا كدرجة صوت عشوائية. - ✅ يمكنك أن تصف ما يمثّله إطار واحد ولماذا يساعد التداخل.
🤔 سؤال (أسئلة) سقراطي(ة)
- وجد الارتباط الذاتي دورية النغمة تمامًا على جيب نقي. أي صوت من العالم الحقيقي (فكّر: حرف ‘s’ صوتي أو ضحكة) يجعل الارتباط الذاتي يفشل أو ينقص إلى النصف، وكيف سترصد ذلك الفشل في كفاف درجة الصوت نفسه؟
- يرتفع كلٌّ من معدل تقاطع الصفر ودرجة الصوت للأصوات العالية. لماذا تحسب أدوات الكلام كلاهما بدلًا من معاملة معدل التقاطع كبديل لدرجة الصوت؟
الخطوة 3: ابنِ ملف تعريف متحدث
الميزات لكل إطار هي المادة الخام؛ ملف التعريف هو التكثيف الذي يمكن للمقارنة استخدامه ، متوسط درجة الصوت ونطاقها، ومتوسط الطاقة، المُعصَّر من مقطع كامل في قاموس صغير واحد.
3.1 اكتب build_profile
👟 تلميح البداية : اقرأ الملف، وأطّره، وشغّل ميزات الخطوة 2 على كل إطار، ثم اجمع درجات المُصوَّتة والطاقة الكلية في قاموس تلخيصي واحد.
# voicekit.py (continued)
def build_profile(path: str) -> dict:
data, sr = read_wav(path)
frames = frame_signal(data, sr=sr)
pitches = [autocorr_pitch(f, sr) for f in frames]
voiced = [p for p in pitches if p > 0]
energy = float(np.mean([rms(f) for f in frames]))
return {
"mean_pitch": float(np.mean(voiced)) if voiced else 0.0,
"pitch_range": (float(min(voiced)), float(max(voiced))) if voiced else (0.0, 0.0),
"mean_energy": energy,
}
def make_tone(freq: float, seconds: float = 1.0, sr: int = SR) -> str:
t = np.linspace(0, seconds, int(sr * seconds), endpoint=False)
tone = 0.25 * np.sin(2 * np.pi * freq * t)
path = f"tone_{int(freq)}Hz.wav"
with wave.open(path, "wb") as wav:
wav.setnchannels(1); wav.setsampwidth(2)
wav.setframerate(sr)
wav.writeframes((tone * 32767).astype(np.int16).tobytes())
return path
prof_low = build_profile(make_tone(110))
prof_high = build_profile(make_tone(300))
print("low voice:", prof_low)
print("high voice:", prof_high)
ملف التعريف هو بالضبط أربعة أرقام اختيرت لتكون مقروءة: mean_pitch يحدد الصوت في النطاق البشري، وpitch_range يلتقط التعبيرية (رتيب → واسع)، وmean_energy وكيل للجهارة. تصفية voiced مهمة ، كانت الإطارات الصامتة ستجرّ المتوسط نحو 0 وتسمّم المقارنة لو بقيت. إعادة استخدام make_tone تعطي الأدوات أصوات اختبار مضبوطة: نغمتان نقيتان عند 110 Hz و300 Hz يجب أن تختلف ملفات تعريفهما في mean_pitch فقط، لذا يكون استخراج ملف التعريف قابلاً للتحقق قبل أن يعقّد الصوت الحقيقي الأمور.
🎯 الناتج المتوقع : ملفا تعريف تختلف مراكز pitch_range فيهما ، mean_pitch ≈ 110 للنغمة المنخفضة و≈ 300 للنغمة العالية ، مع mean_energy متساوٍ تقريبًا (≈ 0.18).
🩹 إذا لم يعمل : إذا كانت كلتا درجتي الصوت ~0، فـvoiced فارغ لأن autocorr_pitch لم تجتز اختبار region.max() > 0 أبدًا. إذا اختلف mean_energy كثيرًا بين النغمتين، فسعَتا make_tone مختلفتان (سطر tone = 0.25 * … يستخدم ضريبي مضاعفة مختلفين). إذا كان pitch_range رقمًا واحدًا بدلًا من زوج، فالأقواس اللاصقة في القاموس مفقودة.
3.2 تحقق من ملفات التعريف
✅ قائمة التحقق
- ✅ ملف تعريف النغمة المنخفضة يُبلِّغ ~110 Hz والنغمة العالية ~300 Hz.
- ✅ قيمتا
mean_energyمتوافقتان ضمن بضعة بالمئة. - ✅ مقطع صامت بالكامل يُنتج ملف تعريف
mean_pitch: 0.0دون تحطم.
🤔 سؤال (أسئلة) سقراطي(ة)
- ملف التعريف أربعة أرقام، ومع ذلك يميّز البشر مئات الأصوات. ما المعلومات التي تتخلص منها التي كان سيحتفظ بها نموذج تزييف عميق حقيقي، ولماذا التخلص منها ميزة أمان فعلًا لهذه الأدوات؟
- يطوي
mean_energyالجهارة في ملف التعريف، لكن صوتًا مُسجَّلًا من قرب مقابل مسافة يغيّره. كيف تجعل ملف التعريف عادلًا عبر مسافات التسجيل ، وهل يجعل ذلك التغيير المقارنات أكثر صدقًا؟
الخطوة 4: قارن متحدثَين بمقياس مسافة
مع ملفات التعريف كنقاط، يتحول “من أقرب إلى مَن” إلى حساب: مسافة نسبية لكل ميزة، تُجمَع. تسجّل هذه الخطوة مسافة صوت مستهدف من كل ملف تعريف مرشح وتُرتب الأقرب ، نفس الشكل الذي تستخدمه الطبقة الأخيرة لنظام تحديد هوية الصوت.
4.1 اكتب profile_distance ورتّب المرشحين
👟 تلميح البداية : طبّع كل اختلاف ميزة بقيمة المرجع (فيقلّ تأثير 10 Hz عند 300 Hz عن عند 100 Hz)، واجمع الاختلافَين المُطبَّعين، واختر المرشح بأصغر مجموع.
# voicekit.py (continued)
def profile_distance(a: dict, b: dict) -> float:
pitch_diff = abs(a["mean_pitch"] - b["mean_pitch"]) / (b["mean_pitch"] + 1e-9)
energy_diff = abs(a["mean_energy"] - b["mean_energy"]) / (b["mean_energy"] + 1e-9)
return pitch_diff + energy_diff
mid_tone = build_profile(make_tone(200))
candidates = {"low": prof_low, "high": prof_high}
for name, prof in candidates.items():
print(f"{name:>5} distance: {profile_distance(mid_tone, prof):.3f}")
best = min(candidates, key=lambda n: profile_distance(mid_tone, candidates[n]))
print("closest match to 200 Hz:", best)
قسمة كل اختلاف على ميزة المرجع هي الحيلة: |110 − 200| / 110 ≈ 0.82 لكن |300 − 200| / 300 ≈ 0.33، لذا يُحكَم على الفجوة المطلقة نسبيًا إلى درجة الصوت التي تحدث عندها ، انحراف صوت رئيسٍ بقدر 10 Hz يجب أن يضر أقل من انحراف همسٍ بقدر 10 Hz. إضافة 1e-9 مقابل مرجع صفري تمنع الصيغة من القسمة على صمت. جمع الحدّين المُطبَّعين يُنتج “قربًا” بلا وحدات يمكنك مقارنتها عبر المرشحين، وmin(..., key=...) يحوّل لوحة النتائج إلى حُكم.
🎯 الناتج المتوقع : low distance: 0.82، high distance: 0.33، ثم closest match to 200 Hz: high ، نغمة 200 Hz أقرب إلى ملف تعريف 300 Hz.
🩹 إذا لم يعمل : إذا تساوت المسافة إلى ‘low’ و’high’، فمقام أحد سطري diff مُخطّأ الحجم (كلاهما يستخدم a بدلًا من b). إذا كان الحُكم دائمًا ‘low’، فاختار min المسافة القصوى لأن key= يُرجع -distance. إذا طبعت المسافة inf، فملف تعريف بـmean_pitch == 0.0 والحراسة 1e-9 مفقودة.
4.2 تحقق من المقارنات
✅ قائمة التحقق
- ✅ نغمة 200 Hz تُحكَم على أنها الأقرب إلى ملف تعريف 300 Hz تحت هذا المقياس.
- ✅ نغمة 150 Hz تقلب الحُكم نحو low، ويمكنك التنبؤ بمكان الحد.
- ✅ يمكنك تفسير لماذا الطبّعة على المرجع تتفوق على الاختلافات المطلقة الخام.
🤔 سؤال (أسئلة) سقراطي(ة)
- يزن هذا المقياس درجة الصوت والطاقة بالتساوي (1:1). لترتيب “متشابه الصوت” حقيقي ستضيف ميزة ثالثة، أو تزن درجة الصوت أعلى. ماذا يحدث للحُكم إذا بدأ
energy_diffبالهيمنة ، أي نوع من الإجابات الخاطئة يظهر؟ - لا يستخدم المقياس نطاق درجة الصوت أبدًا. صوتان بنفس متوسط درجة الصوت لكن أحدهما رتيب والآخر نابض بالحياة يحصلان على مسافة 0. متى يهمّ النطاق فعلًا للتفريق بين الأصوات، وما الذي ستفاضِله لتضمينه؟
الخطوة 5: شكّل مقطعًا باتجاه نطاق مستهدف
الحركة الأخيرة هي “الاستنساخ” النزيه للأدوات: قِس درجة صوت كل إطار، وإذا وقعت خارج نطاق متحدث مرجعي، أعد أخذ العينات من ذلك الإطار بحيث ينزاح نحو المرجع ، ثم اكتب النتيجة كـ WAV جديد. تنتقل الإحصائيات؛ لا تنتقل الهوية.
5.1 اكتب خطوة ملاءمة درجة الصوت وكاتب WAV
👟 تلميح البداية : أعد أخذ عينات من إطار بعامل f عبر np.interp (التقصير يرفع درجة الصوت، والإطالة تخفضها)، وثبّت كل إطار خارج النطاق نحو نطاق المرجع، وأعد تجميع الإطارات في مصفوفة واحدة.
# voicekit.py (continued)
def pitch_shift(segment: np.ndarray, factor: float) -> np.ndarray:
n = int(len(segment) / factor)
xs = np.linspace(0, len(segment) - 1, n)
return np.interp(xs, np.arange(len(segment)), segment)
def fit_to_range(data: np.ndarray, sr: int, target: dict) -> np.ndarray:
low, high = target["pitch_range"]
frames = frame_signal(data, sr=sr)
shaped = []
for frame in frames:
p = autocorr_pitch(frame, sr)
if 0 < p < low:
shaped.append(pitch_shift(frame, low / p))
elif p > high:
shaped.append(pitch_shift(frame, high / p))
else:
shaped.append(frame)
return np.concatenate(shaped)
def write_wav(path: str, data: np.ndarray, sr: int = SR) -> None:
with wave.open(path, "wb") as wav:
wav.setnchannels(1); wav.setsampwidth(2); wav.setframerate(sr)
clip = np.clip(data, -1, 1)
wav.writeframes((clip * 32767).astype(np.int16).tobytes())
source = read_wav(make_tone(500)) # a 500 Hz tone
shaped_data, sr = source[0], source[1]
shaped = fit_to_range(shaped_data, sr, prof_low) # target range ~110 Hz
write_wav("shaped.wav", shaped)
after = build_profile("shaped.wav")
print("before:", round(build_profile("tone_500Hz.wav")["mean_pitch"], 1), "Hz")
print("after:", round(after["mean_pitch"], 1), "Hz (target ~min/mean of low)")
يعيد pitch_shift أخذ العينات: للعامل factor = 2، يبقي كل عينة ثانية تقريبًا في نصف الطول، ما يرفع درجة الصوت المُدرَكة أوكتافًا ، فيزياء تشغيل تسجيل أسرع هي التحويل نفسها. يطبّقها fit_to_range فقط حيث يحتاج: تحت الحافة السفلية لـpitch_range المرجعي، انقل الإطار لأعلى إلى low؛ وفوق الحافة العليا، لأسفل. يكشف أيضًا المقايضة الصادقة ، إعادة أخذ العينات تغيّر المدة أيضًا، ولهذا يعيد تركيب استنساخ الصوت الحقيقي بجهاز تشفير صوتي (vocoder) بدلًا من إعادة أخذ العينات، ولهذا يكون ناتج هذه الأدوات عرضًا “مُشكَّلًا”، لا نسخة طفيلية أبدًا.
🎯 الناتج المتوقع : “before: 500.0 Hz”، “after:” قيمة قرب نطاق ملف التعريف المنخفض (~110–150)، وshaped.wav قابل للتشغيل على القرص تُسمع درجة صوته تهبط.
🩹 إذا لم يعمل : إذا قرأ “after” ~500 Hz، فـbuild_profile يُشغَّل على tone_500Hz.wav بدلًا من shaped.wav (المتغير after). إذا هبطت درجة الصوت لكن الملف صامت، فقصّ write_wav كل شيء بعد ±1 ، فاضت الإطارات المُعاد أخذ عيناتها قبل np.clip. إذا سمعت تشوهات بدلًا من نغمة، فقد ضمّ np.concatenate إطارات بأطوال غير محاذية ، يجب أن يعيد كل pitch_shift التجميع بوضوح بتصفية الإطارات الكاملة فقط.
5.2 تحقق من البداية للنهاية
✅ قائمة التحقق
- ✅ مصدر 500 Hz يهبط في نطاق درجة صوت ملف التعريف المنخفض.
- ✅ يُنتج
write_wavملفshaped.wavقابلًا للتشغيل يتوافقbuild_profileفيه عند إعادة القراءة. - ✅ الإطارات خارج النطاق تنزاح؛ الإطارات داخل النطاق تمر دون تغيير.
- ✅ تأمّلت في الاستنساخ الذي لم تبنِه: لا نموذج مُتعلَّم، ولا هوية، ولا مشكلة موافقة دون مالك المقطع.
🤔 سؤال (أسئلة) سقراطي(ة)
- تحوّل إعادة أخذ العينات درجة الصوت والسرعة معًا، فالصوت المُشكَّل أقصر. إذا أردت الحفاظ على مدة الوقت الفعلي، ماذا عليك أن تفعل بالإطارات الأخرى (تلميح: إعادة أخذ العينات العكسية)، وما التشوه الذي يظهر عندما يتصادم التعديلان؟
- تنقل الأدوات الإحصائيات ومع ذلك لا تستطيع إعادة إنتاج صوت صراحةً. أين، بالضبط، يقع الخط بين “قياس صوت” و”انتحال صوت” ، وأي من ميزات اليوم يجب أن تحذفها، لا تضيفها، لإبقاء هذه الأداة على الجانب الآمن؟
⚠️ المآزق الشائعة
- قراءة
int16الخام كسعة. نسيان إعادة القياس/ 32768.0يُمرّر أعدادًا صحيحة إلى حساب الميزات؛ فتخرج الطاقة والتقاطعات منتفخة بشدة. الإصلاح: طبّع مرة واحدة فيread_wav، وثق بكل دالة لاحقة. - نسيان تركيز الإطارات قبل الارتباط الذاتي. مقطع ذو إزاحة DC يرتبط بنفسه عند تأخر 0 إلى الأبد، منتجًا ذروات قرب تأخر 0 وهمية. الإصلاح: اطرح
segment.mean()قبلnp.correlate. - أخطاء مضاعفة درجة الصوت/الأوكتاف. قد تستقر أعظمية argmax العالمية لمنطقة الارتباط على التوافق الثاني. الإصلاح: خذ أول ذروة محلية بعد الحد الأدنى للتأخر، أو ضيّق
max_lag. - خلط الميزات الخام مع مرشح المُصوَّت. إطعام إطارات غير مُصوَّتة (صمت → درجة
0.0) فيnp.mean(voiced)دون تصفية يجرّ كل ملف تعريف نحو الصفر. الإصلاح: أسقط دائمًاpitch <= 0كما تفعل الخطوة 3. - الثقة بملف التعريف كهوية. المتوسطات إحصائيات، لا شخص. أي استخدام لهذه الأدوات يساوي “أقرب ملف تعريف” بـ”هذا هو المتحدث” ، للأمن، أو الانتحال، أو الإسناد ، يكرّر بالضبط الخطأ الذي يحذّر منه إخلاء المسؤولية في الأعلى.
ما بنيته للتو
أداة قياس صوت numpy خالصة تطبع WAV، وتستخرج ميزات درجة الصوت/الطاقة/التقاطعات لكل إطار، وتُكثّف المقاطع في ملفات تعريف قابلة للمقارنة، وتُرتّب مدى قرب المتحدث بمقياس مسافة مُطبَّع، وتشكّل درجة صوت مقطع في النطاق الإحصائي لآخر ، مع ذكر حد الاستنساخ بصدق على كل مسار. المهارة القابلة للنقل هي استخراج الميزات على الإشارات الخام: الصوت، وتدفقات المستشعرات، والموجات كلها تكافئ الوصفة نفسها من التأطير والقياس والتكثيف قبل أن تراها أي طبقة “ذكاء”.
شغّل نسخة أكمل دون أي إعداد محلي
examples/voice-cloning-toolkit/ في مستودع الدورة نسخة أكمل من الكود أعلاه، مع نغمات اصطناعية بأسلوب الصيغ ودفتر ملاحظات يرسم كفاف درجة الصوت قبل/بعد. استنسخه، أو افتح المستودع كاملًا في GitHub Codespace، وشغّله من هناك.
إلى أين تذهب من هنا
- أضف ميزة ثالثة ، المركز الطيفي عبر FFT لكل إطار ، وشاهد مقياس المسافة يحتد على صوتَين يتشاركان متوسط درجة الصوت نفسه.
- ارسم كفاف درجة الصوت على مدى المقطع لترى الاهتزاز (vibrato) والتنغيم بدلًا من رقم متوسط واحد.
- نفّذ فحص سطوع بمعدل تقاطع الصفر لرفض الإطارات غير المُصوَّتة غالبًا، ما يجعل تصفية
voicedأذكى من “درجة > 0”. - اكتب
read_wavوbuild_profileوprofile_distanceمقابل نغمات قليلة مُعَلَّمة يدويًا فيtest_voicekit.pyحتى لا تتسلل انحدارات الميزات بصمت.
شارك مشروعك مع الصف
بَنيت شيئًا تفتخر به؟ examples/student-projects/ معرض لمشاريع قدّمها طلاب آخرون ، وملف README الخاص به يحتوي شرحًا كاملًا وودودًا للمبتدئين لإضافة مشروعك عبر pull request، حتى لو لم تستخدم git من قبل: عمل fork للمستودع، وإنشاء فرع، والالتزام بملفاتك، وفتح الـ PR، خطوة بخطوة. لا يُفترَض أي خبرة سابقة بـ git.
مرحبًا بك في كتابة Python خارج المتصفح. 🎓
أكمل كل خطوة ثم حدد المشروع كمكتمل لجمع نقاطه.