diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b49dc53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# For the love of God, don't track the actual music piece files themselves. +pieces/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6572aa7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2011 by Ben Kurtovic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5398120 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +__MusicQuizzer__ (_musicquizzer_) is a Python program that can help you prepare +for any test that involves listening to excerpts of music pieces and answering +multiple choice questions about them. + +# Installation + +## Mac OS X + +Get [MacPorts](http://www.macports.org/install.php), if you don't have it. + +From Terminal (`/Applications/Utiliies/Terminal`), do: + + sudo port install python26 + sudo port install py26-game + +Next, +[download MusicQuizzer](https://github.com/earwig/music-quizzer/tarball/v0.1) +and uncompress it. Move the folder wherever you want (keep its contents +intact!) and double-click on `mac_osx.sh` to use the quizzer. + +## Windows + +MusicQuizzer is written in Python, a language that does not come with Windows +by default. Download the latest version of Python 2.7.x +[here](http://python.org/download/) (_not_ Python 3). Use the default settings +during installation. + +Next, download and install pygame from +[here](http://pygame.org/ftp/pygame-1.9.2a0.win32-py2.7.msi). + +Finally, +[download MusicQuizzer](https://github.com/earwig/music-quizzer/zipball/v0.1) +and extract it wherever you want. To use, simply double-click on the +`musicquizzer` file inside (do not move or delete any of the other files). + +## Linux (with apt-get) + +You should be on at least Python 2.7 (check with `python --version`), assuming +you keep your operating system up-to-date. Install the latest versions of +pygame and tk with: + + sudo apt-get install python-pygame python-tk + +Then, +[download MusicQuizzer](https://github.com/earwig/music-quizzer/tarball/v0.1) +and execute the program with `python musicquizzer.py` from your terminal. + +# Usage + +The first time you start the program, it will download all of the 25 necessary +(default) music pieces to the `pieces` folder. This is a ~70 MB download. + +MusicQuizzer will present you with an answer sheet, containing four or five +multiple choice questions per piece (which are, of course, randomized every +time you begin a new quiz). Press `Start Quiz` to begin listening to the +excerpts. Each one is played for 30 seconds. You are then given five seconds of +rest, followed by the next piece. After all excerpts have been played, you +_cannot_ re-listen to them. Press `Submit Answers` to "hand in" your quiz and +view the results. + +# Modifying + +The music pieces are located in `pieces/`, in `.mp3` format. The file +`config.cfg` contains the information for each excerpt, like so: + + [10.mp3] + title: Der Erlkönig + composer: Franz Schubert + era: Romantic + genre: Lied + form: Through-composed + url: http://stuy.enschool.org/music/10_The_Erlking_Erlkonig.mp3 + +...and so-on. The section's header is the name of the file in `pieces/` (or +whatever directory you have chosen), and the fields hold the information that +MusicQuizzer will use to generate questions. The exception is the `url` field, +which is the _direct_ URL that MusicQuizzer will use to download the piece if +it does not have a file with that name. + +Feel free to rename any of the pieces, delete them, add totally new ones, or +change their information. This program is designed to be customizable. + +In the config file, you can also change the length of time each excerpt is +played for, the time between each excerpt, and other things. If an attribute is +not defined for a certain piece, the quizzer will not ask the question in that +excerpt, but the question will remain for other pieces. diff --git a/config.cfg b/config.cfg new file mode 100644 index 0000000..b7ac9c2 --- /dev/null +++ b/config.cfg @@ -0,0 +1,205 @@ +; This file contains some configuration variables for MusicQuizzer that you can +; change if desired. Comments begin with a ; character. This file must be +; included with MusicQuizzer in order for it to work correctly. + +[general] +; Length of time each excerpt is played for. Longer ones will be cut short, and +; shorter ones will be played until their end: +excerpt_length: 30 + +; Length of the silent break in between pieces: +break_length: 5 + +; Number of answer choices per question: +answers: 6 + +; Directory where pieces are stored, relative to the script or absolute: +piece_dir: pieces/ + +; Website to get music pieces from: +base_url: http://stuy.enschool.org + +; Data for each piece follows. Section name is the (full, with extension) +; filename of the piece in pieces/. + +[01.mp3] +title: Toccata and Fugue in D minor +composer: Johann Sebastian Bach +era: Baroque +genre: Fugue +url: /music/1_Toccata_Fugue_In_D_Minor.mp3 + +[02.mp3] +title: Brandenburg Concerto No. 2 +composer: Johann Sebastian Bach +era: Baroque +genre: Concerto grosso +url: /music/2_Brandenburg_Concerto_2.mp3 + +[03.mp3] +title: Hornpipe from Water Music Suite +composer: George Frideric Handel +era: Baroque +genre: Suite +url: /music/3_Hornpipe_From_Water_Music_Suite.mp3 + +[04.mp3] +title: Spring from The Four Seasons +composer: Antonio Vivaldi +era: Baroque +genre: Concerto +url: /music/4_Spring_From_Four_Seasons_Movement_1.mp3 + +[05.mp3] +title: Trumpet Concerto in E-flat major +composer: Joseph Haydn +era: Classical +genre: Concerto +url: /music/5_Trumpet_Concerto_In_E_Flat_Major_Movement_3.mp3 + +[06.mp3] +title: Piano Concerto No. 20 +composer: Wolfgang Amadeus Mozart +era: Classical +genre: Concerto +url: /music/6_Piano_Concerto_20_In_D_Minor_Movement_1.mp3 + +[07.mp3] +title: Symphony No. 40 +composer: Wolfgang Amadeus Mozart +era: Classical +genre: Symphony +url: /music/7_Symphony_40_In_G_Minor_Movement_1.mp3 + +[08.mp3] +title: Symphony No. 5 +composer: Ludwig van Beethoven +era: Transitional +genre: Symphony +url: /music/8_Symphony_5_In_C_Minor_Fate_Movement_1.mp3 + +[09.mp3] +title: Symphony No. 9 +composer: Ludwig van Beethoven +era: Transitional +genre: Choral symphony +url: /music/9_Symphony_9_In_D_Minor_Choral_With_Verses_From_Ode_To_Joy_Movement_4.mp3 + +[10.mp3] +title: Der Erlkönig +composer: Franz Schubert +era: Romantic +genre: Lied +form: Through-composed +url: /music/10_The_Erlking_Erlkonig.mp3 + +[11.mp3] +title: Symphony No. 8: Unfinished +composer: Franz Schubert +era: Romantic +genre: Symphony +form: Sonata-allegro +url: /music/11_Symphony_8_Unfinished.mp3 + +[12.mp3] +title: Symphony No. 3 +composer: Johannes Brahms +era: Romantic +genre: Symphony +form: Ternary +url: /music/12_Symphony_3_Movement_1.mp3 + +[13.mp3] +title: Violin Concerto in E minor +composer: Felix Mendelssohn +era: Romantic +genre: Concerto +form: Sonata-allegro +url: /music/13_Violin_Concerto_Movement_1.mp3 + +[14.mp3] +title: Symphonie Fantastique +composer: Hector Berlioz +era: Romantic +genre: Program symphony +url: /music/14_Symphonie_Fantastique_Movement_4.mp3 + +[15.mp3] +title: The Moldau +composer: Bedrich Smetana +era: Romantic +genre: Symphonic poem +form: Episodic +url: /music/15_The_Moldau.mp3 + +[16.mp3] +title: 1812 Overture +composer: Piotr Tchaikovsky +era: Romantic +genre: Concert overture +form: Sonata-allegro +url: /music/16_Overture_1812.mp3 + +[17.mp3] +title: Revolutionary Etude +composer: Frederic Chopin +era: Romantic +genre: Etude +url: /music/17_Revolutionary_Etude.mp3 + +[18.mp3] +title: Dies Irae from Requiem +composer: Giuseppe Verdi +era: Romantic +genre: Requiem +url: /music/18_Requiem_Dies_Irae.mp3 + +[19.mp3] +title: Ride of the Valkyries +composer: Richard Wagner +era: Romantic +genre: Opera +url: /music/19_Ride_Of_The_Valkyries.mp3 + +[20.mp3] +title: Vissi d'arte from Tosca +composer: Giaccomo Puccini +era: Romantic +genre: Opera +url: /music/20_Vissi_Darte_From_Tosca.mp3 + +[21.mp3] +title: Prelude to the Afternoon of a Faun +composer: Claude Debussy +era: Impressionism +genre: Symphonic poem +url: /music/21_Prelude_To_The_Afternoon_Of_A_Faun.mp3 + +[22.mp3] +title: Rite of Spring +composer: Igor Stravinsky +era: 20th Century +genre: Ballet +url: /music/22_Rite_Of_Spring.mp3 + +[23.mp3] +title: Hoedown from Rodeo +composer: Aaron Copland +era: 20th Century +genre: Ballet +form: Ternary +url: /music/23_Hoedown_From_Rodeo.mp3 + +[24.mp3] +title: Adagio for Strings +composer: Samuel Barber +era: 20th Century +form: Through-composed +url: /music/24_Adagio_For_Strings.mp3 + +[25.mp3] +title: Short Ride in a Fast Machine +composer: John Adams +era: 20th Century +genre: Minimalism +url: /music/25_Short_Ride_In_A_Fast_Machine.mp3 diff --git a/mac_osx.sh b/mac_osx.sh new file mode 100755 index 0000000..21948ac --- /dev/null +++ b/mac_osx.sh @@ -0,0 +1,2 @@ +#! /bin/bash +python2.6 ./musicquizzer.py diff --git a/musicquizzer.py b/musicquizzer.py new file mode 100644 index 0000000..bb42968 --- /dev/null +++ b/musicquizzer.py @@ -0,0 +1,410 @@ +#! /usr/bin/python +# -*- coding: utf-8 -*- + +""" +MusicQuizzer is a Python program that can help you prepare for any test that +involves listening to excerpts of music pieces and answering multiple choice +questions about them. For more information, see the included README.md. +""" + +from __future__ import division + +import ConfigParser as configparser +import os +from pygame import mixer, error +import random +import thread +import time +from Tkinter import * +from tkFont import Font +from urllib import urlretrieve + +__author__ = "Ben Kurtovic" +__copyright__ = "Copyright (c) 2011 by Ben Kurtovic" +__license__ = "MIT License" +__version__ = "0.1" +__email__ = "ben.kurtovic@verizon.net" + +config_filename = "config.cfg" +config = None +piece_dir = None +download_complete = False + +master_width = 500 +master_height = 500 +question_height = 100 + +class AnswerSheet(object): + def __init__(self, master): + self.master = master + self.order = generate_piece_order() + + self.init_widgets() + self.generate_questions() + self.grid_questions() + + def init_widgets(self): + self.scroll = Scrollbar(self.master) + self.scroll.grid(row=1, column=1, sticky=N+S) + + self.canvas = Canvas(self.master, yscrollcommand=self.scroll.set, + width=master_width, height=master_height-30) + self.canvas.grid(row=1, column=0, sticky=N+S+E+W) + self.canvas.grid_propagate(0) + + self.scroll.config(command=self.canvas.yview) + + self.master.grid_rowconfigure(0, weight=1) + self.master.grid_columnconfigure(0, weight=1) + + self.frame = Frame(self.canvas) + self.frame.rowconfigure(1, weight=1) + self.frame.columnconfigure(1, weight=1) + + self.header = Frame(self.master, bd=2, relief="groove") + self.header.grid(row=0, column=0, columnspan=2) + + self.header_buttons = Frame(self.header, width=250, height=30) + self.header_buttons.grid_propagate(0) + self.header_buttons.grid(row=0, column=0) + + self.playing_container = Frame(self.header, width=master_width-240, height=30) + self.playing_container.grid_propagate(0) + self.playing_container.grid(row=0, column=1) + + self.play_button = Button(self.header_buttons, text="Start Quiz", + command=self.play) + self.play_button.grid(row=0, column=0) + + self.submit_button = Button(self.header_buttons, text="Submit Answers", + command=self.submit) + self.submit_button.grid(row=0, column=1) + + self.playing = StringVar() + self.playing.set("Now Playing: None") + self.now_playing = Label(self.playing_container, textvar=self.playing) + self.now_playing.grid(row=0, column=0) + + def generate_questions(self): + num_answers = config.getint("general", "answers") + answer_choices = {} # dict of {category1: {choice1, choice2...}, ...} + questions = {} # dict of {piece1: [question1, question2...], ...} + self.number_of_questions = 0 + + for piece in self.order: + for category, answer_choice in config.items(piece): + if category == "url": + continue + try: + answer_choices[category].add(answer_choice) + except KeyError: + answer_choices[category] = set([answer_choice]) + + for piece in self.order: + questions[piece] = dict() + for category in config.options(piece): + if category == "url": + continue + correct_choice = config.get(piece, category) + questions[piece][category] = [correct_choice] + + all_choices = list(answer_choices[category]) + all_choices.remove(correct_choice) + + for x in range(num_answers - 1): + try: + choice = random.choice(all_choices) + except IndexError: + break # if there aren't enough choices in the choice + # bank, there will be fewer answer choices than + # we want, but what else can we do? + all_choices.remove(choice) + questions[piece][category].append(choice) + + question_choices = questions[piece][category] + questions[piece][category] = randomize_list(question_choices) + self.number_of_questions += 1 + + self.questions = questions + + def grid_questions(self): + self.answers = {} + self.stuff_to_disable = [] # what gets turned off when we press submit? + question_grid = Frame(self.frame) + this_row_number = 0 + + excerpt = "A" + + for piece in self.order: + question_row_number = 1 + + piece_questions = self.questions[piece].keys() + piece_questions.reverse() # correct ordering issues + + self.answers[piece] = {} + + height = question_height * len(piece_questions) + 20 + piece_grid = Frame(question_grid, width=master_width, + height=height) + + title = Label(piece_grid, text="Excerpt {0}".format(excerpt), + font=Font(family="Verdana", size=10, weight="bold")) + excerpt = chr(ord(excerpt) + 1) # increment excerpt by 1 letter + title.grid(row=0, column=0, columnspan=3) + + for question in piece_questions: + agrid = LabelFrame(piece_grid, text=question.capitalize(), + width=master_width, height=question_height) + + a = StringVar() + self.answers[piece][question] = a + + w = (master_width / 2) - 4 + lhgrid = Frame(agrid, width=w, height=question_height) + rhgrid = Frame(agrid, width=w, height=question_height) + + this_row = 0 + left_side = True + + for choice in self.questions[piece][question]: + if left_side: + r = Radiobutton(lhgrid, text=choice, + value=choice, variable=a) + left_side = False + else: + r = Radiobutton(rhgrid, text=choice, + value=choice, variable=a) + left_side = True + this_row += 1 + r.grid(row=this_row, column=0, sticky=W) + self.stuff_to_disable.append(r) + + lhgrid.grid_propagate(0) + lhgrid.grid(row=0, column=1) + + rhgrid.grid_propagate(0) + rhgrid.grid(row=0, column=2) + + agrid.grid_propagate(0) + agrid.grid(row=question_row_number, column=0) + question_row_number += 1 + + piece_grid.grid_propagate(0) + piece_grid.grid(row=this_row_number, column=0) + this_row_number += 1 + + question_grid.grid(row=0, column=0) + + def play(self): + self.play_button.configure(state=DISABLED) + thread.start_new_thread(self.play_pieces, ()) + + def play_pieces(self): + self.excerpt_length = (config.getfloat("general", "excerpt_length") - 5) * 1000 + break_length = config.getfloat("general", "break_length") + cur_excerpt = "A" + + mixer.init() + for piece in self.order: + try: + self.playing.set("Now Playing: Excerpt {0}".format(cur_excerpt)) + before = time.time() + self.play_piece(piece) + after = time.time() + retries = 1 + while after - before < 3 or retries >= 100: # if the piece + before = time.time() # played for less than 3 seconds, + self.play_piece(piece) # assume something went wrong + after = time.time() # loading and try to replay it, + retries += 1 # but don't get stuck in a loop if + # we legitimately can't play it + self.playing.set("That was Excerpt {0}...".format(cur_excerpt)) + cur_excerpt = chr(ord(cur_excerpt) + 1) + time.sleep(break_length) + except error: # Someone quit our mixer? STOP EVERYTHING. + break + + self.playing.set("Finished playing.") + + def play_piece(self, piece): + mixer.music.load(os.path.join(piece_dir, piece)) + mixer.music.play() + + fadeout_enabled = False + while mixer.music.get_busy(): + if mixer.music.get_pos() >= self.excerpt_length: + if not fadeout_enabled: + mixer.music.fadeout(5000) + fadeout_enabled = True + time.sleep(1) + + def submit(self): + self.submit_button.configure(state=DISABLED) + self.play_button.configure(state=DISABLED) + for item in self.stuff_to_disable: + item.configure(state=DISABLED) + + try: + mixer.quit() + except error: # pygame.error + pass # music was never played, so we can't stop it + + right = 0 + wrong = [] + + excerpt = "A" + for piece in self.order: + questions = self.questions[piece].keys() + questions.reverse() # correct question ordering + for question in questions: + correct_answer = config.get(piece, question) + given_answer = self.answers[piece][question].get() + if given_answer == u"Der Erlk\xf6nig": # unicode bugfix + given_answer = "Der Erlk\xc3\xb6nig" + if correct_answer == given_answer: + right += 1 + else: + wrong.append((excerpt, config.get(piece, "title"), + question, given_answer, correct_answer)) + excerpt = chr(ord(excerpt) + 1) + + results = Toplevel() # make a new window to display results + results.title("Results") + + noq = self.number_of_questions + text = "{0} of {1} answered correctly ({2}%):".format(right, noq, + round((right / noq) * 100, 2)) + + if right == noq: + text += "\n\nCongratulations, you got everything right!" + + else: + text += "\n" + for excerpt, title, question, given_answer, correct_answer in wrong: + if not given_answer: + if question == "title": + text += "\nYou left the title of Excerpt {0} blank; it's \"{1}\".".format( + excerpt, correct_answer) + else: + text += "\nYou left the {0} of \"{1}\" blank; it's {2}.".format( + question, title, correct_answer) + elif question == "title": + text += "\nExcerpt {0} was {1}, not {2}.".format( + excerpt, correct_answer, given_answer) + else: + text += "\nThe {0} of \"{1}\" is {2}, not {3}.".format( + question, title, correct_answer, given_answer) + + label = Label(results, text=text, justify=LEFT, padx=15, pady=10, + font=Font(family="Verdana", size=8)) + label.pack() + + +def randomize_list(old): + new = [] + while old: + obj = random.choice(old) + new.append(obj) + old.remove(obj) + return new + +def generate_piece_order(): + pieces = config.sections() + pieces.remove("general") # remove config section that is not a piece + return randomize_list(pieces) + +def load_config(): + global config, piece_dir + config = configparser.SafeConfigParser() + config.read(config_filename) + if not config.has_section("general"): + exit("Your config file is missing or malformed.") + + piece_dir = os.path.abspath(config.get("general", "piece_dir")) + +def get_missing_pieces(root): + pieces = config.sections() + pieces.remove("general") + missing_pieces = [] + + for piece in pieces: + if not os.path.exists(os.path.join(piece_dir, piece)): + missing_pieces.append(piece) + + if missing_pieces: + window = Toplevel() + window.title("PyQuizzer") + window.protocol("WM_DELETE_WINDOW", root.destroy) + + status = StringVar() + status.set("I'm missing {0} music ".format(len(missing_pieces)) + + "pieces;\nwould you like me to download them for you now?") + + head_label = Label(window, text="Download Music Pieces", font=Font( + family="Verdana", size=10, weight="bold")) + head_label.grid(row=0, column=0, columnspan=2) + + status_label = Label(window, textvar=status, justify=LEFT, padx=15, + pady=10) + status_label.grid(row=1, column=0, columnspan=2) + + quit_button = Button(window, text="Quit", command=lambda: exit()) + quit_button.grid(row=2, column=0) + + dl_button = Button(window, text="Download", + command=lambda: do_pieces_download(missing_pieces, status, + dl_button, status_label, window)) + dl_button.grid(row=2, column=1) + + window.mainloop() + + else: + global download_complete + download_complete = True + +def do_pieces_download(pieces, status, dl_button, status_label, window): + global download_complete + dl_button.configure(state=DISABLED) + + counter = 1 + for piece in pieces: + url = config.get("general", "base_url") + config.get(piece, "url") + name = "{0} of {1}: {2}".format(counter, len(pieces), + config.get(piece, "title")) + urlretrieve(url, os.path.join(piece_dir, piece), + lambda x, y, z: progress(x, y, z, name, status, status_label)) + counter += 1 + + window.quit() + window.withdraw() + download_complete = True + +def progress(count, block_size, total_size, name, status, label): + percent = int(count * block_size * 100 / total_size) + status.set("Downloading pieces...\n" + name + ": %2d%%" % percent) + label.update_idletasks() + +def run(): + root = Tk() + root.withdraw() + + load_config() + get_missing_pieces(root) + + while not download_complete: + time.sleep(0.5) + + window = Toplevel() + window.title("MusicQuizzer") + answer_sheet = AnswerSheet(window) + answer_sheet.canvas.create_window(0, 0, anchor=NW, + window=answer_sheet.frame) + answer_sheet.frame.update_idletasks() + answer_sheet.canvas.config(scrollregion=answer_sheet.canvas.bbox("all")) + + window.protocol("WM_DELETE_WINDOW", root.destroy) # make the 'x' in the + # corner quit the entire program, not just this window + window.mainloop() + +if __name__ == "__main__": + run()