# 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(' 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)