A Tk-based program that can help you prepare for your music final with randomly-generated listening quizzes
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

411 lines
15 KiB

  1. #! /usr/bin/python
  2. # -*- coding: utf-8 -*-
  3. """
  4. MusicQuizzer is a Python program that can help you prepare for any test that
  5. involves listening to excerpts of music pieces and answering multiple choice
  6. questions about them. For more information, see the included README.md.
  7. """
  8. from __future__ import division
  9. import ConfigParser as configparser
  10. import os
  11. from pygame import mixer, error
  12. import random
  13. import thread
  14. import time
  15. from Tkinter import *
  16. from tkFont import Font
  17. from urllib import urlretrieve
  18. __author__ = "Ben Kurtovic"
  19. __copyright__ = "Copyright (c) 2011 by Ben Kurtovic"
  20. __license__ = "MIT License"
  21. __version__ = "0.1"
  22. __email__ = "ben.kurtovic@verizon.net"
  23. config_filename = "config.cfg"
  24. config = None
  25. piece_dir = None
  26. download_complete = False
  27. master_width = 500
  28. master_height = 500
  29. question_height = 100
  30. class AnswerSheet(object):
  31. def __init__(self, master):
  32. self.master = master
  33. self.order = generate_piece_order()
  34. self.init_widgets()
  35. self.generate_questions()
  36. self.grid_questions()
  37. def init_widgets(self):
  38. self.scroll = Scrollbar(self.master)
  39. self.scroll.grid(row=1, column=1, sticky=N+S)
  40. self.canvas = Canvas(self.master, yscrollcommand=self.scroll.set,
  41. width=master_width, height=master_height-30)
  42. self.canvas.grid(row=1, column=0, sticky=N+S+E+W)
  43. self.canvas.grid_propagate(0)
  44. self.scroll.config(command=self.canvas.yview)
  45. self.master.grid_rowconfigure(0, weight=1)
  46. self.master.grid_columnconfigure(0, weight=1)
  47. self.frame = Frame(self.canvas)
  48. self.frame.rowconfigure(1, weight=1)
  49. self.frame.columnconfigure(1, weight=1)
  50. self.header = Frame(self.master, bd=2, relief="groove")
  51. self.header.grid(row=0, column=0, columnspan=2)
  52. self.header_buttons = Frame(self.header, width=250, height=30)
  53. self.header_buttons.grid_propagate(0)
  54. self.header_buttons.grid(row=0, column=0)
  55. self.playing_container = Frame(self.header, width=master_width-240, height=30)
  56. self.playing_container.grid_propagate(0)
  57. self.playing_container.grid(row=0, column=1)
  58. self.play_button = Button(self.header_buttons, text="Start Quiz",
  59. command=self.play)
  60. self.play_button.grid(row=0, column=0)
  61. self.submit_button = Button(self.header_buttons, text="Submit Answers",
  62. command=self.submit)
  63. self.submit_button.grid(row=0, column=1)
  64. self.playing = StringVar()
  65. self.playing.set("Now Playing: None")
  66. self.now_playing = Label(self.playing_container, textvar=self.playing)
  67. self.now_playing.grid(row=0, column=0)
  68. def generate_questions(self):
  69. num_answers = config.getint("general", "answers")
  70. answer_choices = {} # dict of {category1: {choice1, choice2...}, ...}
  71. questions = {} # dict of {piece1: [question1, question2...], ...}
  72. self.number_of_questions = 0
  73. for piece in self.order:
  74. for category, answer_choice in config.items(piece):
  75. if category == "url":
  76. continue
  77. try:
  78. answer_choices[category].add(answer_choice)
  79. except KeyError:
  80. answer_choices[category] = set([answer_choice])
  81. for piece in self.order:
  82. questions[piece] = dict()
  83. for category in config.options(piece):
  84. if category == "url":
  85. continue
  86. correct_choice = config.get(piece, category)
  87. questions[piece][category] = [correct_choice]
  88. all_choices = list(answer_choices[category])
  89. all_choices.remove(correct_choice)
  90. for x in range(num_answers - 1):
  91. try:
  92. choice = random.choice(all_choices)
  93. except IndexError:
  94. break # if there aren't enough choices in the choice
  95. # bank, there will be fewer answer choices than
  96. # we want, but what else can we do?
  97. all_choices.remove(choice)
  98. questions[piece][category].append(choice)
  99. question_choices = questions[piece][category]
  100. questions[piece][category] = randomize_list(question_choices)
  101. self.number_of_questions += 1
  102. self.questions = questions
  103. def grid_questions(self):
  104. self.answers = {}
  105. self.stuff_to_disable = [] # what gets turned off when we press submit?
  106. question_grid = Frame(self.frame)
  107. this_row_number = 0
  108. excerpt = "A"
  109. for piece in self.order:
  110. question_row_number = 1
  111. piece_questions = self.questions[piece].keys()
  112. piece_questions.reverse() # correct ordering issues
  113. self.answers[piece] = {}
  114. height = question_height * len(piece_questions) + 20
  115. piece_grid = Frame(question_grid, width=master_width,
  116. height=height)
  117. title = Label(piece_grid, text="Excerpt {0}".format(excerpt),
  118. font=Font(family="Verdana", size=10, weight="bold"))
  119. excerpt = chr(ord(excerpt) + 1) # increment excerpt by 1 letter
  120. title.grid(row=0, column=0, columnspan=3)
  121. for question in piece_questions:
  122. agrid = LabelFrame(piece_grid, text=question.capitalize(),
  123. width=master_width, height=question_height)
  124. a = StringVar()
  125. self.answers[piece][question] = a
  126. w = (master_width / 2) - 4
  127. lhgrid = Frame(agrid, width=w, height=question_height)
  128. rhgrid = Frame(agrid, width=w, height=question_height)
  129. this_row = 0
  130. left_side = True
  131. for choice in self.questions[piece][question]:
  132. if left_side:
  133. r = Radiobutton(lhgrid, text=choice,
  134. value=choice, variable=a)
  135. left_side = False
  136. else:
  137. r = Radiobutton(rhgrid, text=choice,
  138. value=choice, variable=a)
  139. left_side = True
  140. this_row += 1
  141. r.grid(row=this_row, column=0, sticky=W)
  142. self.stuff_to_disable.append(r)
  143. lhgrid.grid_propagate(0)
  144. lhgrid.grid(row=0, column=1)
  145. rhgrid.grid_propagate(0)
  146. rhgrid.grid(row=0, column=2)
  147. agrid.grid_propagate(0)
  148. agrid.grid(row=question_row_number, column=0)
  149. question_row_number += 1
  150. piece_grid.grid_propagate(0)
  151. piece_grid.grid(row=this_row_number, column=0)
  152. this_row_number += 1
  153. question_grid.grid(row=0, column=0)
  154. def play(self):
  155. self.play_button.configure(state=DISABLED)
  156. thread.start_new_thread(self.play_pieces, ())
  157. def play_pieces(self):
  158. self.excerpt_length = (config.getfloat("general", "excerpt_length") - 5) * 1000
  159. break_length = config.getfloat("general", "break_length")
  160. cur_excerpt = "A"
  161. mixer.init()
  162. for piece in self.order:
  163. try:
  164. self.playing.set("Now Playing: Excerpt {0}".format(cur_excerpt))
  165. before = time.time()
  166. self.play_piece(piece)
  167. after = time.time()
  168. retries = 1
  169. while after - before < 3 or retries >= 100: # if the piece
  170. before = time.time() # played for less than 3 seconds,
  171. self.play_piece(piece) # assume something went wrong
  172. after = time.time() # loading and try to replay it,
  173. retries += 1 # but don't get stuck in a loop if
  174. # we legitimately can't play it
  175. self.playing.set("That was Excerpt {0}...".format(cur_excerpt))
  176. cur_excerpt = chr(ord(cur_excerpt) + 1)
  177. time.sleep(break_length)
  178. except error: # Someone quit our mixer? STOP EVERYTHING.
  179. break
  180. self.playing.set("Finished playing.")
  181. def play_piece(self, piece):
  182. mixer.music.load(os.path.join(piece_dir, piece))
  183. mixer.music.play()
  184. fadeout_enabled = False
  185. while mixer.music.get_busy():
  186. if mixer.music.get_pos() >= self.excerpt_length:
  187. if not fadeout_enabled:
  188. mixer.music.fadeout(5000)
  189. fadeout_enabled = True
  190. time.sleep(1)
  191. def submit(self):
  192. self.submit_button.configure(state=DISABLED)
  193. self.play_button.configure(state=DISABLED)
  194. for item in self.stuff_to_disable:
  195. item.configure(state=DISABLED)
  196. try:
  197. mixer.quit()
  198. except error: # pygame.error
  199. pass # music was never played, so we can't stop it
  200. right = 0
  201. wrong = []
  202. excerpt = "A"
  203. for piece in self.order:
  204. questions = self.questions[piece].keys()
  205. questions.reverse() # correct question ordering
  206. for question in questions:
  207. correct_answer = config.get(piece, question)
  208. given_answer = self.answers[piece][question].get()
  209. if given_answer == u"Der Erlk\xf6nig": # unicode bugfix
  210. given_answer = "Der Erlk\xc3\xb6nig"
  211. if correct_answer == given_answer:
  212. right += 1
  213. else:
  214. wrong.append((excerpt, config.get(piece, "title"),
  215. question, given_answer, correct_answer))
  216. excerpt = chr(ord(excerpt) + 1)
  217. results = Toplevel() # make a new window to display results
  218. results.title("Results")
  219. noq = self.number_of_questions
  220. text = "{0} of {1} answered correctly ({2}%):".format(right, noq,
  221. round((right / noq) * 100, 2))
  222. if right == noq:
  223. text += "\n\nCongratulations, you got everything right!"
  224. else:
  225. text += "\n"
  226. for excerpt, title, question, given_answer, correct_answer in wrong:
  227. if not given_answer:
  228. if question == "title":
  229. text += "\nYou left the title of Excerpt {0} blank; it's \"{1}\".".format(
  230. excerpt, correct_answer)
  231. else:
  232. text += "\nYou left the {0} of \"{1}\" blank; it's {2}.".format(
  233. question, title, correct_answer)
  234. elif question == "title":
  235. text += "\nExcerpt {0} was {1}, not {2}.".format(
  236. excerpt, correct_answer, given_answer)
  237. else:
  238. text += "\nThe {0} of \"{1}\" is {2}, not {3}.".format(
  239. question, title, correct_answer, given_answer)
  240. label = Label(results, text=text, justify=LEFT, padx=15, pady=10,
  241. font=Font(family="Verdana", size=8))
  242. label.pack()
  243. def randomize_list(old):
  244. new = []
  245. while old:
  246. obj = random.choice(old)
  247. new.append(obj)
  248. old.remove(obj)
  249. return new
  250. def generate_piece_order():
  251. pieces = config.sections()
  252. pieces.remove("general") # remove config section that is not a piece
  253. return randomize_list(pieces)
  254. def load_config():
  255. global config, piece_dir
  256. config = configparser.SafeConfigParser()
  257. config.read(config_filename)
  258. if not config.has_section("general"):
  259. exit("Your config file is missing or malformed.")
  260. piece_dir = os.path.abspath(config.get("general", "piece_dir"))
  261. def get_missing_pieces(root):
  262. pieces = config.sections()
  263. pieces.remove("general")
  264. missing_pieces = []
  265. for piece in pieces:
  266. if not os.path.exists(os.path.join(piece_dir, piece)):
  267. missing_pieces.append(piece)
  268. if missing_pieces:
  269. window = Toplevel()
  270. window.title("PyQuizzer")
  271. window.protocol("WM_DELETE_WINDOW", root.destroy)
  272. status = StringVar()
  273. status.set("I'm missing {0} music ".format(len(missing_pieces)) +
  274. "pieces;\nwould you like me to download them for you now?")
  275. head_label = Label(window, text="Download Music Pieces", font=Font(
  276. family="Verdana", size=10, weight="bold"))
  277. head_label.grid(row=0, column=0, columnspan=2)
  278. status_label = Label(window, textvar=status, justify=LEFT, padx=15,
  279. pady=10)
  280. status_label.grid(row=1, column=0, columnspan=2)
  281. quit_button = Button(window, text="Quit", command=lambda: exit())
  282. quit_button.grid(row=2, column=0)
  283. dl_button = Button(window, text="Download",
  284. command=lambda: do_pieces_download(missing_pieces, status,
  285. dl_button, status_label, window))
  286. dl_button.grid(row=2, column=1)
  287. window.mainloop()
  288. else:
  289. global download_complete
  290. download_complete = True
  291. def do_pieces_download(pieces, status, dl_button, status_label, window):
  292. global download_complete
  293. dl_button.configure(state=DISABLED)
  294. counter = 1
  295. for piece in pieces:
  296. url = config.get("general", "base_url") + config.get(piece, "url")
  297. name = "{0} of {1}: {2}".format(counter, len(pieces),
  298. config.get(piece, "title"))
  299. urlretrieve(url, os.path.join(piece_dir, piece),
  300. lambda x, y, z: progress(x, y, z, name, status, status_label))
  301. counter += 1
  302. window.quit()
  303. window.withdraw()
  304. download_complete = True
  305. def progress(count, block_size, total_size, name, status, label):
  306. percent = int(count * block_size * 100 / total_size)
  307. status.set("Downloading pieces...\n" + name + ": %2d%%" % percent)
  308. label.update_idletasks()
  309. def run():
  310. root = Tk()
  311. root.withdraw()
  312. load_config()
  313. get_missing_pieces(root)
  314. while not download_complete:
  315. time.sleep(0.5)
  316. window = Toplevel()
  317. window.title("MusicQuizzer")
  318. answer_sheet = AnswerSheet(window)
  319. answer_sheet.canvas.create_window(0, 0, anchor=NW,
  320. window=answer_sheet.frame)
  321. answer_sheet.frame.update_idletasks()
  322. answer_sheet.canvas.config(scrollregion=answer_sheet.canvas.bbox("all"))
  323. window.protocol("WM_DELETE_WINDOW", root.destroy) # make the 'x' in the
  324. # corner quit the entire program, not just this window
  325. window.mainloop()
  326. if __name__ == "__main__":
  327. run()