Skip to content

Commit

Permalink
Revise AudioCaptcha
Browse files Browse the repository at this point in the history
* Replace magic numbers with constants
* Avoid assignments in conditions
* Prefer double-quotes strings
* Refactor nested conditional to guards
* Split conditional
* Error handling instead of assert()
* Avoid output buffering
* Actually test WAV creation
* Extract ::randomGain()
* Rename ::getPeak() to ::peak()
  • Loading branch information
cmb69 committed Mar 23, 2023
1 parent 703c3db commit 92daa20
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 42 deletions.
76 changes: 54 additions & 22 deletions classes/infra/AudioCaptcha.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,18 @@

class AudioCaptcha
{
const NOISE_PEAK = 4000;
private const NOISE_PEAK = 4000;
private const RIFF_TAG = "RIFF";
private const RIFF_TYPE = "WAVE";
private const FMT_TAG = "fmt ";
private const FMT_CHUNK_SIZE = 16;
private const PCM_FORMAT = 1;
private const CHANNELS = 1;
private const SAMPLES_PER_SEC = 8000;
private const BYTES_PER_SAMPLE = 2;
private const BITS_PER_SAMPLE = self::BYTES_PER_SAMPLE * 8;
private const BYTES_PER_SEC = self::SAMPLES_PER_SEC * self::BYTES_PER_SAMPLE;
private const DATA_TAG = "data";

/** @var string */
private $audioFolder;
Expand All @@ -34,10 +45,10 @@ public function __construct(string $audioFolder)
$this->audioFolder = $audioFolder;
}

/** @return string|null */
public function createWav(string $lang, string $code)
public function createWav(string $lang, string $code): ?string
{
if (!($samples = $this->concatenateRawAudio($lang, $code))) {
$samples = $this->concatenateRawAudio($lang, $code);
if ($samples === null) {
return null;
}
$dataChunk = $this->createDataChunk($this->applyWhiteNoise($samples));
Expand All @@ -46,17 +57,27 @@ public function createWav(string $lang, string $code)

private function createRiffChunk(string $dataChunk): string
{
return pack('A4Va4', 'RIFF', 4 + 24 + strlen($dataChunk), 'WAVE');
return pack("A4Va4", self::RIFF_TAG, 4 + 24 + strlen($dataChunk), self::RIFF_TYPE);
}

private function createFmtChunk(): string
{
return pack('A4VvvVVvv', 'fmt', 16, 1, 1, 8000, 16000, 2, 16);
return pack(
"A4VvvVVvv",
self::FMT_TAG,
self::FMT_CHUNK_SIZE,
self::PCM_FORMAT,
self::CHANNELS,
self::SAMPLES_PER_SEC,
self::BYTES_PER_SEC,
self::BYTES_PER_SAMPLE,
self::BITS_PER_SAMPLE
);
}

private function createDataChunk(string $data): string
{
return pack('A4V', 'data', strlen($data)) . $data;
return pack("A4V", self::DATA_TAG, strlen($data)) . $data;
}

/**
Expand All @@ -65,41 +86,46 @@ private function createDataChunk(string $data): string
*
* @return array<int>|null
*/
private function concatenateRawAudio(string $lang, string $code)
private function concatenateRawAudio(string $lang, string $code): ?array
{
if (!is_dir($this->audioFolder . $lang)) {
$lang = "en";
}
$data = '';
$data = "";
for ($i = 0; $i < strlen($code); $i++) {
$filename = $this->audioFolder . "$lang/" . strtolower($code[$i]) . '.raw';
if (is_readable($filename) && ($contents = file_get_contents($filename))) {
$data .= $contents;
} else {
$filename = $this->audioFolder . $lang . "/" . strtolower($code[$i]) . ".raw";
if (!is_readable($filename)) {
return null;
}
$contents = file_get_contents($filename);
if ($contents === false) {
return null;
}
$data .= $contents;
}
$binary = unpack("v*", $data);
if ($binary === false) {
return null;
}
$binary = unpack('v*', $data);
assert($binary !== false);
return $binary;
}

/** @param array<int> $samples */
private function applyWhiteNoise(array $samples): string
{
$gain = (65535 - self::NOISE_PEAK) / $this->getPeak($samples);
ob_start();
$gain = ((1 << self::BITS_PER_SAMPLE) - 1 - self::NOISE_PEAK) / $this->peak($samples);
// loop instead of array_map() for performance reasons
$new = [];
foreach ($samples as $sample) {
echo pack('v', (int) ($gain * $sample) + mt_rand(0, self::NOISE_PEAK) - 32768);
$new[] = pack("v", (int) ($gain * $sample) + $this->randomGain() - (1 << self::BITS_PER_SAMPLE) / 2);
}
$string = ob_get_clean();
assert($string !== false);
return $string;
return implode("", $new);
}

/** @param array<int> $samples */
private function getPeak(array $samples): int
private function peak(array $samples): int
{
// loop instead of array_reduce() for performance reasons
$peak = 0;
foreach ($samples as $sample) {
if ($sample > $peak) {
Expand All @@ -108,4 +134,10 @@ private function getPeak(array $samples): int
}
return $peak;
}

/** @codeCoverageIgnore */
protected function randomGain(): int
{
return mt_rand(0, self::NOISE_PEAK);
}
}
Binary file added tests/audios/gevo.wav
Binary file not shown.
34 changes: 14 additions & 20 deletions tests/infra/AudioCaptchaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,39 +21,33 @@

namespace Cryptographp\Infra;

use org\bovigo\vfs\vfsStream;
use PHPUnit\Framework\TestCase;

class AudioCaptchaTest extends TestCase
{
/**
* @var AudioCaptcha
*/
private $subject;

public function setUp(): void
public function testCreatesWav()
{
$this->setUpFilesystem();
$this->subject = new AudioCaptcha(vfsStream::url('test/cryptographp/languages/'));
$sut = $this->sut();
$wav = $sut->createWav("en", "gevo");
$this->assertStringEqualsFile(__DIR__ . "/../audios/gevo.wav", $wav);
}

private function setUpFilesystem()
public function testCreatesEnglishWavIfTranslationIsMissing()
{
vfsStream::setup('test');
$folder = vfsStream::url('test/cryptographp/languages/en/');
mkdir($folder, 0777, true);
file_put_contents("{$folder}a.raw", 'foo');
file_put_contents("{$folder}b.raw", 'bar');
file_put_contents("{$folder}c.raw", 'baz');
$sut = $this->sut();
$wav = $sut->createWav("de", "gevo");
$this->assertStringEqualsFile(__DIR__ . "/../audios/gevo.wav", $wav);
}

public function testCreateWav()
public function testFailsToCreateWav()
{
$this->assertStringStartsWith('RIFF', $this->subject->createWav("en", 'abc'));
$sut = $this->sut();
$wav = $sut->createWav("en", "!");
$this->assertNull($wav);
}

public function testCreateWavFails()
private function sut()
{
$this->assertNull($this->subject->createWav("en", 'xyz'));
return new FakeAudioCaptcha("./languages/");
}
}
30 changes: 30 additions & 0 deletions tests/infra/FakeAudioCaptcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/**
* Copyright 2023 Christoph M. Becker
*
* This file is part of Cryptographp_XH.
*
* Cryptographp_XH is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Cryptographp_XH is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Cryptographp_XH. If not, see <http://www.gnu.org/licenses/>.
*/

namespace Cryptographp\Infra;

class FakeAudioCaptcha extends AudioCaptcha
{
protected function randomGain(): int
{
return 0;
}
}

0 comments on commit 92daa20

Please sign in to comment.