280 lines
7.9 KiB
Python
280 lines
7.9 KiB
Python
# coding: utf-8
|
|
"""
|
|
captcha.audio
|
|
~~~~~~~~~~~~~
|
|
|
|
Generate Audio CAPTCHAs, with built-in digits CAPTCHA.
|
|
|
|
This module is totally inspired by https://github.com/dchest/captcha
|
|
"""
|
|
|
|
import typing as t
|
|
import os
|
|
import copy
|
|
import wave
|
|
import struct
|
|
import secrets
|
|
import operator
|
|
from functools import reduce
|
|
|
|
|
|
__all__ = ['AudioCaptcha']
|
|
|
|
WAVE_SAMPLE_RATE = 8000 # HZ
|
|
WAVE_HEADER = bytearray(
|
|
b'RIFF\x00\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00'
|
|
b'@\x1f\x00\x00@\x1f\x00\x00\x01\x00\x08\x00data'
|
|
)
|
|
WAVE_HEADER_LENGTH = len(WAVE_HEADER) - 4
|
|
DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data')
|
|
|
|
|
|
def _read_wave_file(filepath: str) -> bytearray:
|
|
w = wave.open(filepath)
|
|
data = w.readframes(-1)
|
|
w.close()
|
|
return bytearray(data)
|
|
|
|
|
|
def change_speed(body: bytearray, speed: float = 1) -> bytearray:
|
|
"""Change the voice speed of the wave body."""
|
|
if speed == 1:
|
|
return body
|
|
|
|
length = int(len(body) * speed)
|
|
rv = bytearray(length)
|
|
|
|
step: float = 0
|
|
for v in body:
|
|
i = int(step)
|
|
while i < int(step + speed) and i < length:
|
|
rv[i] = v
|
|
i += 1
|
|
step += speed
|
|
return rv
|
|
|
|
|
|
def patch_wave_header(body: bytearray) -> bytearray:
|
|
"""Patch header to the given wave body.
|
|
|
|
:param body: the wave content body, it should be bytearray.
|
|
"""
|
|
length = len(body)
|
|
|
|
padded = length + length % 2
|
|
total = WAVE_HEADER_LENGTH + padded
|
|
|
|
header = copy.copy(WAVE_HEADER)
|
|
# fill the total length position
|
|
header[4:8] = bytearray(struct.pack('<I', total))
|
|
header += bytearray(struct.pack('<I', length))
|
|
|
|
data = header + body
|
|
|
|
# the total length is even
|
|
if length != padded:
|
|
data = data + bytearray([0])
|
|
|
|
return data
|
|
|
|
|
|
def create_noise(length: int, level: int = 4) -> bytearray:
|
|
"""Create white noise for background"""
|
|
noise = bytearray(length)
|
|
adjust = 128 - int(level / 2)
|
|
i = 0
|
|
while i < length:
|
|
v = secrets.randbelow(257)
|
|
noise[i] = v % level + adjust
|
|
i += 1
|
|
return noise
|
|
|
|
|
|
def create_silence(length: int) -> bytearray:
|
|
"""Create a piece of silence."""
|
|
data = bytearray(length)
|
|
i = 0
|
|
while i < length:
|
|
data[i] = 128
|
|
i += 1
|
|
return data
|
|
|
|
|
|
def change_sound(body: bytearray, level: float = 1) -> bytearray:
|
|
if level == 1:
|
|
return body
|
|
|
|
body = copy.copy(body)
|
|
for i, v in enumerate(body):
|
|
if v > 128:
|
|
v = int((v - 128) * level + 128)
|
|
v = max(v, 128)
|
|
v = min(v, 255)
|
|
elif v < 128:
|
|
v = int(128 - (128 - v) * level)
|
|
v = min(v, 128)
|
|
v = max(v, 0)
|
|
body[i] = v
|
|
return body
|
|
|
|
|
|
def mix_wave(src: bytearray, dst: bytearray) -> bytearray:
|
|
"""Mix two wave body into one."""
|
|
if len(src) > len(dst):
|
|
# output should be longer
|
|
dst, src = src, dst
|
|
|
|
for i, sv in enumerate(src):
|
|
dv = dst[i]
|
|
if sv < 128 and dv < 128:
|
|
dst[i] = int(sv * dv / 128)
|
|
else:
|
|
dst[i] = int(2 * (sv + dv) - sv * dv / 128 - 256)
|
|
return dst
|
|
|
|
|
|
BEEP = _read_wave_file(os.path.join(DATA_DIR, 'beep.wav'))
|
|
END_BEEP = change_speed(BEEP, 1.4)
|
|
SILENCE = create_silence(int(WAVE_SAMPLE_RATE / 5))
|
|
|
|
|
|
class AudioCaptcha:
|
|
"""Create an audio CAPTCHA.
|
|
|
|
Create an instance of AudioCaptcha is pretty simple::
|
|
|
|
captcha = AudioCaptcha()
|
|
captcha.write('1234', 'out.wav')
|
|
|
|
This module has a built-in digits CAPTCHA, but it is suggested that you
|
|
create your own voice data library. A voice data library is a directory
|
|
that contains lots of single charater named directories, for example::
|
|
|
|
voices/
|
|
0/
|
|
1/
|
|
2/
|
|
|
|
The single charater named directories contain the wave files which pronunce
|
|
the directory name. A charater directory can has many wave files, this
|
|
AudioCaptcha will randomly choose one of them.
|
|
|
|
You should always use your own voice library::
|
|
|
|
captcha = AudioCaptcha(voicedir='/path/to/voices')
|
|
"""
|
|
def __init__(self, voicedir: t.Optional[str] = None):
|
|
if voicedir is None:
|
|
voicedir = DATA_DIR
|
|
|
|
self._voicedir = voicedir
|
|
self._cache: t.Dict[str, t.List[bytearray]] = {}
|
|
self._choices: t.List[str] = []
|
|
|
|
@property
|
|
def choices(self) -> t.List[str]:
|
|
"""Available choices for characters to be generated."""
|
|
if self._choices:
|
|
return self._choices
|
|
for n in os.listdir(self._voicedir):
|
|
if len(n) == 1 and os.path.isdir(os.path.join(self._voicedir, n)):
|
|
self._choices.append(n)
|
|
return self._choices
|
|
|
|
def random(self, length: int = 6) -> t.List[str]:
|
|
"""Generate a random string with the given length.
|
|
|
|
:param length: the return string length.
|
|
"""
|
|
return [secrets.choice(self.choices) for _ in range(length)]
|
|
|
|
def load(self) -> None:
|
|
"""Load voice data into memory."""
|
|
for name in self.choices:
|
|
self._load_data(name)
|
|
|
|
def _load_data(self, name: str) -> None:
|
|
dirname = os.path.join(self._voicedir, name)
|
|
data: t.List[bytearray] = []
|
|
for f in os.listdir(dirname):
|
|
filepath = os.path.join(dirname, f)
|
|
if f.endswith('.wav') and os.path.isfile(filepath):
|
|
data.append(_read_wave_file(filepath))
|
|
self._cache[name] = data
|
|
|
|
def _twist_pick(self, key: str) -> bytearray:
|
|
voice = secrets.choice(self._cache[key])
|
|
|
|
# random change speed
|
|
speed = (secrets.randbelow(31) + 90) / 100.0
|
|
voice = change_speed(voice, speed)
|
|
|
|
# random change sound
|
|
level = (secrets.randbelow(41) + 80) / 100.0
|
|
voice = change_sound(voice, level)
|
|
return voice
|
|
|
|
def _noise_pick(self) -> bytearray:
|
|
key = secrets.choice(self.choices)
|
|
voice = secrets.choice(self._cache[key])
|
|
voice = copy.copy(voice)
|
|
voice.reverse()
|
|
|
|
speed = (secrets.randbelow(9) + 8) / 10.0
|
|
voice = change_speed(voice, speed)
|
|
|
|
level = (secrets.randbelow(5) + 2) / 10.0
|
|
voice = change_sound(voice, level)
|
|
return voice
|
|
|
|
def create_background_noise(self, length: int, chars: str) -> bytearray:
|
|
noise = create_noise(length, 4)
|
|
pos = 0
|
|
while pos < length:
|
|
sound = self._noise_pick()
|
|
end = pos + len(sound) + 1
|
|
noise[pos:end] = mix_wave(sound, noise[pos:end])
|
|
pos = end + secrets.randbelow(int(WAVE_SAMPLE_RATE / 10) + 1)
|
|
return noise
|
|
|
|
def create_wave_body(self, chars: str) -> bytearray:
|
|
voices: t.List[bytearray] = []
|
|
inters: t.List[int] = []
|
|
for c in chars:
|
|
voices.append(self._twist_pick(c))
|
|
i = secrets.randbelow(WAVE_SAMPLE_RATE * 3 - WAVE_SAMPLE_RATE + 1) + WAVE_SAMPLE_RATE
|
|
inters.append(i)
|
|
|
|
durations = map(lambda a: len(a), voices)
|
|
length = max(durations) * len(chars) + reduce(operator.add, inters)
|
|
bg = self.create_background_noise(length, chars)
|
|
|
|
# begin
|
|
pos: int = inters[0]
|
|
for i, v in enumerate(voices):
|
|
end = pos + len(v) + 1
|
|
bg[pos:end] = mix_wave(v, bg[pos:end])
|
|
pos = end + inters[i]
|
|
|
|
return BEEP + SILENCE + BEEP + SILENCE + BEEP + bg + END_BEEP
|
|
|
|
def generate(self, chars: str) -> bytearray:
|
|
"""Generate audio CAPTCHA data. The return data is a bytearray.
|
|
|
|
:param chars: text to be generated.
|
|
"""
|
|
if not self._cache:
|
|
self.load()
|
|
body = self.create_wave_body(chars)
|
|
return patch_wave_header(body)
|
|
|
|
def write(self, chars: str, output: str) -> None:
|
|
"""Generate and write audio CAPTCHA data to the output.
|
|
|
|
:param chars: text to be generated.
|
|
:param output: output destionation.
|
|
"""
|
|
data = self.generate(chars)
|
|
with open(output, 'wb') as f:
|
|
f.write(data)
|