diff --git a/.gitignore b/.gitignore index 2e0657a..5fdef23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -.classpath -.project +.vscode/** bin/** MusicXMLParser/MusicXMLToFmt1x diff --git a/Makefile b/Makefile index 5af4ebf..c1982b8 100644 --- a/Makefile +++ b/Makefile @@ -2,4 +2,3 @@ all: mkdir -p bin javac -d bin -cp src src/mv2h/*.java javac -d bin -cp src src/mv2h/*/*.java - cd MusicXMLParser; ./compile.sh diff --git a/MusicXMLParser/BasicCalculation_v170122.hpp b/MusicXMLParser/BasicCalculation_v170122.hpp deleted file mode 100755 index 3cdec8c..0000000 --- a/MusicXMLParser/BasicCalculation_v170122.hpp +++ /dev/null @@ -1,381 +0,0 @@ -#ifndef BasicCalculation_HPP -#define BasicCalculation_HPP - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -using namespace std; - -inline int gcd(int m, int n){ - if(0==m||0==n){return 0;} - while(m!=n){if(m>n){m=m-n;}else{n=n-m;}}//endwhile - return m; -}//end gcd -inline int lcm(int m,int n){ - if (0==m||0==n){return 0;} - return ((m/gcd(m,n))*n);//lcm=m*n/gcd(m,n) -}//end lcm - -inline double LogAdd(double d1,double d2){ - //log(exp(d1)+exp(d2))=log(exp(d1)(1+exp(d2-d1))) - if(d1>d2){ -// if(d1-d2>20){return d1;} - return d1+log(1+exp(d2-d1)); - }else{ -// if(d2-d1>20){return d2;} - return d2+log(1+exp(d1-d2)); - }//endif -}//end LogAdd -inline void Norm(vector& vd){ - double sum=0; - for(int i=0;i& vd){ - double tmpd=vd[0]; - for(int i=0;itmpd){tmpd=vd[i];}}//endfor i - for(int i=0;i& vd){ - assert(vd.size()>0); - double sum=0; - for(int i=0;i& vd){ - assert(vd.size()>1); - double ave=Average(vd); - double sum=0; - for(int i=0;i p,vector q,double regularizer=0){//p given q - assert(p.size()==q.size()); - double sum=0; - for(int i=0;i p,vector q,double scale=1){//p given q - assert(p.size()==q.size()); - double sum=0; - for(int i=0;i &p){ - double val=(1.0*rand())/(1.0*RAND_MAX); - for(int i=0;i b.value){ - return true; - }else{//if a.value <= b.value - return false; - }//endif - }//end operator() -};//end class MorePair -//sort(pairs.begin(), pairs.end(), MorePair()); - -inline vector Intervals(double valmin,double valmax,int nPoint){ - vector values; - double eps=(valmax-valmin)/double(nPoint-1); - for(int i=0;i LogIntervals(double valmin,double valmax,int nPoint){ - vector values; - double eps=(log(valmax)-log(valmin))/double(nPoint-1); - for(int i=0;i class Prob{ -public: - vector P; - vector LP; - vector samples; - - Prob(){ - }//end Prob - Prob(Prob const & prob_){ - P=prob_.P; - LP=prob_.LP; - samples=prob_.samples; - }//end Prob - - ~Prob(){ - }//end ~Prob - - Prob& operator=(const Prob & prob_){ - P=prob_.P; - LP=prob_.LP; - samples=prob_.samples; - return *this; - }//end = - - void Print(){ - for(int i=0;imax){max=P[i];} - }//endfor i - return max; - }//end MaxValue - - int ModeID(){ - double max=P[0]; - int modeID=0; - for(int i=1;imax){modeID=i;} - }//endfor i - return modeID; - }//end ModeID - - void Randomize(){ - for(int i=0;i pairs; - Pair pair; - for(int i=0;i tmpProb; - tmpProb=*this; - for(int i=0;i values; -};//endclass TemporalSample - -class TemporalData{ -public: - vector refTimes;//E.g. 1900,2000 => intervals are (-inf,1900) [1900,2000) [2000,inf) - vector data; - vector > > statistics;//(refYears.size+1)xdimValuex3; #samples,mean,stdev - int dimValue; - - void PrintTimeIntervals(){ - cout<<"(-inf,"< > > values; - values.resize(refTimes.size()+1); - int timeID; - for(int n=0;n=refTimes[i]){timeID=i+1; - }else{break; - }//endif - }//endfor i - values[timeID].push_back(data[n].values); - }//endfor n - dimValue=data[0].dimValue; - - statistics.clear(); - statistics.resize(refTimes.size()+1); - for(int i=0;i vd; - for(int n=0;n -#include -#include -#include -#include -#include -#include -#include"BasicCalculation_v170122.hpp" -using namespace std; - -inline void DeleteHeadSpace(string &buf){ - size_t pos; - while((pos = buf.find_first_of("  \t")) == 0){ - buf.erase(buf.begin()); - if(buf.empty()) break; - }//endwhile -}//end DeleteHeadSpace - -inline vector UnspaceString(string str){ - vector vs; - while(str.size()>0){ - DeleteHeadSpace(str); - if(str=="" || isspace(str[0])!=0){break;} - vs.push_back(str.substr(0,str.find_first_of("  \t"))); - for(int i=0;i0){ - for(int i=1;i<=curKeyFifth;i+=1){ - if(dc==intToDitchclass((i*4-1+70)%7+1)){return "#";} - }//endfor i - return ""; - }else if(curKeyFifth<0){ - for(int i=1;i<=-1*curKeyFifth;i+=1){ - if(dc==intToDitchclass((i*3+4-1+70)%7+1)){return "b";} - }//endfor i - return ""; - }else{ - return ""; - }//endif -}; - -inline string ditchUp(string principal,char acc_rel,int curKeyFifth){ - char dc=intToDitchclass( (ditchclassToInt(principal[0])+7-1+1)%7+1 ); - int oct=principal[principal.size()-1]-'0'; - if(dc=='C'){oct+=1;} - string acc=""; - if(acc_rel!='*'){ - if(acc_rel=='n'){acc=""; - }else if(acc_rel=='s'){acc="#"; - }else if(acc_rel=='S'){acc="##"; - }else if(acc_rel=='f'){acc="b"; - }else if(acc_rel=='F'){acc="bb"; - }//endif - }else{ - acc=acc_norm(dc,curKeyFifth); - }//endif - stringstream ss; - ss.str(""); ss< sitches;//size = numNotes - vector notetypes;//size = numNotes -// vector notenums;//size = numNotes - vector fmt1IDs;//size = numNotes - vector ties;// = 0(def)/1 if the note is NOT/is tied with a previous note. (Used only if tieinfo > 0) (size = numNotes) - string info;//used for type "attributes" -};//end class Fmt1xEvent - -class Fmt1x{ -public: - int TPQN; - vector evts; - vector comments; - - void ReadFile(string filename){ - vector v(100); - vector d(100); - vector s(100); - stringstream ss; - - evts.clear(); - comments.clear(); - - ifstream ifs(filename.c_str()); - if(!ifs.is_open()){cout<<"File not found: "<>s[0]){ - if(s[0][0]=='/'||s[0][0]=='#'){ - if(s[0]=="//TPQN:"){ - ifs>>TPQN; - getline(ifs,s[99]); - }else if(s[0]=="//Fmt1xVersion:"){ - ifs>>s[1]; - if(s[1]!="170104"){ - cout<<"Warning: The fmt1x version is not 170104!"<>evt.barnum>>evt.part>>evt.staff>>evt.voice>>evt.eventtype; - evt.sitches.clear(); evt.notetypes.clear(); evt.fmt1IDs.clear(); evt.ties.clear(); - evt.info=""; - if(evt.eventtype=="attributes"){ - getline(ifs,evt.info); - evt.info+="\n"; - }else if(evt.eventtype=="rest"||evt.eventtype=="chord"||evt.eventtype=="tremolo-s"||evt.eventtype=="tremolo-e"){ - ifs>>evt.dur>>evt.tieinfo>>evt.numNotes; - for(int j=0;j>s[8]; evt.sitches.push_back(s[8]);}//endfor j - for(int j=0;j>s[8]; evt.notetypes.push_back(s[8]);}//endfor j - for(int j=0;j>s[8]; evt.fmt1IDs.push_back(s[8]);}//endfor j - for(int j=0;j>v[8]; evt.ties.push_back(v[8]);}//endfor j - getline(ifs,s[99]); - }else{ - getline(ifs,s[99]); - continue; - }//endif - evts.push_back(evt); - }//endwhile - ifs.close(); - - //cout< v(100); - vector d(100); - vector s(100); - stringstream ss; - - evts.clear(); - comments.clear(); - - ifstream ifs(filename.c_str()); - if(!ifs.is_open()){cout<<"File not found: "< events; - vector depths; - vector inout; -{ - bool isInBracket=false; - int depth=0; - for(int i=0;i'){ - isInBracket=false; - if(all[i-1]=='/'){depth-=1;} - events.push_back(s[0]); - depths.push_back(depth); - inout.push_back(true); - s[0]=""; - continue; - }//endif - s[0]+=all[i]; - }//endfor i -}// - -{ - vector prev_events(events); - vector prev_depths(depths); - vector prev_inout(inout); - events.clear(); - depths.clear(); - inout.clear(); - for(int i=0;i divs; - for(int i=1;i curClefLab; - vector curClefOctChange; - curClefLab.push_back("G2"); - curClefOctChange.push_back(0); - v[0]=0; - int notenum; - string trem; - string curScorePart="P0"; - string curInstrumentName; - - for(int i=1;i1)? 1:0) ); - evt.tieinfo=tie; - evts.push_back(evt); - }else{ - ss.str(""); - ss<1)? 1:0) ); - if(tie%2==1 && evts[evts.size()-1].tieinfo%2==0){ - evts[evts.size()-1].tieinfo+=1; - }//enduf - if(tie/2==1 && evts[evts.size()-1].tieinfo/2==0){ - evts[evts.size()-1].tieinfo+=2; - }//enduf - }//endif - notein=false; - }//endif - }//endfor i - - }//end ReadMusicXML - -};//endclass Fmt1x - - -#endif // FMT1X_HPP diff --git a/MusicXMLParser/MusicXMLToFmt1x_v170104.cpp b/MusicXMLParser/MusicXMLToFmt1x_v170104.cpp deleted file mode 100755 index a95681f..0000000 --- a/MusicXMLParser/MusicXMLToFmt1x_v170104.cpp +++ /dev/null @@ -1,26 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include"Fmt1x_v170108_2.hpp" -using namespace std; - -int main(int argc, char** argv) { - - vector v(100); - vector d(100); - vector s(100); - stringstream ss; - - if(argc!=3){cout<<"Error in usage! : $./this in.xml out_fmt1x.txt"<gt_converted.txt` -Input and output files can also be specified with `-i FILE` and `-o FILE`. -Different parsed voices can be generated using `--part` (instrument/part), `--staff`, and/or `--voice`. Default uses all 3. +There is now a bash script that will perform this evaluation in one command (if you have musescore3): `evaluate_xml.bash gt.xml transcription.xml` -3. Evaluate with alignment using the `-a` flag: -`java -cp bin mv2h.Main -g gt_converted.txt -t trans_converted.txt -a` - -Chord symbols will not be parsed, and all key signatures will be major. - -See [Dataset](#dataset) for examples. +You can also perform the process manually by first converting the MusicXML files into MIDI, and then following the [instructions for MIDI files](#MIDI). + - The recommended way to convert MusicXML to MIDI is to use Musescore3: +`musescore3 -o file.mid file.xml` + - Other methods may also work, but not all will handle anacrusis (pick-up) measures correctly (`music21`, for example, did not when I tested it and will require manual setting during the MIDI conversion with `-a INT`). + - MusicXML files without a time signature are treated as 4/4. #### MIDI 1. Convert a MIDI file into the MV2H format: -`java -cp bin mv2h.tools.Converter -m -i gt.mid >gt_converted.txt` -`-a INT` can be used to set the anacrusis (pick-up bar) length to INT sub beats. -`-o FILE` can also be used to specify an output file (instead of standard output). -Different parsed voices can be generated using `--track` or `--channel`. Default uses both. +`java -cp bin mv2h.tools.Converter -m -i gt.mid -o gt_converted.txt` +`-a INT` can be used to set the anacrusis (pick-up bar) length to INT sub beats, in case it is not aligned correctly in the MIDI. +Different parsed voices can be generated using `--channel` or `--track`. Default uses both. 2. Evaluate with alignment using the `-a` flag: -`java -cp bin mv2h.Main -g gt_converted.txt -t trans_converted.txt -a` +`java -cp bin mv2h.Main -g gt_converted.txt -t transcription_converted.txt -a` Chord symbols will not be parsed. @@ -82,7 +78,7 @@ Meter: 0.7368421052631577 Value: 0.9642857142857143 Harmony: 1.0 MV2H: 0.8887720755376813 - + * `java -cp bin mv2h.Main -g examples/GroundTruth.txt -t examples/Transcription2.txt` Multi-pitch: 0.7727272727272727 Voice: 1.0 @@ -90,7 +86,7 @@ Meter: 1.0 Value: 1.0 Harmony: 0.5 MV2H: 0.8545454545454545 - + * `java -cp bin mv2h.Main -F $1.conv.txt -rm $1.txt +musescore3 -o $1.mid $1 +musescore3 -o $2.mid $2 -./MusicXMLParser/MusicXMLToFmt1x $2 $2.txt -java -cp bin mv2h.tools.Converter -x <$2.txt >$2.conv.txt -rm $2.txt +java -cp bin mv2h.tools.Converter -i $1.mid -o $1.mid.txt +java -cp bin mv2h.tools.Converter -i $2.mid -o $2.mid.txt +rm $1.mid $2.mid -java -cp bin mv2h.Main -g $1.conv.txt -t $2.conv.txt -a -rm $1.conv.txt $2.conv.txt +java -cp bin mv2h.Main -g $1.mid.txt -t $2.mid.txt -a +rm $1.mid.txt $2.mid.txt diff --git a/src/mv2h/Main.java b/src/mv2h/Main.java index 09d2abc..f978361 100644 --- a/src/mv2h/Main.java +++ b/src/mv2h/Main.java @@ -10,14 +10,15 @@ import mv2h.objects.Music; import mv2h.objects.Note; import mv2h.tools.Aligner; +import mv2h.tools.AlignmentNode; /** * The Main class is the class called to evaluate anything with the MV2H package. - * + * * @author Andrew McLeod */ public class Main { - + /** * The difference in duration between two {@link mv2h.objects.Note}s for their value * to be counted as a match. @@ -25,7 +26,7 @@ public class Main { * Measured in milliseconds. */ public static int DURATION_DELTA = 100; - + /** * The difference in onset time between two {@link mv2h.objects.Note}s for them to be * counted as a match. @@ -33,7 +34,7 @@ public class Main { * Measured in milliseconds. */ public static int ONSET_DELTA = 50; - + /** * The difference in time between beginning and end times of a {@link mv2h.objects.meter.Grouping} * for it to be counted as a match. @@ -41,7 +42,7 @@ public class Main { * Measured in milliseconds. */ public static int GROUPING_EPSILON = 50; - + /** * A flag representing if alignment should be performed. Defaults to false. * Can be set to true with the -a or -A flags. @@ -49,7 +50,7 @@ public class Main { * @see #PRINT_ALIGNMENT */ private static boolean PERFORM_ALIGNMENT = false; - + /** * A flag representing if the alignment should be printed or not. Defaults to false. * Can be set to true with the -A flag (which also sets @@ -59,6 +60,13 @@ public class Main { */ private static boolean PRINT_ALIGNMENT = false; + /** + * The penalty assigned for insertion and deletion errors when performing alignment. + * The default value of 1 leads to a reasonably fast, but not exhaustive + * search through alignments. Can be set with the -p flag. + */ + public static double NON_ALIGNMENT_PENALTY = 1.0; + /** * Run the program. There are 2 different modes. *
@@ -72,21 +80,21 @@ public class Main { *
* 2. Get the means and standard deviations of many outputs of this program * (read from standard in): -F - * + * * @param args The command line arguments, as described. - * + * * @throws IOException If a File given with -g or -t cannot be * read. */ public static void main(String[] args) throws IOException { File groundTruth = null; File transcription = null; - + // No args given if (args.length == 0) { argumentError("No arguments given"); } - + for (int i = 0; i < args.length; i++) { switch (args[i].charAt(0)) { // ARGS @@ -94,23 +102,35 @@ public static void main(String[] args) throws IOException { if (args[i].length() == 1) { argumentError("Unrecognized option: " + args[i]); } - + switch (args[i].charAt(1)) { // Check Full case 'F': checkFull(); return; - + case 'A': PRINT_ALIGNMENT = true; - + case 'a': DURATION_DELTA = 20; ONSET_DELTA = 0; GROUPING_EPSILON = 20; PERFORM_ALIGNMENT = true; break; - + + case 'p': + i++; + if (args.length <= i) { + argumentError("No non-alignment penalty given with -p."); + } + try { + NON_ALIGNMENT_PENALTY = Double.parseDouble(args[i]); + } catch (NumberFormatException e) { + argumentError("Non-alignment penalty must be a decimal value. Given: " + args[i]); + } + break; + // Evaluate! case 'g': i++; @@ -125,7 +145,7 @@ public static void main(String[] args) throws IOException { argumentError("Ground truth file " + groundTruth + " does not exist."); } break; - + case 't': i++; if (args.length <= i) { @@ -139,65 +159,78 @@ public static void main(String[] args) throws IOException { argumentError("Transcription file " + transcription + " does not exist."); } break; - + // Error default: argumentError("Unrecognized option: " + args[i]); } break; - + // Error default: argumentError("Unrecognized option: " + args[i]); } } - + if (groundTruth != null && transcription != null) { evaluateGroundTruth(groundTruth, transcription); } else { argumentError("Must give either -F, or both -g FILE and -t FILE."); } } - + /** * Evaluate the given transcription against the given ground truth file. * Prints the result to std out. - * + * * @param groundTruthFile The ground truth. * @param transcriptionFile The transcription. - * - * @throws IOException If one of the Files could not be read. + * + * @throws IOException If one of the Files could not be read. */ public static void evaluateGroundTruth(File groundTruthFile, File transcriptionFile) throws IOException { Music groundTruth = Music.parseMusic(new Scanner(groundTruthFile)); Music transcription = Music.parseMusic(new Scanner(transcriptionFile)); - + // Get scores if (PERFORM_ALIGNMENT) { - + // Choose the best possible alignment out of all potential alignments. MV2H best = new MV2H(0, 0, 0, 0, 0); List bestAlignment = new ArrayList(); - - for (List alignment : Aligner.getPossibleAlignments(groundTruth, transcription)) { - MV2H candidate = groundTruth.evaluateTranscription(transcription.align(groundTruth, alignment)); - - if (candidate.compareTo(best) > 0) { - best = candidate; - bestAlignment = alignment; + + List alignmentNodes = Aligner.getPossibleAlignments(groundTruth, transcription); + int total = 0; + for (AlignmentNode alignmentNode : alignmentNodes) { + total += alignmentNode.count; + } + + int i = 0; + for (AlignmentNode alignmentNode : alignmentNodes) { + for (int alignmentIndex = 0; alignmentIndex < alignmentNode.count; alignmentIndex++) { + System.out.print("Evaluating alignment " + (++i) + " / " + total + "\r"); + + List alignment = alignmentNode.getAlignment(alignmentIndex); + + MV2H candidate = groundTruth.evaluateTranscription(transcription.align(groundTruth, alignment)); + + if (candidate.compareTo(best) > 0) { + best = candidate; + bestAlignment = alignment; + } } } - + if (PRINT_ALIGNMENT) { System.out.println("ALIGNMENT"); System.out.println("========="); - + List> nonAlignedNotes = new ArrayList>(); - + System.out.println("Aligned notes (transcribed -> ground truth):"); for (int noteIndex = 0; noteIndex < transcription.getNoteLists().size(); noteIndex++) { int alignment = bestAlignment.indexOf(noteIndex); - + if (alignment == -1) { nonAlignedNotes.add(transcription.getNoteLists().get(noteIndex)); } else { @@ -206,13 +239,13 @@ public static void evaluateGroundTruth(File groundTruthFile, File transcriptionF } } System.out.println(); - + System.out.println("Non-aligned transcription notes:"); for (List notes : nonAlignedNotes) { System.out.println(notes); } System.out.println(); - + System.out.println("Non-aligned ground truth notes:"); for (int noteIndex = 0; noteIndex < groundTruth.getNoteLists().size(); noteIndex++) { if (bestAlignment.get(noteIndex) == -1) { @@ -220,19 +253,19 @@ public static void evaluateGroundTruth(File groundTruthFile, File transcriptionF } } System.out.println(); - + System.out.println("MV2H"); System.out.println("===="); } - + System.out.println(best); - + } else { // No alignment System.out.println(groundTruth.evaluateTranscription(transcription)); } } - + /** * Calculate and print mean and standard deviation of Multi-pitch, Voice, Meter, Value, Harmony, and MV2H * scores as produced by this program, read from std in. @@ -242,70 +275,70 @@ private static void checkFull() { int multiPitchCount = 0; double multiPitchSum = 0.0; double multiPitchSumSquared = 0.0; - + int voiceCount = 0; double voiceSum = 0.0; double voiceSumSquared = 0.0; - + int meterCount = 0; double meterSum = 0.0; double meterSumSquared = 0.0; - + int valueCount = 0; double valueSum = 0.0; double valueSumSquared = 0.0; - + int harmonyCount = 0; double harmonySum = 0.0; double harmonySumSquared = 0.0; - + int mv2hCount = 0; double mv2hSum = 0.0; double mv2hSumSquared = 0.0; - + // Parse std in Scanner input = new Scanner(System.in); while (input.hasNextLine()) { String line = input.nextLine(); - + int breakPoint = line.indexOf(": "); if (breakPoint == -1) { continue; } - + String prefix = line.substring(0, breakPoint); - + // Check for matching prefixes if (prefix.equalsIgnoreCase("Multi-pitch")) { double score = Double.parseDouble(line.substring(breakPoint + 2)); multiPitchSum += score; multiPitchSumSquared += score * score; multiPitchCount++; - + } else if (prefix.equalsIgnoreCase("Voice")) { double score = Double.parseDouble(line.substring(breakPoint + 2)); voiceSum += score; voiceSumSquared += score * score; voiceCount++; - + } else if (prefix.equalsIgnoreCase("Meter")) { double score = Double.parseDouble(line.substring(breakPoint + 2)); meterSum += score; meterSumSquared += score * score; meterCount++; - + } else if (prefix.equalsIgnoreCase("Value")) { double score = Double.parseDouble(line.substring(breakPoint + 2)); valueSum += score; valueSumSquared += score * score; valueCount++; - + } else if (prefix.equalsIgnoreCase("Harmony")) { double score = Double.parseDouble(line.substring(breakPoint + 2)); harmonySum += score; harmonySumSquared += score * score; harmonyCount++; - + } else if (prefix.equalsIgnoreCase("MV2H")) { double score = Double.parseDouble(line.substring(breakPoint + 2)); mv2hSum += score; @@ -314,26 +347,26 @@ private static void checkFull() { } } input.close(); - + // Calculate means and standard deviations double multiPitchMean = multiPitchSum / multiPitchCount; double multiPitchVariance = multiPitchSumSquared / multiPitchCount - multiPitchMean * multiPitchMean; - + double voiceMean = voiceSum / voiceCount; double voiceVariance = voiceSumSquared / voiceCount - voiceMean * voiceMean; - + double meterMean = meterSum / meterCount; double meterVariance = meterSumSquared / meterCount - meterMean * meterMean; - + double valueMean = valueSum / valueCount; double valueVariance = valueSumSquared / valueCount - valueMean * valueMean; - + double harmonyMean = harmonySum / harmonyCount; double harmonyVariance = harmonySumSquared / harmonyCount - harmonyMean * harmonyMean; - + double mv2hMean = mv2hSum / mv2hCount; double mv2hVariance = mv2hSumSquared / mv2hCount - mv2hMean * mv2hMean; - + // Print System.out.println("Multi-pitch: mean=" + multiPitchMean + " stdev=" + Math.sqrt(multiPitchVariance)); System.out.println("Voice: mean=" + voiceMean + " stdev=" + Math.sqrt(voiceVariance)); @@ -342,46 +375,48 @@ private static void checkFull() { System.out.println("Harmony: mean=" + harmonyMean + " stdev=" + Math.sqrt(harmonyVariance)); System.out.println("MV2H: mean=" + mv2hMean + " stdev=" + Math.sqrt(mv2hVariance)); } - + /** * Calculate the F-measure given counts of TP, FP, and FN. - * + * * @param truePositives The number of true positives. * @param falsePositives The number of false positives. * @param falseNegatives The number of false negatives. - * + * * @return The F-measure of the given counts, or 0 if the result is otherwise NaN. */ public static double getF1(double truePositives, double falsePositives, double falseNegatives) { double precision = truePositives / (truePositives + falsePositives); double recall = truePositives / (truePositives + falseNegatives); - + double f1 = 2.0 * recall * precision / (recall + precision); return Double.isNaN(f1) ? 0.0 : f1; } - + /** * Some argument error occurred. Print the given message and the usage instructions to std err * and exit. - * + * * @param message The message to print to std err. */ private static void argumentError(String message) { StringBuilder sb = new StringBuilder(message).append('\n'); - + sb.append("Usage: Main ARGS\n"); sb.append("ARGS:\n"); - + sb.append("-a = Perform DTW alignment to evaluate non-aligned transcriptions.\n"); sb.append("-A = Perform and print the DTW alignment.\n"); - + sb.append("-g FILE = Use the given FILE as the ground truth (defaults to std in).\n"); sb.append("-t FILE = Use the given FILE as the transcription (defaults to std in).\n"); - sb.append("Either -g or -t (or both) must be given to evaluate, since both cannot be read from std in.\n"); - + sb.append("Either -g or -t (or both) must be given to evaluate, since both cannot be read from std in.\n\n"); + + sb.append("-p DOUBLE = Use the given value as the insertion and deletion penalty for alignment.\n"); + sb.append("-F = Combine the scores from std in (from this program's output) into final"); sb.append(" global mean and standard deviation distributions for each score.\n"); - + System.err.println(sb); System.exit(1); } diff --git a/src/mv2h/objects/Music.java b/src/mv2h/objects/Music.java index 2decc87..41a967c 100644 --- a/src/mv2h/objects/Music.java +++ b/src/mv2h/objects/Music.java @@ -27,36 +27,41 @@ * ({@link #align(Music, List)}). *
* New Music objects should be created with the {@link #parseMusic(Scanner)} method. - * + * * @author Andrew McLeod */ public class Music { - + /** * The notes present in this score. */ private final List notes; - + + /** + * The notes lists, to avoid calculating them every time. + */ + private List> notesLists = null; + /** * The voices of this score. */ private final List voices; - + /** * The metrical structure of this score. */ private final Meter meter; - + /** * The key signature and changes of this score. */ private final KeyProgression keyProgression; - + /** * The chord progression of this score. */ private final ChordProgression chordProgression; - + /** * The last time of this score. */ @@ -66,7 +71,7 @@ public class Music { * Create a new Music object with the given fields. *
* This should not usually be called directly. Rather, use {@link #parseMusic(Scanner)}. - * + * * @param notes {@link #notes} * @param voices {@link #voices} * @param meter {@link #meter} @@ -78,25 +83,29 @@ public Music(List notes, List voices, Meter meter, KeyProgression k int lastTime) { this.notes = notes; Collections.sort(notes); - + this.voices = voices; this.meter = meter; this.keyProgression = keyProgression; this.chordProgression = chordProgression; this.lastTime = lastTime; - + for (Voice voice : voices) { voice.createConnections(); } } - + /** * Get a list of lists of notes, sorted by onset time. Each 2nd level list * contains all notes which share an identical onset time. - * + * * @return A list of lists of notes. */ public List> getNoteLists() { + if (notesLists != null) { + return notesLists; + } + List> lists = new ArrayList>(); if (notes.isEmpty()) { return lists; @@ -106,14 +115,14 @@ public List> getNoteLists() { int mostRecentValueOnsetTime = notes.get(0).valueOnsetTime; lists.add(mostRecentList); mostRecentList.add(notes.get(0)); - + for (int i = 1; i < notes.size(); i++) { Note note = notes.get(i); - + // Still at the same onset time if (mostRecentValueOnsetTime == note.valueOnsetTime) { mostRecentList.add(note); - + // New onset time } else { mostRecentList = new ArrayList(); @@ -122,59 +131,60 @@ public List> getNoteLists() { mostRecentList.add(note); } } - + + notesLists = lists; return lists; } - + /** * Evaluate a given transcription, treating this object as the ground truth. - * + * * @param transcription The transcription to evaluate. - * + * * @return The MV2H evaluation scores object. */ public MV2H evaluateTranscription(Music transcription) { // Tracking objects for notes List transcriptionNotes = new ArrayList(transcription.notes); List groundTruthNotes = new ArrayList(notes); - + // Tracking lists for voices, which will include only matched notes List transcriptionVoices = new ArrayList(transcription.voices.size()); for (int i = 0; i < transcription.voices.size(); i++) { transcriptionVoices.add(new Voice()); } - + List groundTruthVoices = new ArrayList(voices.size()); for (int i = 0; i < voices.size(); i++) { groundTruthVoices.add(new Voice()); } - + // Notes which we can check for value accuracy List valueCheckNotes = new ArrayList(); - + // A mapping for the ground truth. Map groundTruthNoteMapping = new HashMap(); - - + + // Multi-pitch accuracy int multiPitchTruePositives = 0; Iterator transcriptionIterator = transcriptionNotes.iterator(); while (transcriptionIterator.hasNext()) { Note transcriptionNote = transcriptionIterator.next(); - + Iterator groundTruthIterator = groundTruthNotes.iterator(); while (groundTruthIterator.hasNext()) { Note groundTruthNote = groundTruthIterator.next(); - + // Match found if (transcriptionNote.matches(groundTruthNote)) { multiPitchTruePositives++; - + groundTruthNoteMapping.put(transcriptionNote, groundTruthNote); - + transcriptionVoices.get(transcriptionNote.voice).addNote(transcriptionNote); groundTruthVoices.get(groundTruthNote.voice).addNote(groundTruthNote); - + groundTruthIterator.remove(); transcriptionIterator.remove(); break; @@ -183,9 +193,9 @@ public MV2H evaluateTranscription(Music transcription) { } int multiPitchFalsePositives = transcriptionNotes.size(); int multiPitchFalseNegatives = groundTruthNotes.size(); - + double multiPitchF1 = Main.getF1(multiPitchTruePositives, multiPitchFalsePositives, multiPitchFalseNegatives); - + // Make voice connections for (Voice voice : transcriptionVoices) { voice.createConnections(); @@ -193,17 +203,17 @@ public MV2H evaluateTranscription(Music transcription) { for (Voice voice : groundTruthVoices) { voice.createConnections(); } - + // Voice separation double voiceTruePositives = 0; double voiceFalsePositives = 0; double voiceFalseNegatives = 0; // Go through each voice in the transcription (this is only matched notes) for (Voice transcriptionVoice : transcriptionVoices) { - + // Go through each note cluster in the transcription voice for (NoteCluster transcriptionCluster : transcriptionVoice.noteClusters.values()) { - + // Create list of notes which are linked to in the transcription List nextTranscriptionNotesFinal = new ArrayList(); for (NoteCluster nextTranscriptionCluster : transcriptionCluster.nextClusters) { @@ -211,15 +221,15 @@ public MV2H evaluateTranscription(Music transcription) { nextTranscriptionNotesFinal.add(nextTranscriptionNote); } } - + // Go through each note in the note cluster for (Note transcriptionNote : transcriptionCluster.notes) { Note groundTruthNote = groundTruthNoteMapping.get(transcriptionNote); - + // Find the matching ground truth note and its place in its voice Voice groundTruthVoice = groundTruthVoices.get(groundTruthNote.voice); NoteCluster groundTruthCluster = groundTruthVoice.getNoteCluster(groundTruthNote); - + // Create list of notes which are linked to in the ground truth List nextGroundTruthNotesFinal = new ArrayList(); for (NoteCluster nextGroundTruthCluster : groundTruthCluster.nextClusters) { @@ -228,24 +238,24 @@ public MV2H evaluateTranscription(Music transcription) { } } List nextGroundTruthNotes = new ArrayList(nextGroundTruthNotesFinal); - + // Save a copy of the linked transcription notes list List nextTranscriptionNotes = new ArrayList(nextTranscriptionNotesFinal); - + // Count how many tp, fp, and fn for these connection sets int connectionTruePositives = 0; Iterator transcriptionConnectionIterator = nextTranscriptionNotes.iterator(); while (transcriptionConnectionIterator.hasNext()) { Note nextTranscriptionNote = transcriptionConnectionIterator.next(); - + Iterator groundTruthConnectionIterator = nextGroundTruthNotes.iterator(); while (groundTruthConnectionIterator.hasNext()) { Note nextGroundTruthNote = groundTruthConnectionIterator.next(); - + // Match found if (nextTranscriptionNote.matches(nextGroundTruthNote)) { connectionTruePositives++; - + groundTruthConnectionIterator.remove(); transcriptionConnectionIterator.remove(); break; @@ -254,7 +264,7 @@ public MV2H evaluateTranscription(Music transcription) { } int connectionFalsePositives = nextTranscriptionNotes.size(); int connectionFalseNegatives = nextGroundTruthNotes.size(); - + // Normalize counts before adding to totals, so that each connection is weighted equally double outWeight = (nextGroundTruthNotesFinal.size() + nextTranscriptionNotesFinal.size()) / 2.0; if (outWeight > 0) { @@ -262,9 +272,9 @@ public MV2H evaluateTranscription(Music transcription) { voiceFalsePositives += ((double) connectionFalsePositives) / (outWeight * transcriptionCluster.notes.size()); voiceFalseNegatives += ((double) connectionFalseNegatives) / (outWeight * transcriptionCluster.notes.size()); } - + // Add note to list to noteValue check - + // List of notes which are linked to in the original ground truth (including multi-pitch non-TPs) List nextOriginalGroundTruthNotes = new ArrayList(); for (NoteCluster nextGroundTruthCluster : voices.get(groundTruthNote.voice).getNoteCluster(groundTruthNote).nextClusters) { @@ -272,11 +282,11 @@ public MV2H evaluateTranscription(Music transcription) { nextOriginalGroundTruthNotes.add(nextGroundTruthNote); } } - + // Both are the end of a voice if (nextOriginalGroundTruthNotes.isEmpty() && nextTranscriptionNotesFinal.isEmpty()) { valueCheckNotes.add(transcriptionNote); - + } else { // Check if at least one original ground truth connection was correct boolean match = false; @@ -288,7 +298,7 @@ public MV2H evaluateTranscription(Music transcription) { break; } } - + if (match) { break; } @@ -298,12 +308,12 @@ public MV2H evaluateTranscription(Music transcription) { } } double voiceF1 = Main.getF1(voiceTruePositives, voiceFalsePositives, voiceFalseNegatives); - - + + // Meter double meterF1 = transcription.meter.getF1(meter); - - + + // Note value (check only GT matches and GT voice matches) double valueScoreSum = 0.0; for (Note transcriptionNote : valueCheckNotes) { @@ -313,25 +323,25 @@ public MV2H evaluateTranscription(Music transcription) { if (Double.isNaN(valueScore)) { valueScore = 0.0; } - - + + // Harmony double keyScore = transcription.keyProgression.getScore(keyProgression, lastTime); double progressionScore = transcription.chordProgression.getScore(chordProgression, lastTime); - + double harmonyScore = (keyScore + progressionScore) / 2; if (Double.isNaN(progressionScore)) { harmonyScore = keyScore; } - + if (Double.isNaN(keyScore)) { harmonyScore = progressionScore; } - + if (Double.isNaN(harmonyScore)) { harmonyScore = 0.0; } - + // MV2H return new MV2H(multiPitchF1, voiceF1, meterF1, valueScore, harmonyScore); } @@ -339,64 +349,66 @@ public MV2H evaluateTranscription(Music transcription) { /** * Get a new Music object whose times are mapped to the corresponding ground truth's * times given the alignment. - * + * * @param gt The ground truth Music object. * @param alignment The alignment to re-map with. * An alignment is a list containing, for each ground truth note list, the index of the transcription * note list to which it is aligned, or -1 if it was not aligned with any transcription note. - * + * * @return A new Music object with the given alignment. */ public Music align(Music gt, List alignment) { List newNotes = new ArrayList(notes.size()); List newVoices = new ArrayList(voices.size()); - + + Map alignedTimes = new HashMap(); + // Convert each note into a new note for (Note note : notes) { newNotes.add(new Note( note.pitch, - Aligner.convertTime(note.onsetTime, gt, this, alignment), - Aligner.convertTime(note.valueOnsetTime, gt, this, alignment), - Aligner.convertTime(note.valueOffsetTime, gt, this, alignment), + Aligner.convertTime(note.onsetTime, gt, this, alignment, alignedTimes), + Aligner.convertTime(note.valueOnsetTime, gt, this, alignment, alignedTimes), + Aligner.convertTime(note.valueOffsetTime, gt, this, alignment, alignedTimes), note.voice)); - + while (note.voice >= newVoices.size()) { newVoices.add(new Voice()); } newVoices.get(note.voice).addNote(newNotes.get(newNotes.size() - 1)); } - + // Convert the metrical structure times - Meter newMeter = new Meter(Aligner.convertTime(0, gt, this, alignment)); + Meter newMeter = new Meter(Aligner.convertTime(0, gt, this, alignment, alignedTimes)); for (Hierarchy h : meter.getHierarchies()) { newMeter.addHierarchy(new Hierarchy(h.beatsPerBar, h.subBeatsPerBeat, h.tatumsPerSubBeat, h.anacrusisLengthTatums, - Aligner.convertTime(h.time, gt, this, alignment))); + Aligner.convertTime(h.time, gt, this, alignment, alignedTimes))); } for (Tatum tatum : meter.getTatums()) { - newMeter.addTatum(new Tatum(Aligner.convertTime(tatum.time, gt, this, alignment))); + newMeter.addTatum(new Tatum(Aligner.convertTime(tatum.time, gt, this, alignment, alignedTimes))); } - + // Convert the key change times KeyProgression newKeyProgression = new KeyProgression(); for (Key key : keyProgression.getKeys()) { - newKeyProgression.addKey(new Key(key.tonic, key.isMajor, Aligner.convertTime(key.time, gt, this, alignment))); + newKeyProgression.addKey(new Key(key.tonic, key.isMajor, Aligner.convertTime(key.time, gt, this, alignment, alignedTimes))); } - + // Convert the chord change times ChordProgression newChordProgression = new ChordProgression(); for (Chord chord : chordProgression.getChords()) { - newChordProgression.addChord(new Chord(chord.chord, Aligner.convertTime(chord.time, gt, this, alignment))); + newChordProgression.addChord(new Chord(chord.chord, Aligner.convertTime(chord.time, gt, this, alignment, alignedTimes))); } - + // Create and return the new Music object return new Music(newNotes, newVoices, newMeter, newKeyProgression, newChordProgression, - Aligner.convertTime(lastTime, gt, this, alignment)); + Aligner.convertTime(lastTime, gt, this, alignment, alignedTimes)); } /** * Parse a musical score from the given scanner in mv2h format and return a corresponding * Music object. - * + * * @param input The input stream to read from. * @return The parsed Music object. * @throws IOException If there was an error in reading or parsing the stream. @@ -409,34 +421,34 @@ public static Music parseMusic(Scanner input) throws IOException { ChordProgression chordProgression = new ChordProgression(); KeyProgression keyProgression = new KeyProgression(); int lastTime = Integer.MIN_VALUE; - + // Read through input while (input.hasNextLine()) { String line = input.nextLine(); - + // Check for matching prefixes, and pass each to its corresponding parser. if (line.startsWith("Note")) { Note note = Note.parseNote(line); notes.add(note); - + // Add note to voice while (note.voice >= voices.size()) { voices.add(new Voice()); } voices.get(note.voice).addNote(note); - + lastTime = Math.max(lastTime, note.valueOffsetTime); - + } else if (line.startsWith("Tatum")) { Tatum tatum = Tatum.parseTatum(line); meter.addTatum(tatum); - + lastTime = Math.max(lastTime, tatum.time); - + } else if (line.startsWith("Chord")) { Chord chord = Chord.parseChord(line); chordProgression.addChord(chord); - + lastTime = Math.max(lastTime, chord.time); } else if (line.startsWith("Hierarchy")) { @@ -446,12 +458,12 @@ public static Music parseMusic(Scanner input) throws IOException { } else if (line.startsWith("Key")) { Key key = Key.parseKey(line); keyProgression.addKey(key); - + lastTime = Math.max(lastTime, key.time); } } input.close(); - + return new Music(notes, voices, meter, keyProgression, chordProgression, lastTime); } diff --git a/src/mv2h/tools/Aligner.java b/src/mv2h/tools/Aligner.java index dc95cd2..873ed8c 100644 --- a/src/mv2h/tools/Aligner.java +++ b/src/mv2h/tools/Aligner.java @@ -1,10 +1,10 @@ package mv2h.tools; import java.util.ArrayList; -import java.util.HashSet; -import java.util.Iterator; +import java.util.HashMap; import java.util.List; -import java.util.Set; +import java.util.Map; +import java.util.Map.Entry; import mv2h.Main; import mv2h.objects.Music; @@ -15,211 +15,281 @@ * All of its methods are static, and it uses a heuristic-based dynamic time warp to get a number of * candidate alignments {@link #getPossibleAlignments(Music, Music)}, and can be used to convert * the times of a transcription based on one of those alignments - * {@link #convertTime(int, Music, Music, List)}. - * + * {@link #convertTime(int, Music, Music, List)}. + * * @author Andrew McLeod */ public class Aligner { - + /** * Get all possible alignments of the given ground truth and transcription. - * + * * @param gt The ground truth. * @param m The transcription. - * - * @return A Set of all possible alignments of the transcription to the ground truth. - * An alignment is a list containing, for each ground truth note list, the index of the transcription - * note list to which it is aligned, or -1 if it was not aligned with any transcription note. + * + * @return A List of all possible alignments of the transcription to the ground truth. + * An alignment is a list containing, for each ground truth note, the index of the transcription + * note to which it is aligned, or -1 if it was not aligned with any transcription note. */ - public static Set> getPossibleAlignments(Music gt, Music m) { - double[][] distances = getAlignmentMatrix(gt.getNoteLists(), m.getNoteLists()); - return getPossibleAlignmentsFromMatrix(distances.length - 1, distances[0].length - 1, distances); + public static List getPossibleAlignments(Music gt, Music m) { + List>> previousCells = getAlignmentMatrix(gt.getNoteLists(), m.getNoteLists()); + + List>> alignmentCache = new ArrayList>>(previousCells.size()); + for (int i = 0; i < previousCells.size(); i++) { + List> nestedList = new ArrayList>(previousCells.get(0).size()); + for (int j = 0; j < previousCells.get(0).size(); j++) { + nestedList.add(new ArrayList()); + } + alignmentCache.add(nestedList); + } + + return getPossibleAlignmentsFromMatrix(previousCells.size() - 1, previousCells.get(0).size() - 1, previousCells, alignmentCache); } - + /** - * A recursive function to get all of the possible alignments which lead to - * the optimal distance from the distance matrix returned by the heuristic-based DTW in + * A recursive function to get all of the possible alignments from the previousCells + * pointers returned by the heuristic-based DTW in * {@link #getAlignmentMatrix(List, List)}, up to matrix indices i, j. - * + * * @param i The first index, representing the transcribed note index. * @param j The second index, representing the ground truth note index. - * @param distances The full distances matrix from {@link #getAlignmentMatrix(List, List)}. - * - * @return A Set of all possible alignments given the distance matrix, up to notes i, j. + * @param previousCells The previous cells matrix from {@link #getAlignmentMatrix(List, List)}. + * + * @return A List of all possible alignments given the previous cells matrix, up to notes i, j. * An alignment is a list containing, for each ground truth note list, the index of the transcription * note list to which it is aligned, or -1 if it was not aligned with any transcription note. */ - private static Set> getPossibleAlignmentsFromMatrix(int i, int j, double[][] distances) { - Set> alignments = new HashSet>(); - + private static List getPossibleAlignmentsFromMatrix(int i, int j, List>> previousCells, List>> alignmentCache) { + List alignments = alignmentCache.get(i).get(j); + if (!alignments.isEmpty()) { + return alignments; + } + // Base case. we are at the beginning and nothing else needs to be aligned. if (i == 0 && j == 0) { - alignments.add(new ArrayList()); return alignments; } - - double min = Math.min(Math.min( - i > 0 ? distances[i - 1][j] : Double.POSITIVE_INFINITY, - j > 0 ? distances[i][j - 1] : Double.POSITIVE_INFINITY), - i > 0 && j > 0 ? distances[i - 1][j - 1] : Double.POSITIVE_INFINITY); - - // Note that we could perform multiple of these if blocks. - - // This transcription note was aligned with nothing in the ground truth. Add -1. - if (distances[i - 1][j] == min) { - for (List list : getPossibleAlignmentsFromMatrix(i - 1, j, distances)) { - list.add(-1); - alignments.add(list); - } - } - - // This ground truth note was aligned with nothing in the transcription. Skip it. - if (distances[i][j - 1] == min) { - for (List list : getPossibleAlignmentsFromMatrix(i, j - 1, distances)) { - alignments.add(list); - } - } - - // The current transcription and ground truth notes were aligned. Add the current ground - // truth index to the alignment list. - if (distances[i - 1][j - 1] == min) { - for (List list : getPossibleAlignmentsFromMatrix(i - 1, j - 1, distances)) { - list.add(j - 1); // j - 2, because (j-1) is aligned, and the distance matrix starts from 1. - alignments.add(list); + + for (int previousCell : previousCells.get(i).get(j)) { + if (previousCell == -1) { + // This transcription note was aligned with nothing in the ground truth. + alignments.add(new AlignmentNode(getPossibleAlignmentsFromMatrix(i - 1, j, previousCells, alignmentCache), -1)); + + } else if (previousCell == 1) { + // This ground truth note was aligned with nothing in the transcription. + for (AlignmentNode prev : getPossibleAlignmentsFromMatrix(i, j - 1, previousCells, alignmentCache)) { + if (prev.value != -1) { + alignments.add(prev); + } + } + + } else { + // The current transcription and ground truth notes were aligned. + alignments.add(new AlignmentNode(getPossibleAlignmentsFromMatrix(i - 1, j - 1, previousCells, alignmentCache), j - 1)); } } - + return alignments; } - + /** - * Get the Dynamic Time Warping distance matrix from the note lists. + * Get the Dynamic Time Warping alignment paths from the note lists. *
- * During calculation, we add an additional 0.01 penalty to any aligned note whose previous + * During calculation, we add an additional 0.6 penalty to any aligned note whose previous * notes (in both ground truth and transcription) were not aligned. This is used to prefer * alignments which align many consecutive notes. - * + * * @param gtNotes The ground truth note lists, split by onset time. * @param mNotes The transcribed note lists, split by onset time. - * - * @return The DTW distance matrix. + * + * @return A List of the previous step's aligned cells for each cell in the alignment matrix. */ - private static double[][] getAlignmentMatrix(List> gtNotes, List> mNotes) { + private static List>> getAlignmentMatrix(List> gtNotes, List> mNotes) { + List> gtNoteMaps = getNotePitchMaps(gtNotes); + List> mNoteMaps = getNotePitchMaps(mNotes); + double[][] distances = new double[gtNotes.size() + 1][mNotes.size() + 1]; - + + List>> previousCells = new ArrayList>>(gtNotes.size() + 1); + for (int i = 0; i < gtNotes.size() + 1; i++) { + List> list = new ArrayList>(mNotes.size() + 1); + for (int j = 0; j < mNotes.size() + 1; j++) { + list.add(new ArrayList(3)); + } + + previousCells.add(list); + } + for (int i = 1; i < distances.length; i++) { distances[i][0] = Double.POSITIVE_INFINITY; } - + for (int j = 1; j < distances[0].length; j++) { distances[0][j] = Double.POSITIVE_INFINITY; } - + for (int j = 1; j < distances[0].length; j++) { for (int i = 1; i < distances.length; i++) { - double distance = getDistance(gtNotes.get(i - 1), mNotes.get(j - 1)); - - double min = Math.min(Math.min( - distances[i - 1][j] + 0.6, - distances[i][j - 1] + 0.6), - distances[i - 1][j - 1] + distance); - distances[i][j] = min; + double distance = getDistance(gtNoteMaps.get(i - 1), mNoteMaps.get(j - 1)); + + double distance_i_1 = distances[i - 1][j] + Main.NON_ALIGNMENT_PENALTY; + double distance_j_1 = distances[i][j - 1] + Main.NON_ALIGNMENT_PENALTY; + double distance_i_j_1 = distances[i - 1][j - 1] + distance; + + double min_distance = Math.min(Math.min(distance_i_1, distance_j_1), distance_i_j_1); + + List previousCell = previousCells.get(i).get(j); + if (distance_i_1 == min_distance) { + previousCell.add(-1); + } + + if (distance_j_1 == min_distance) { + previousCell.add(1); + } + + if (distance_i_j_1 == min_distance) { + previousCell.add(0); + } + + distances[i][j] = min_distance; } } - - return distances; + + return previousCells; } - + + /** + * Convert note lists into note pitch maps, which map each pitch of a note to the + * number of notes in that note list at that pitch. + * + * @param noteLists A list of the note lists of a piece of music. + * @return A list of pitch maps for that piece of music. + */ + private static List> getNotePitchMaps(List> noteLists) { + List> notePitchMaps = new ArrayList>(noteLists.size()); + + for (List notesList : noteLists) { + Map pitchMap = new HashMap(notesList.size()); + notePitchMaps.add(pitchMap); + + for (Note note : notesList) { + if (pitchMap.containsKey(note.pitch)) { + pitchMap.put(note.pitch, pitchMap.get(note.pitch) + 1); + } else { + pitchMap.put(note.pitch, 1); + } + } + } + + return notePitchMaps; + } + /** * Get the distance between a given ground truth note set and a possible transcription note set. - * - * @param gtNotes The ground truth notes. - * @param mNotes The possible transcription notes. + * + * @param gtNoteMap The pitch map of a ground truth note set. + * @param mNoteMap The pitch map of a possible transcription note set. * @return The alignment score. 1 - its F-measure. */ - private static double getDistance(List gtNotes, List mNotes) { + private static double getDistance(Map gtNoteMap, Map mNoteMap) { int truePositives = 0; - List gtNotesCopy = new ArrayList(gtNotes); - - for (Note mNote : mNotes) { - Iterator gtIterator = gtNotesCopy.iterator(); - - while (gtIterator.hasNext()) { - Note gtNote = gtIterator.next(); - - if (mNote.pitch == gtNote.pitch) { - truePositives++; - gtIterator.remove(); - break; + int falsePositives = 0; + + for (Entry entry : mNoteMap.entrySet()) { + Integer pitch = entry.getKey(); + int count = entry.getValue(); + + if (gtNoteMap.containsKey(pitch)) { + int gtCount = gtNoteMap.get(pitch); + + truePositives += Math.min(count, gtCount); + if (count > gtCount) { + falsePositives += count - gtCount; } + + } else { + falsePositives += count; } } - - int falsePositives = mNotes.size() - truePositives; - int falseNegatives = gtNotesCopy.size(); - + + if (truePositives == 0) { + return 1.0; + } + + int gtNoteCount = 0; + for (int count : gtNoteMap.values()) { + gtNoteCount += count; + } + + int falseNegatives = gtNoteCount - truePositives; + return 1.0 - Main.getF1(truePositives, falsePositives, falseNegatives); } /** * Convert a time to a new time given some alignment. - * + * * @param time The time we want to convert. * @param gt The ground truth music, to help with alignment. * @param transcription The transcribed music, where the time comes from. * @param alignment The alignment. * An alignment is a list containing, for each ground truth note list, the index of the transcription * note list to which it is aligned, or -1 if it was not aligned with any transcription note. - * + * * @return A time converted from transcription scale to ground truth scale. */ - public static int convertTime(int time, Music gt, Music transcription, List alignment) { + public static int convertTime(int time, Music gt, Music transcription, List alignment, Map alignedTimes) { + Integer alignedTime = alignedTimes.get(time); + if (alignedTime != null) { + return alignedTime; + } + double transcriptionIndex = -1; List> transcriptionNotes = transcription.getNoteLists(); - + // Find the correct transcription anchor index to start with for (int i = 0; i < transcriptionNotes.size(); i++) { - + // Time matches an anchor exactly if (transcriptionNotes.get(i).get(0).valueOnsetTime == time) { transcriptionIndex = i; break; } - + // This anchor is past the time if (transcriptionNotes.get(i).get(0).valueOnsetTime > time) { transcriptionIndex = i - 0.5; break; } } - + List> gtNotes = gt.getNoteLists(); int gtPreviousAnchor = -1; int gtPreviousPreviousAnchor = -1; int gtNextAnchor = gtNotes.size(); int gtNextNextAnchor = gtNotes.size(); - + // Go through the alignments for (int i = 0; i < alignment.size(); i++) { if (alignment.get(i) != -1) { // There was an alignment here - + if (alignment.get(i) == transcriptionIndex) { // This is the correct time, exactly on the index return gtNotes.get(i).get(0).valueOnsetTime; } - + if (alignment.get(i) < transcriptionIndex) { // The time is past this anchor gtPreviousPreviousAnchor = gtPreviousAnchor; gtPreviousAnchor = i; - + } else { // We are past the time if (gtNextAnchor == gtNotes.size()) { // This is the first anchor for which we are past the time gtNextAnchor = i; - + } else { // This is the 2nd anchor for which we are past the time gtNextNextAnchor = i; @@ -228,40 +298,46 @@ public static int convertTime(int time, Music gt, Music transcription, List> gtNotes, List> mNotes, List alignment) { @@ -278,9 +354,9 @@ private static int convertTime(int time, int gtPreviousAnchor, int gtNextAnchor, int gtNextTime = gtNotes.get(gtNextAnchor).get(0).valueOnsetTime; int mPreviousTime = mNotes.get(alignment.get(gtPreviousAnchor)).get(0).valueOnsetTime; int mNextTime = mNotes.get(alignment.get(gtNextAnchor)).get(0).valueOnsetTime; - + double rate = ((double) (gtNextTime - gtPreviousTime)) / (mNextTime - mPreviousTime); - + return (int) Math.round(rate * (time - mPreviousTime) + gtPreviousTime); } -} \ No newline at end of file +} diff --git a/src/mv2h/tools/AlignmentNode.java b/src/mv2h/tools/AlignmentNode.java new file mode 100644 index 0000000..399d4f0 --- /dev/null +++ b/src/mv2h/tools/AlignmentNode.java @@ -0,0 +1,82 @@ +package mv2h.tools; + +import java.util.ArrayList; +import java.util.List; + +/** + * The AlignmentNode class is used to help when aligning musical scores + * ({@link mv2h.opbjects.Music} objects). It implements a backwards-linked list of alignments, + * and can be used to generate an alignment list at each node. + * + * @author Andrew McLeod + */ +public class AlignmentNode { + /** + * A List of previous AlignmentNodes in the backwards-linked list. + */ + public final List prevList; + + /** + * The value to add to the alignment list for this node. Use {@link #NO_ALIGNMENT} + * for no aligned transcription note. + */ + public final int value; + + /** + * How many alignment lists pass through this node. + */ + public final int count; + + /** + * Create a new AlignmentNode. + * + * @param prevList {@link #prevList} + * @param value {@link #value} + */ + public AlignmentNode(List prevList, int value) { + this.prevList = prevList; + this.value = value; + + int count = 0; + for (AlignmentNode prev : this.prevList) { + count += prev.count; + } + this.count = Math.max(count, 1); + } + + /** + * Generate an alignment list from the node. + * + * @param index The index of the alignment node to return (since multiple lists pass + * through this node). + * + * @return An alignment for this node. + * An alignment is a list containing, for each ground truth note, the index of the transcription + * note to which it is aligned, or -1 if it was not aligned with any transcription note. + */ + public List getAlignment(int index) { + List alignment = null; + + if (prevList.isEmpty()) { + // Base case + alignment = new ArrayList(); + + } else { + // Find the correct previous node based on the index + for (AlignmentNode prev : prevList) { + if (index < prev.count) { + // Previous node found. Get prev list. + alignment = prev.getAlignment(index); + break; + } + + // Previous node not yet found. Decrememnt index and find the previous list. + index -= prev.count; + } + } + + alignment.add(value); + + return alignment; + } +} diff --git a/src/mv2h/tools/Converter.java b/src/mv2h/tools/Converter.java index 498cb4e..73f16c1 100644 --- a/src/mv2h/tools/Converter.java +++ b/src/mv2h/tools/Converter.java @@ -1,58 +1,41 @@ package mv2h.tools; import java.io.File; -import java.io.FileInputStream; import java.io.FileWriter; import java.io.IOException; -import java.io.InputStream; import javax.sound.midi.InvalidMidiDataException; /** * The Converter class is used to convert another file format into * a format that can be read by the MV2H package (standard out). - * + * * @author Andrew McLeod */ public class Converter { - /** - * The number of milliseconds per beat by default. - */ - public static final int MS_PER_BEAT = 500; - - /** - * Options for MusicXML voice calculation. - */ - public static boolean PART = false; - public static boolean STAFF = false; - public static boolean VOICE = false; - /** * Options for MIDI voice calculation. */ public static boolean CHANNEL = false; public static boolean TRACK = false; - + /** * Run the program, reading the MusicXMLParser output from standard in and printing to * standard out. - * + * * @param args Unused command line arguments. */ public static void main(String[] args) { - boolean useXml = false; - boolean useMidi = false; - int numToUse = 0; int anacrusis = 0; - + File inFile = null; File outFile = null; - + // No args given if (args.length == 0) { argumentError("No arguments given"); } - + for (int i = 0; i < args.length; i++) { switch (args[i].charAt(0)) { // ARGS @@ -60,24 +43,8 @@ public static void main(String[] args) { if (args[i].length() == 1) { argumentError("Unrecognized option: " + args[i]); } - + switch (args[i].charAt(1)) { - // midi - case 'm': - if (!useMidi) { - numToUse++; - useMidi = true; - } - break; - - // musicxml - case 'x': - if (!useXml) { - numToUse++; - useXml = true; - } - break; - // anacrusis case 'a': i++; @@ -90,7 +57,7 @@ public static void main(String[] args) { argumentError("Anacrusis must be an integer."); } break; - + // input file case 'i': i++; @@ -105,7 +72,7 @@ public static void main(String[] args) { argumentError("Input file " + inFile + " does not exist."); } break; - + // output file case 'o': i++; @@ -117,94 +84,51 @@ public static void main(String[] args) { } outFile = new File(args[i]); break; - + // Voice options case '-': switch (args[i].substring(2)) { - case "part": - PART = true; - break; - - case "staff": - STAFF = true; - break; - - case "voice": - VOICE = true; - break; - case "channel": CHANNEL = true; break; - + case "track": TRACK = true; break; - + default: argumentError("Unrecognized option: " + args[i]); } break; - + // Error default: argumentError("Unrecognized option: " + args[i]); } break; - + // Error default: argumentError("Unrecognized option: " + args[i]); } } - - if (numToUse != 1) { - argumentError("Exactly 1 format is required"); - } - + // Convert Converter converter = null; - if (useMidi) { - if (!CHANNEL && !TRACK) { - CHANNEL = true; - TRACK = true; - } - if (inFile == null) { - argumentError("-i FILE is required with MIDI files (-m)."); - } - try { - converter = new MidiConverter(inFile, anacrusis); - } catch (IOException | InvalidMidiDataException e) { - System.err.println("Error reading from " + inFile + ":\n" + e.getMessage()); - System.exit(1); - } - - } else if (useXml) { - if (!PART && !STAFF && !VOICE) { - PART = true; - STAFF = true; - VOICE = true; - } - InputStream is = System.in; - if (inFile != null) { - try { - is = new FileInputStream(inFile); - } catch (IOException e) { - System.err.println("Error reading from " + inFile + ":\n" + e.getMessage()); - System.exit(1); - } - } - converter = new MusicXmlConverter(is); - if (inFile != null) { - try { - is.close(); - } catch (IOException e) { - System.err.println("Error reading from " + inFile + ":\n" + e.getMessage()); - System.exit(1); - } - } + if (!CHANNEL && !TRACK) { + CHANNEL = true; + TRACK = true; } - + if (inFile == null) { + argumentError("-i FILE is required."); + } + try { + converter = new MidiConverter(inFile, anacrusis); + } catch (IOException | InvalidMidiDataException e) { + System.err.println("Error reading from " + inFile + ":\n" + e.getMessage()); + System.exit(1); + } + // Print result if (outFile != null) { try { @@ -212,49 +136,38 @@ public static void main(String[] args) { fw.write(converter.toString()); fw.close(); } catch (IOException e) { - System.err.println("Error reading from " + inFile + ":\n" + e.getMessage()); + System.err.println("Error writing to " + outFile + ":\n" + e.getMessage()); System.err.println(); System.err.println("Printing to std out instead:"); System.out.println(converter.toString()); } - + } else { System.out.println(converter.toString()); } } - + /** * Some argument error occurred. Print the given message and the usage instructions to std err * and exit. - * + * * @param message The message to print to std err. */ private static void argumentError(String message) { StringBuilder sb = new StringBuilder(message).append('\n'); - - sb.append("Usage: Converter [-x | -m] [-i FILE] [-o FILE] [-a INT] [--VOICE_ARGS]\n\n"); - - sb.append("Exactly one format of -x or -m is required:\n"); - sb.append("-x = Convert from parsed MusicXML.\n"); - sb.append("-m = Convert from MIDI.\n\n"); - - sb.append("-i FILE = Read input from the given FILE. Required for MIDI.\n"); - sb.append(" If not given for MusicXML, read from std input.\n"); + + sb.append("Usage: Converter [-i FILE] [-o FILE] [-a INT] [--VOICE_ARGS]\n\n"); + + sb.append("-i FILE = Read input from the given midi FILE. Required.\n"); sb.append("-o FILE = Print out to the given FILE.\n"); sb.append(" If not given, print to std out.\n\n"); - + sb.append("Voice specific args (can include multiple; defaults to all):\n"); - sb.append("MusicXML:\n"); - sb.append(" --part = Use part (instrument) to separate parsed voices.\n"); - sb.append(" --staff = Use staff to separate parsed voices.\n"); - sb.append(" --voice = Use voice to separate parsed voices.\n"); - sb.append("MIDI:\n"); sb.append(" --channel = Use channel to separate parsed voices.\n"); sb.append(" --track = Use track to separate parsed voices.\n\n"); - - sb.append("MIDI-specific args:\n"); + sb.append("-a INT = Set the length of the anacrusis (pick-up bar), in sub-beats.\n"); - + System.err.println(sb); System.exit(1); } diff --git a/src/mv2h/tools/MusicXmlConverter.java b/src/mv2h/tools/MusicXmlConverter.java deleted file mode 100644 index 81d4136..0000000 --- a/src/mv2h/tools/MusicXmlConverter.java +++ /dev/null @@ -1,477 +0,0 @@ -package mv2h.tools; - -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Scanner; - -import mv2h.objects.Note; -import mv2h.objects.harmony.Key; -import mv2h.objects.meter.Hierarchy; -import mv2h.objects.meter.Tatum; - -/** - * The MusicXmlConverter class is used to convert a given output from the MusicXMLParser - * into a format that can be read by the MV2H package (using the toString() method). - * - * @author Andrew McLeod - */ -public class MusicXmlConverter extends Converter { - /** - * The notes present in the XML piece. - */ - private List notes = new ArrayList(); - - /** - * The hierarchies of this piece. - */ - private List hierarchies = new ArrayList(); - - /** - * The tick of the starting time of each hierarchy. - */ - private List hierarchyTicks = new ArrayList(); - - /** - * A list of the key signatures of this piece. - */ - private List keys = new ArrayList(); - - /** - * A list of the notes for which there hasn't yet been an offset. - */ - private List unfinishedNotes = new ArrayList(); - - /** - * The last tick of the piece. - */ - private int lastTick = 0; - - /** - * The first tick of the piece. - */ - private int firstTick = Integer.MAX_VALUE; - - /** - * The bar of the previous line. This is kept updated until the anacrusis is handled. - *
- * @see #handleAnacrusis(int, int, int, int) - */ - private int previousBar = -1; - - /** - * The number of ticks per quarter note. 1 tick is 1 tatum. Defaults to 4. - */ - private int ticksPerQuarterNote = 4; - - /** - * A mapping for XML voices ("part_staff_voice" strings) to 0-indexed MV2H voices. - */ - private final Map voiceMap; - - /** - * Create a new MusicXmlConverter object by parsing the input from the MusicXMLParser. - *
- * This method contains the main program logic, and printing is handled by - * {@link #toString()}. - * - * @param stream The MusicXMLParser output to convert. - */ - public MusicXmlConverter(InputStream stream) { - Scanner in = new Scanner(stream); - int lineNum = 0; - boolean anacrusisHandled = false; - - voiceMap = new HashMap(); - - while (in.hasNextLine()) { - lineNum++; - String line = in.nextLine(); - - // Skip comment lines - if (line.startsWith("//")) { - continue; - } - - String[] attributes = line.split("\t"); - if (attributes.length < 5) { - // Error if fewer than 5 columns - System.err.println("WARNING: Line type not found. Skipping line " + lineNum + ": " + line); - continue; - } - - int tick = Integer.parseInt(attributes[0]); - - // Zero out unused voice markers - if (!Converter.PART) { - attributes[2] = "0"; - } - if (!Converter.STAFF) { - attributes[3] = "0"; - } - if (!Converter.VOICE) { - attributes[4] = "0"; - } - String xmlVoice = attributes[2] + "_" + attributes[3] + "_" + attributes[4]; - - if (!voiceMap.containsKey(xmlVoice)) { - voiceMap.put(xmlVoice, voiceMap.size()); - } - int voice = voiceMap.get(xmlVoice); - - lastTick = Math.max(tick, lastTick); - firstTick = Math.min(tick, firstTick); - - // Switch for different types of lines - switch (attributes[5]) { - // Attributes is the base line type describing time signature, tempo, etc. - case "attributes": - ticksPerQuarterNote = Integer.parseInt(attributes[6]); - - // Time signature - int tsNumerator = Integer.parseInt(attributes[9]); - int tsDenominator = Integer.parseInt(attributes[10]); - - int beatsPerBar = tsNumerator; - int subBeatsPerBeat = 2; - - int subBeatsPerQuarterNote = tsDenominator / 2; - - // Check for compound meter - if (beatsPerBar % 3 == 0 && beatsPerBar > 3) { - beatsPerBar /= 3; - subBeatsPerBeat = 3; - - subBeatsPerQuarterNote = tsDenominator / 4; - } - - int tatumsPerSubBeat = ticksPerQuarterNote / subBeatsPerQuarterNote; - - // Add the new time signature (if it is new) - Hierarchy mostRecent = hierarchies.isEmpty() ? null : hierarchies.get(hierarchies.size() - 1); - if (mostRecent == null || mostRecent.beatsPerBar != beatsPerBar || - mostRecent.subBeatsPerBeat != subBeatsPerBeat || mostRecent.tatumsPerSubBeat != tatumsPerSubBeat) { - hierarchyTicks.add(tick); - hierarchies.add(new Hierarchy(beatsPerBar, subBeatsPerBeat, tatumsPerSubBeat, 0, getTimeFromTick(tick))); - } - - // Key signature - int keyFifths = Integer.parseInt(attributes[7]); - String keyMode = attributes[8]; - - int tonic = ((7 * keyFifths) + 144) % 12; - boolean mode = keyMode.equalsIgnoreCase("Major"); - - // Add the new key (if it is new) - Key mostRecentKey = keys.isEmpty() ? null : keys.get(keys.size() - 1); - if (mostRecentKey == null || mostRecentKey.tonic != tonic || mostRecentKey.isMajor != mode) { - keys.add(new Key(tonic, mode, getTimeFromTick(tick))); - } - - break; - - case "rest": - // Handle anacrusis - if (!anacrusisHandled) { - anacrusisHandled = handleAnacrusis(Integer.parseInt(attributes[1]), tick); - } - break; - - case "chord": - // There are notes here - - // Handle anacrusis - if (!anacrusisHandled) { - anacrusisHandled = handleAnacrusis(Integer.parseInt(attributes[1]), tick); - } - - int duration = Integer.parseInt(attributes[6]); - lastTick = Math.max(tick + duration, lastTick); - - int tieInfo = Integer.parseInt(attributes[7]); - int numNotes = Integer.parseInt(attributes[8]); - - // Get all of the pitches - int[] pitches = new int[numNotes]; - for (int i = 0; i < numNotes; i++) { - try { - pitches[i] = getPitchFromString(attributes[9 + i]); - } catch (IOException e) { - System.err.println("WARNING: " + e.getMessage() + " Skipping line " + lineNum + ": " + line); - continue; - } - } - - // Handle each pitch - for (int pitch : pitches) { - switch (tieInfo) { - // No tie - case 0: - notes.add(new Note(pitch, getTimeFromTick(tick), getTimeFromTick(tick), getTimeFromTick(tick + duration), voice)); - break; - - // Tie out - case 1: - unfinishedNotes.add(new Note(pitch, getTimeFromTick(tick), getTimeFromTick(tick), getTimeFromTick(tick + duration), voice)); - break; - - // Tie in - case 2: - try { - Note matchedNote = findAndRemoveUnfinishedNote(pitch, getTimeFromTick(tick), voice); - notes.add(new Note(pitch, matchedNote.onsetTime, matchedNote.valueOnsetTime, getTimeFromTick(tick), voice)); - } catch (IOException e) { - System.err.println("WARNING: " + e.getMessage() + " Adding tied note as new note " + lineNum + ": " + line); - notes.add(new Note(pitch, getTimeFromTick(tick), getTimeFromTick(tick), getTimeFromTick(tick + duration), voice)); - } - break; - - // Tie in and out - case 3: - try { - Note matchedNote = findAndRemoveUnfinishedNote(pitch, getTimeFromTick(tick), voice); - unfinishedNotes.add(new Note(pitch, matchedNote.onsetTime, matchedNote.valueOnsetTime, getTimeFromTick(tick + duration), voice)); - } catch (IOException e) { - System.err.println("WARNING: " + e.getMessage() + " Skipping note on line " + lineNum + ": " + line); - unfinishedNotes.add(new Note(pitch, getTimeFromTick(tick), getTimeFromTick(tick), getTimeFromTick(tick + duration), voice)); - } - break; - - // ??? - default: - System.err.println("WARNING: Unknown tie type " + tieInfo + ". Skipping line " + lineNum + ": " + line); - break; - } - } - break; - - case "tremolo-m": - duration = Integer.parseInt(attributes[6]); - lastTick = Math.max(tick + duration, lastTick); - - numNotes = Integer.parseInt(attributes[8]); - - pitches = new int[numNotes]; - for (int i = 0; i < numNotes; i++) { - try { - pitches[i] = getPitchFromString(attributes[9 + i]); - } catch (IOException e) { - System.err.println("WARNING: " + e.getMessage() + " Skipping line " + lineNum + ": " + line); - continue; - } - } - - for (int pitch : pitches) { - // TODO: Decide how many notes to add here. i.e., default to eighth notes for now? - int ticksPerTremolo = ticksPerQuarterNote / 2; - int numTremolos = duration / ticksPerTremolo; - - - for (int i = 0; i < numTremolos; i++) { - notes.add(new Note(pitch, getTimeFromTick(tick + ticksPerTremolo * i), getTimeFromTick(tick + ticksPerTremolo * i), getTimeFromTick(tick + ticksPerTremolo * (i + 1)), voice)); - } - } - break; - - default: - System.err.println("WARNING: Unrecognized line type. Skipping line " + lineNum + ": " + line); - continue; - } - } - - in.close(); - - // Check for any unfinished notes (because of ties out). - for (Note note : unfinishedNotes) { - System.err.println("WARNING: Tie never ended for note " + note + ". Adding note as untied."); - notes.add(note); - } - } - - /** - * Handle any anacrusis, if possible. First, detect if at least 1 bar has finished. If it has, - * check how long the previous bar was, and set the first anacrusis according to that. - * - * @param bar The bar number of the current line. This will be compared to {@link #previousBar} - * to check if a bar has just finished. - * @param tick The tick of the current line. - * @return True if the anacrusis has now been handled. False otherwise. - */ - private boolean handleAnacrusis(int bar, int tick) { - if (previousBar == -1) { - // This is the first bar we've seen - previousBar = bar; - - } else if (previousBar != bar) { - // Ready to handle the anacrusis - - // Add a default 4/4 at time 0 if no hierarchy has been seen yet - if (hierarchies.isEmpty()) { - hierarchies.add(new Hierarchy(4, 2, ticksPerQuarterNote / 2, 0, 0)); - } - - if (hierarchies.size() != 1) { - System.err.println("Warning: More than 1 time signature seen in the first bar."); - } - - // Duplicate mostRecent, but with correct anacrusis (tick % tatumsPerBar) - Hierarchy mostRecent = hierarchies.get(hierarchies.size() - 1); - int tatumsPerBar = mostRecent.beatsPerBar * mostRecent.subBeatsPerBeat * mostRecent.tatumsPerSubBeat; - hierarchies.set(hierarchies.size() - 1, new Hierarchy(mostRecent.beatsPerBar, mostRecent.subBeatsPerBeat, - mostRecent.tatumsPerSubBeat, tick % tatumsPerBar, mostRecent.time)); - - return true; - } - - return false; - } - - /** - * Find a tied out note that matches a new tied in note, return it, and remove it from - * {@link #unfinishedNotes}. - * - * @param pitch The pitch of the tie. - * @param valueOnsetTime The onset time of the tied in note. - * @param voice The voice of the tied in note. - * - * @return The note from {@link #unfinishedNotes} that matches the pitch onset time and voice. - * @throws IOException If no matching note is found. - */ - private Note findAndRemoveUnfinishedNote(int pitch, int valueOnsetTime, int voice) throws IOException { - Iterator noteIterator = unfinishedNotes.iterator(); - while (noteIterator.hasNext()) { - Note note = noteIterator.next(); - - if (note.pitch == pitch && note.valueOffsetTime == valueOnsetTime && note.voice == voice) { - noteIterator.remove(); - return note; - } - } - - throw new IOException("Tied note not found at pitch=" + pitch + " offset=" + valueOnsetTime + " voice=" + voice + "."); - } - - /** - * Convert from XML tick to time, using {@link #MS_PER_BEAT}. - * - * @param tick The XML tick. - * @return The time, in milliseconds. - */ - private int getTimeFromTick(int tick) { - if (hierarchies.isEmpty()) { - return tick; - } - - int i; - for (i = 0; i < hierarchies.size() - 1; i++) { - if (hierarchyTicks.get(i + 1) > tick) { - break; - } - } - - Hierarchy hierarchy = hierarchies.get(i); - int hierarchyTick = hierarchyTicks.get(i); - - return hierarchy.time + (int) Math.round(((double) tick - hierarchyTick) / hierarchy.tatumsPerSubBeat / hierarchy.subBeatsPerBeat * MS_PER_BEAT); - } - - /** - * Create and return a list of tatums based on the parsed {@link #hierarchy}, {@link #firstTick}, and - * {@link #lastTick}. - * - * @return A list of the parsed tatums. - */ - private List getTatums() { - List tatums = new ArrayList(lastTick - firstTick); - - for (int tick = firstTick; tick < lastTick; tick++) { - tatums.add(new Tatum(getTimeFromTick(tick))); - } - - return tatums; - } - - /** - * Return a String version of the parsed musical score into our mv2h format. - * - * @return The parsed musical score. - */ - @Override - public String toString() { - StringBuilder sb = new StringBuilder(); - - for (Note note : notes) { - sb.append(note).append('\n'); - } - - for (Tatum tatum : getTatums()) { - sb.append(tatum).append('\n'); - } - - for (Key key : keys) { - sb.append(key).append('\n'); - } - - for (Hierarchy hierarchy : hierarchies) { - sb.append(hierarchy).append('\n'); - } - - return sb.toString(); - } - - /** - * Get the pitch number of a note given its String. - * - * @param pitchString A pitch String, like C##4, or Ab1, or G7. - * @return The number of the given pitch, with A4 = 440Hz = 69. - * - * @throws IOException If a parse error occurs. - */ - private static int getPitchFromString(String pitchString) throws IOException { - char pitchChar = pitchString.charAt(0); - int pitch; - switch (pitchChar) { - case 'C': - pitch = 0; - break; - - case 'D': - pitch = 2; - break; - - case 'E': - pitch = 4; - break; - - case 'F': - pitch = 5; - break; - - case 'G': - pitch = 7; - break; - - case 'A': - pitch = 9; - break; - - case 'B': - pitch = 11; - break; - - default: - throw new IOException("Pith " + pitchChar + " not recognized."); - } - - int accidental = pitchString.length() - pitchString.replace("#", "").length(); - accidental -= pitchString.length() - pitchString.replace("b", "").length(); - - int octave = Integer.parseInt(pitchString.substring(1).replace("#", "").replace("b", "")); - - return (octave + 1) * 12 + pitch + accidental; - } -} diff --git a/src/mv2h/tools/midi/TimeSignature.java b/src/mv2h/tools/midi/TimeSignature.java index 9270d3d..f298e10 100644 --- a/src/mv2h/tools/midi/TimeSignature.java +++ b/src/mv2h/tools/midi/TimeSignature.java @@ -5,11 +5,11 @@ /** * A TimeSignature represents some MIDI data's beat structure (time signature). * Equality is based only on the numerator and denominator. - * + * * @author Andrew McLeod - 11 Feb, 2015 */ public class TimeSignature { - + /** * The numerator used to signify an irregular meter. It can be used with any denominator (4, for example). */ @@ -19,34 +19,34 @@ public class TimeSignature { * The numerator of the time signature. */ private final int numerator; - + /** * The denominator of the time signature. */ private final int denominator; - + /** * Create a new default TimeSignature (4/4 time) */ public TimeSignature() { this(new byte[] {4, 2, 24, 8}); } - + /** * Create a new TimeSignature from the given data array. - * + * * @param data Data array, parsed directly from midi. */ public TimeSignature(byte[] data) { numerator = data[0]; denominator = (int) Math.pow(2, data[1]); } - + /** * Create a new TimeSignature with the given numerator and denominator. * Using this, {@link #metronomeTicksPerBeat} will be 24, and {@link #notes32PerQuarter} * will be 8. - * + * * @param numerator {@link #numerator} * @param denominator {@link #denominator} */ @@ -54,49 +54,49 @@ public TimeSignature(int numerator, int denominator) { this.numerator = numerator; this.denominator = denominator; } - + /** * Get the numerator of this time signature. - * + * * @return {@link #numerator} */ public int getNumerator() { return numerator; } - + /** * Get the denominator of this time signature. - * + * * @return {@link #denominator} */ public int getDenominator() { return denominator; } - + /** * Get the number of sub beats per quarter note. - * + * * @return The number of sub beats per quarter note. */ - public int getSubBeatsPerQuarter() { + public double getSubBeatsPerQuarter() { // Simple meter if (numerator <= 4 || numerator % 3 != 0) { - return denominator / 2; - + return ((double) denominator) / 2.0; + // Compound meter } else { - return denominator / 4; + return ((double) denominator) / 4.0; } } - + @Override public boolean equals(Object other) { if (!(other instanceof TimeSignature)) { return false; } - + TimeSignature ts = (TimeSignature) other; - + return getDenominator() == ts.getDenominator() && getNumerator() == ts.getNumerator(); } @@ -104,21 +104,21 @@ public boolean equals(Object other) { * Get the Hierarchy of this time signature. * @param time The time of this hierarchy. * @param anacrusisLengthSubBeats The length of this Hierarchy's anacrusis, in sub beats. - * + * * @return The Hierarchy of this time signature. */ public Hierarchy getHierarchy(int time, int anacrusisLengthSubBeats) { int beatsPerMeasure = numerator; int subBeatsPerBeat = 2; - + // Check for compound if (numerator > 3 && numerator % 3 == 0) { beatsPerMeasure = numerator / 3; - subBeatsPerBeat = 3; + subBeatsPerBeat = 3; } - + Hierarchy hierarchy = new Hierarchy(beatsPerMeasure, subBeatsPerBeat, 1, anacrusisLengthSubBeats, time); - + return hierarchy; } } \ No newline at end of file diff --git a/src/mv2h/tools/midi/TimeTracker.java b/src/mv2h/tools/midi/TimeTracker.java index 3a0978a..b346370 100644 --- a/src/mv2h/tools/midi/TimeTracker.java +++ b/src/mv2h/tools/midi/TimeTracker.java @@ -17,7 +17,7 @@ * A TimeTracker is able to interpret MIDI tempo, key, and time signature change events and keep track * of the song timing in seconds, instead of just using ticks as MIDI events do. It does this by using * a LinkedList of {@link TimeTrackerNode} objects. - * + * * @author Andrew McLeod - 23 October, 2014 */ public class TimeTracker { @@ -25,32 +25,32 @@ public class TimeTracker { * Pulses (ticks) per Quarter note, as in the current Midi song's header. */ private double PPQ = 120.0; - + /** * The LInkedList of TimeTrackerNodes of this TimeTracker, ordered by time. */ private final LinkedList nodes; - + /** * The number of sub beats which lie before the first full measure in this song. */ private int anacrusisLengthSubBeats; - + /** * The last tick for any event in this song, initially 0. */ private long lastTick = 0; - + /** * Create a new TimeTracker. */ public TimeTracker() { this(-1); } - + /** * Create a new TimeTracker with the given sub beat length. - * + * * @param subBeatLength {@link #subBeatLength} */ public TimeTracker(int subBeatLength) { @@ -58,106 +58,119 @@ public TimeTracker(int subBeatLength) { nodes = new LinkedList(); nodes.add(new TimeTrackerNode()); } - + /** * A TimeSignature event was detected. Deal with it. - * + * * @param event The event. * @param mm The message from the event. */ public void addTimeSignatureChange(MidiEvent event, MetaMessage mm) { - TimeSignature ts = new TimeSignature(mm.getData()); - + TimeSignature ts = new TimeSignature(mm.getData()); + + /*************** + * SPECIAL CASE + * ------------ + * Musescore3 outputs a single 1/1 time signature at the beginning of a MIDI file if the + * score had no time signature. + * + * This assumes any 1/1 time signature at time 0 is from that, and defaults instead to 4/4, + * like the MIDI standard. + ***************/ + if (event.getTick() == 0 && ts.getNumerator() == 1 && ts.getDenominator() == 1) { + ts = new TimeSignature(4, 4); + } + if (nodes.getLast().getStartTick() > event.getTick()) { return; } - + if (nodes.getLast().getStartTick() == event.getTick()) { // If we're at the same time as a prior time change, combine this with that node. nodes.getLast().setTimeSignature(ts); - + } else if (!ts.equals(nodes.getLast().getTimeSignature())) { // Some change has been made nodes.add(new TimeTrackerNode(nodes.getLast(), event.getTick(), PPQ)); nodes.getLast().setTimeSignature(ts); } - + nodes.getLast().setIsTimeSignatureDummy(false); } - + /** * A Tempo event was detected. Deal with it. - * + * * @param event The event. * @param mm The message from the event. */ public void addTempoChange(MidiEvent event, MetaMessage mm) { Tempo t = new Tempo(mm.getData()); - + if (nodes.getLast().getStartTick() > event.getTick()) { return; } - + if (nodes.getLast().getStartTick() == event.getTick()) { // If we're at the same time as a prior time change, combine this with that node. nodes.getLast().setTempo(t); - + } else if (!t.equals(nodes.getLast().getTempo())) { nodes.add(new TimeTrackerNode(nodes.getLast(), event.getTick(), PPQ)); nodes.getLast().setTempo(t); } } - + /** * A Key event was detected. Deal with it. - * + * * @param event The event. * @param mm The message from the event. */ public void addKeyChange(MidiEvent event, MetaMessage mm) { int numSharps = mm.getData()[0]; boolean major = mm.getData()[1] == 0; - + int keyNumber = (7 * numSharps + 100 * 12) % 12; Key ks = new Key(keyNumber, major); - + if (nodes.getLast().getStartTick() > event.getTick()) { return; } - + if (nodes.getLast().getStartTick() == event.getTick()) { // If we're at the same time as a prior time change, combine this with that node. nodes.getLast().setKey(ks); - + } else if (!ks.equals(nodes.getLast().getKey())) { nodes.add(new TimeTrackerNode(nodes.getLast(), event.getTick(), PPQ)); nodes.getLast().setKey(ks); } } - + /** * Returns the time in milliseconds at a given tick. - * + * * @param tick The tick number to calculate the time of. * @return The time of the given tick number, measured in milliseconds since time 0. */ public double getTimeAtTick(long tick) { return getNodeAtTick(tick).getTimeAtTick(tick, PPQ); } - + /** * Get the TimeTrackerNode which is valid at the given tick. - * + * * @param tick The tick. * @return The valid TimeTrackerNode. */ private TimeTrackerNode getNodeAtTick(long tick) { ListIterator iterator = nodes.listIterator(); - + TimeTrackerNode node = iterator.next(); while (iterator.hasNext()) { node = iterator.next(); - + if (node.getStartTick() > tick) { iterator.previous(); return iterator.previous(); @@ -166,26 +179,26 @@ private TimeTrackerNode getNodeAtTick(long tick) { return node; } - + /** * Get a List of all of the key signatures of this TimeTracker. - * + * * @return A List of all of the key signatures of this TimeTracker. */ public List getAllKeySignatures() { List keys = new ArrayList(); - + for (TimeTrackerNode node : nodes) { Key key = node.getKey(); - + // First key if (keys.isEmpty()) { keys.add(key); - + // Duplicate time } else if (keys.get(keys.size() - 1).time == key.time) { keys.set(keys.size() - 1, key); - + // Check if keys are equal } else { Key oldKey = keys.get(keys.size() - 1); @@ -194,31 +207,31 @@ public List getAllKeySignatures() { } } } - + return keys; } - + /** * Get the Meter of this piece, according to this time tracker. - * + * * @return The meter of this piece. */ public Meter getMeter() { Meter meter = new Meter(); - + // Add Hierarchies Hierarchy current = null; for (TimeTrackerNode node : nodes) { current = node.getTimeSignature().getHierarchy((int) node.getStartTime(), node.getStartTime() == 0 ? anacrusisLengthSubBeats : 0); - + // First hierarchy if (meter.getHierarchies().isEmpty()) { meter.addHierarchy(current); - + // Same time -- overwrite } else if (meter.getHierarchies().get(meter.getHierarchies().size() - 1).time == current.time) { meter.addHierarchy(current); - + // New time -- add if changed } else { Hierarchy old = meter.getHierarchies().get(meter.getHierarchies().size() - 1); @@ -227,49 +240,49 @@ public Meter getMeter() { } } } - + // Add Tatums (sub-beats) double propFinished = 1.0; for (int i = 1; i < nodes.size(); i++) { TimeTrackerNode prevNode = nodes.get(i - 1); TimeTrackerNode nextNode = nodes.get(i); - + for (Tatum tatum : prevNode.getSubBeatsUntil(propFinished, nextNode.getStartTime())) { meter.addTatum(tatum); } propFinished = prevNode.getPropFinished(propFinished, nextNode.getStartTime()); } - + // Add last tatums TimeTrackerNode lastNode = nodes.get(nodes.size() - 1); for (Tatum tatum : lastNode.getSubBeatsUntil(propFinished, lastNode.getTimeAtTick(lastTick, PPQ))) { meter.addTatum(tatum); } - + return meter; } - + /** * Set the anacrusis length of this song to the given number of sub beats. - * + * * @param ticks The anacrusis length of this song, measured in sub beats. */ public void setAnacrusis(int length) { anacrusisLengthSubBeats = length; } - + /** * Set the last tick for this song to the given value. - * + * * @param lastTick {@link #lastTick} */ public void setLastTick(long lastTick) { this.lastTick = lastTick; } - + /** * Set the PPQ for this TimeTracker. - * + * * @param ppq {@link #PPQ} */ public void setPPQ(double ppq) {