1 /** 2 Copyright: Auburn Sounds 2015-2018. 3 License: All Rights Reserved. 4 */ 5 module main; 6 7 import std.stdio; 8 import std.math; 9 import std.path; 10 import std.random; 11 import std.file; 12 import std.algorithm; 13 import std.conv; 14 import std.string; 15 import std.process; 16 17 import consolecolors; 18 19 import bindbc.sdl; 20 import bindbc.sdl.mixer; 21 22 23 24 void usage() 25 { 26 void flag(string arg, string desc, string possibleValues, string defaultDesc) 27 { 28 string argStr = format(" %s", arg); 29 cwrite(argStr.lcyan); 30 for(size_t i = argStr.length; i < 24; ++i) 31 write(" "); 32 cwritefln("%s".white, desc); 33 if (possibleValues) 34 cwritefln(" Possible values: ".grey ~ "%s".yellow, possibleValues); 35 if (defaultDesc) 36 cwritefln(" Default: ".grey ~ "%s".lcyan, defaultDesc); 37 cwriteln; 38 } 39 cwriteln(); 40 cwriteln( "This is ".white ~ "abtest".lcyan ~ ": A/B sound testing tool.".white); 41 cwriteln(); 42 cwriteln("USAGE".white); 43 cwriteln(" abtest A.wav B.wav".lcyan); 44 45 cwriteln(); 46 cwriteln(); 47 cwriteln("FLAGS".white); 48 cwriteln(); 49 flag("-t --topic", "Select questions to ask.", "diffuse | focused | all | none", "all"); 50 flag("-a --amplify", "Scale differences between A and B.", null, "1.0"); 51 flag("-n ", "Number of decisions/questions.", "5 to 12", "6"); 52 flag("-d --driver", "Force a particular audio driver through SDL_AUDIODRIVER", null, null); 53 flag("-h --help", "Shows this help", null, null); 54 55 cwriteln(); 56 cwriteln("EXAMPLES".white); 57 cwriteln(); 58 cwriteln(" # Normal comparison".lgreen); 59 cwriteln(" abtest baseline.wav candidate.wav".lcyan); 60 cwriteln(); 61 cwriteln(" # Comparison with only focused questionning and amplification of difference x2".lgreen); 62 cwriteln(" # (this might not make any sense depending on the context)".lgreen); 63 cwriteln(" abtest baseline.wav candidate.wav --amplify 2 --topic focused".lcyan); 64 cwriteln(); 65 cwriteln("NOTES".white); 66 cwriteln(); 67 cwriteln(" \"Diffuse\" questions are about snap decisions using 'System 1' intuitive judgement."); 68 cwriteln(" \"Focused\" questions are about conscious rational analysis, using 'System 2' reasoning."); 69 cwriteln(" See the book \"Thinking, Fast and Slow\" by Kahneman."); 70 cwriteln(" Our opinion: this maps to two ways to hear sound."); 71 cwriteln(); 72 } 73 74 int main(string[] args) 75 { 76 try 77 { 78 int numQuestions = 6; 79 int topicMask = DIFFUSE | FOCUSED; 80 string inputFileA = null; 81 string inputFileB = null; 82 float amplifyDiff = 1.0f; 83 bool help = false; 84 string driver = null; 85 86 for (int i = 1; i < args.length; ++i) 87 { 88 string arg = args[i]; 89 if (arg == "-t" || arg == "-topic") 90 { 91 i++; 92 if (args[i] == "diffuse") 93 topicMask = DIFFUSE; 94 else if (args[i] == "focused") 95 topicMask = FOCUSED; 96 else if (args[i] == "all") 97 topicMask = DIFFUSE | FOCUSED; 98 else if (args[i] == "none") 99 topicMask = 0; 100 else 101 throw new Exception("Bad topic, expected --topic {diffuse|focused|all|none}"); 102 } 103 else if (arg == "-a" || arg == "-topic") 104 { 105 i++; 106 amplifyDiff =to!float(args[i]); 107 } 108 else if (arg == "-d" || arg == "--driver") 109 { 110 i++; 111 driver = args[i]; 112 } 113 else if (arg == "-n") 114 { 115 i++; 116 numQuestions = to!int(args[i]); 117 } 118 else if (arg == "-h" || arg == "--help") 119 { 120 help = true; 121 } 122 else if (inputFileA is null) 123 { 124 inputFileA = arg; 125 } 126 else if (inputFileB is null) 127 { 128 inputFileB = arg; 129 } 130 else 131 { 132 error("Too many command-line arguments."); 133 usage(); 134 return 1; 135 } 136 } 137 138 cwriteln("First of all, make sure you are in a comfortable position, try to use your most"); 139 cwriteln("accurate headphones, and breathe."); 140 cwriteln(); 141 142 if (help) 143 { 144 usage(); 145 return 0; 146 } 147 148 if (numQuestions < 5) 149 throw new Exception("Number of questions too low. See --help for documentation."); 150 151 if (inputFileA is null || inputFileB is null) 152 { 153 error("Missing files."); 154 usage(); 155 return 1; 156 } 157 158 string[] questions = getQuestionsFromMask(topicMask, numQuestions); 159 160 if (driver) 161 environment["SDL_AUDIODRIVER"] = driver; 162 163 // Transcode inputs to 32-bit WAV with audio-format. 164 165 166 // Translate input to 32-bit float WAV file so that SDL_mixer can always read them 167 string inputFileA_tr = buildPath(tempDir, "abtest-input-1.wav"); 168 string inputFileB_tr = buildPath(tempDir, "abtest-input-2.wav"); 169 transcode(inputFileA, inputFileA_tr, inputFileB, inputFileB_tr, amplifyDiff); 170 171 // Load SDL 172 SDLSupport ret = loadSDL(); 173 if(ret != sdlSupport) 174 { 175 if(ret == SDLSupport.noLibrary) 176 throw new Exception("SDL shared library failed to load"); 177 else if(SDLSupport.badLibrary) 178 throw new Exception("One or more symbols failed to load."); 179 else 180 throw new Exception("Cannot load SDL, unknown error"); 181 } 182 183 if(loadSDLMixer() != sdlMixerSupport) 184 { 185 throw new Exception("SDL_mixer shared library failed to load"); 186 } 187 188 if (driver) 189 { 190 cwriteln("*** Available audio drivers"); 191 for (int i = 0; i < SDL_GetNumAudioDrivers(); ++i) 192 { 193 string driverName = fromStringz(SDL_GetAudioDriver(i)).idup; 194 bool selected = (driver == driverName); 195 cwritefln("- Audio driver %s: %s", i, selected ? escapeCCL(driverName).yellow : escapeCCL(driverName).lcyan); 196 } 197 cwriteln; 198 } 199 200 if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) != 0) throw new Exception("SDL_Init failed"); 201 202 { 203 int flags = Mix_Init(0); // whatever 204 if ((flags & 0) != 0) 205 { 206 throw new Exception("Mix_Init failed"); 207 } 208 } 209 210 if (Mix_OpenAudio(44100, AUDIO_F32SYS, MIX_DEFAULT_CHANNELS, 1024) ) 211 throw new Exception("Mix_OpenAudio failed"); 212 213 // Load sounds 214 Mix_Chunk* soundA = Mix_LoadWAV(toStringz(inputFileA_tr)); 215 cwriteln; 216 Mix_Chunk* soundB = Mix_LoadWAV(toStringz(inputFileB_tr)); 217 cwriteln; 218 219 // winner for each question 220 // 0 => A 221 // 1 => B 222 // 0.5 => draw 223 // NaN => skipped 224 float[] choiceQuestion; 225 for (int question = 0; question < numQuestions; ++question) 226 { 227 // New question 228 cwritefln("*** Question #%d".white, question + 1); 229 230 // 1. Random exchange 231 Mix_Chunk* A = soundA; 232 Mix_Chunk* B = soundB; 233 bool randomlyExchanged = uniform(0, 100) >= 50; 234 if (randomlyExchanged) 235 { 236 swap(A, B); 237 } 238 239 int currentSelected = 0; // A 240 241 Mix_Volume(0, 255); 242 Mix_Volume(1, 0); 243 244 // 2. Play both at once, looping 245 // Listen only to A at first 246 Mix_FadeInChannel(0, A, 10000, 0); 247 Mix_FadeInChannel(1, B, 10000, 0); 248 // Mix_Volume(0, 255); 249 // Mix_Volume(1, 0); 250 251 bool choosen = false; 252 bool badChoice = false; 253 while(!choosen) 254 { 255 if (!badChoice) 256 { 257 cwriteln; 258 cwritefln(" " ~ questions[question].white); 259 cwritefln(" * Type " ~ "'a'".yellow ~" to listen to A"); 260 cwritefln(" * Type " ~ "'b'".yellow ~" to listen to B"); 261 cwritefln(" * Type " ~ "' '".yellow ~" to choose current (%s) and move to next question", currentSelected == 0 ? "A" : "B" ); 262 cwritefln(" * Type " ~ "'='".yellow ~" to declare a draw and move to next question", ); 263 cwritefln(" * Type " ~ "'s'".yellow ~" to skip this question", ); 264 } 265 badChoice = false; 266 267 cwriteln; 268 cwritef(" Choice? ".yellow); 269 270 was_return: 271 char choice = readCharFromStdin(); 272 273 switch (choice) 274 { 275 case 'a': 276 case 'A': 277 Mix_Volume(0, 255); 278 Mix_Volume(1, 0); 279 currentSelected = 0; 280 break; 281 case 'b': 282 case 'B': 283 Mix_Volume(0, 0); 284 Mix_Volume(1, 255); 285 currentSelected = 1; 286 break; 287 case 's': 288 choosen = true; 289 choiceQuestion ~= float.nan; 290 break; 291 case '=': 292 choosen = true; 293 choiceQuestion ~= 0.5f; 294 break; 295 case ' ': 296 297 if (randomlyExchanged) 298 currentSelected = 1 - currentSelected; 299 choiceQuestion ~= currentSelected; 300 choosen = true; 301 break; 302 case '\n': 303 case '\r': 304 case '\t': 305 goto was_return; 306 default: 307 error("Bad choice. Choose 'a', 'b', ' ', or '=' instead."); 308 badChoice = true; 309 } 310 311 } 312 cwriteln; 313 } 314 315 // Computer score 316 float scoreA = 0, scoreB = 0; 317 for (int question = 0; question < numQuestions; ++question) 318 { 319 if (!isNaN(choiceQuestion[question])) 320 { 321 if (choiceQuestion[question] == 0) scoreA += 1; 322 if (choiceQuestion[question] == 1.0f) scoreB += 1; 323 if (choiceQuestion[question] == 0.5f) { scoreA += 0.5f; scoreB += 0.5f; } 324 } 325 } 326 327 // Display scores 328 cwritefln("*** TOTAL RESULTS".white); 329 cwritefln(" => %s got %s votes", inputFileA.lcyan, to!string(scoreA).yellow); 330 cwritefln(" => %s got %s votes", inputFileB.lcyan, to!string(scoreB).yellow); 331 cwriteln; 332 cwritefln("*** DETAILS".white); 333 334 for (int question = 0; question < numQuestions; ++question) 335 { 336 string result; 337 if (choiceQuestion[question] == 0) result = inputFileA.lcyan; 338 if (choiceQuestion[question] == 1.0f) result = inputFileB.lcyan; 339 if (choiceQuestion[question] == 0.5f) result = "draw".yellow; 340 if (isNaN(choiceQuestion[question])) result = "skipped".lred; 341 cwritefln(" %60s => %s", questions[question], result); 342 } 343 cwriteln; 344 345 Mix_CloseAudio(); 346 Mix_Quit(); 347 SDL_Quit(); 348 349 return 0; 350 } 351 catch(Exception e) 352 { 353 error(e.msg); 354 return 1; 355 } 356 } 357 358 // Always return 359 string[] getQuestionsFromMask(int mask, int numberOfQuestions) 360 { 361 // First, all questions diffused, then all questions 362 363 // sorted from better questions to worse 364 string[] DIFFUSE_QUESTIONS = 365 [ 366 "What would you rather hear in " ~ "YOUR MUSIC".yellow ~ "?", 367 "Which sound feels more " ~ "TRUE".yellow ~ "?", 368 "What would you rather hear in your " ~ "CAR".yellow ~ "?", 369 "What would you rather hear on the " ~ "RADIO".yellow ~ "?", 370 "Which sound is the " ~ "WINNER".yellow ~ "?", 371 "Which sound feels more " ~ "FREE".yellow ~ "?" 372 ]; 373 374 // sorted from better questions to worse 375 string[] FOCUSED_QUESTIONS = 376 [ 377 "Which sound has the best "~"LOWS".yellow ~ "?", 378 "Which sound has the best "~"HIGHS".yellow ~ "?", 379 "Which sound is the "~"CLEANEST".yellow ~ "?", 380 "Which sound has better "~"DYNAMICS".yellow ~ "?", 381 "Which sound has better "~"PHASE".yellow ~ "?", 382 "Which sound has the best "~"MIDS".yellow ~ "?", 383 ]; 384 385 string GENERIC_QUESTION = "Which sound do you choose?"; 386 387 string[] result; 388 int remain = numberOfQuestions; 389 390 if (mask & DIFFUSE) 391 { 392 int numQuestionDiffuse = numberOfQuestions; 393 if (mask & FOCUSED) 394 { 395 numQuestionDiffuse = (numberOfQuestions + 1) / 2; 396 } 397 if (numQuestionDiffuse > 6) 398 numQuestionDiffuse = 6; 399 if (numQuestionDiffuse > remain) 400 numQuestionDiffuse = remain; 401 402 foreach(n; 0..numQuestionDiffuse) 403 result ~= DIFFUSE_QUESTIONS[n]; 404 405 remain -= numQuestionDiffuse; 406 } 407 if (mask & FOCUSED) 408 { 409 int numQuestionFocused = remain; 410 if (numQuestionFocused > 6) 411 numQuestionFocused = 6; 412 413 foreach(n; 0..numQuestionFocused) 414 result ~= FOCUSED_QUESTIONS[n]; 415 416 remain -= numQuestionFocused; 417 } 418 419 // Add empty question 420 foreach(n; 0..remain) 421 result ~= GENERIC_QUESTION; 422 return result; 423 } 424 425 426 427 // Questions: 428 enum DIFFUSE = 1; 429 enum FOCUSED = 2; 430 431 void info(string msg) 432 { 433 cwritefln("info: %s".white, escapeCCL(msg)); 434 } 435 436 void warning(string msg) 437 { 438 cwritefln("warning: %s".yellow, escapeCCL(msg)); 439 } 440 441 void error(string msg) 442 { 443 cwritefln("error: %s".lred, escapeCCL(msg)); 444 } 445 446 // Transcode inputs to WAV 447 // check similarity 448 // apply diff factor 449 void transcode(string inputFileA, string outputFileA, 450 string inputFileB, string outputFileB, double diffFactor) 451 { 452 import audioformats; 453 454 AudioStream inputA, outputA, inputB, outputB; 455 inputA.openFromFile(inputFileA); 456 inputB.openFromFile(inputFileB); 457 if (inputA.isError) 458 throw new Exception(inputA.errorMessage); 459 if (inputB.isError) 460 throw new Exception(inputB.errorMessage); 461 462 float sampleRateA = inputA.getSamplerate(); 463 int channelsA = inputA.getNumChannels(); 464 long lengthFramesA = inputA.getLengthInFrames(); 465 466 float sampleRateB = inputB.getSamplerate(); 467 int channelsB = inputB.getNumChannels(); 468 long lengthFramesB = inputB.getLengthInFrames(); 469 470 if (channelsA != channelsB) throw new Exception("Cannot compare files with different number of channels."); 471 if (lengthFramesA != lengthFramesB) throw new Exception("Cannot compare files with different length."); 472 if (sampleRateA != sampleRateB) throw new Exception("Cannot compare files with different sample rate."); 473 474 float[] bufA = new float[1024 * channelsA]; 475 float[] bufB = new float[1024 * channelsB]; 476 477 outputA.openToFile(outputFileA, AudioFileFormat.wav, sampleRateA, channelsA); 478 outputB.openToFile(outputFileB, AudioFileFormat.wav, sampleRateB, channelsB); 479 if (outputA.isError) 480 throw new Exception(outputA.errorMessage); 481 if (outputB.isError) 482 throw new Exception(outputB.errorMessage); 483 484 // Chunked encode/decode 485 int totalFramesA = 0; 486 int framesReadA; 487 int totalFramesB = 0; 488 int framesReadB; 489 do 490 { 491 framesReadA = inputA.readSamplesFloat(bufA); 492 framesReadB = inputB.readSamplesFloat(bufB); 493 if (inputA.isError) 494 throw new Exception(inputA.errorMessage); 495 if (inputB.isError) 496 throw new Exception(inputB.errorMessage); 497 498 if (framesReadA != framesReadB) 499 throw new Exception("Read different frame count between files."); 500 501 if (diffFactor != 1.0f) 502 { 503 foreach(n; 0..framesReadA) 504 { 505 double diff = cast(double)(bufB[n]) - cast(double)(bufA[n]) * 0.5 * (diffFactor - 1.0); 506 bufA[n] -= diff; 507 bufB[n] += diff; 508 } 509 } 510 511 outputA.writeSamplesFloat(bufA[0..framesReadA*channelsA]); 512 outputB.writeSamplesFloat(bufB[0..framesReadB*channelsB]); 513 if (outputA.isError) 514 throw new Exception(outputA.errorMessage); 515 if (outputB.isError) 516 throw new Exception(outputB.errorMessage); 517 518 totalFramesA += framesReadA; 519 totalFramesB += framesReadB; 520 } while(framesReadA > 0); 521 } 522 523 char readCharFromStdin() 524 { 525 import core.stdc.stdio; 526 return cast(char) fgetc(stdin); 527 }