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("  =&gt; %s got %s votes", inputFileA.lcyan, to!string(scoreA).yellow);
330         cwritefln("  =&gt; %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 =&gt; %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 }