diff --git a/gradint-build/Makefile b/gradint-build/Makefile
index 68d15dedb8f3e054e5da6d2f1d748f8774209557..616d49e5dced4636abd55bd5d8d93985a55d9c22 100644
--- a/gradint-build/Makefile
+++ b/gradint-build/Makefile
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
diff --git a/gradint-build/src/booktime.py b/gradint-build/src/booktime.py
index 8819bdcd8305f55f91f5a632d6c986013deb4331..a2457817cd79735c4af975926d62a040d59a61ab 100644
--- a/gradint-build/src/booktime.py
+++ b/gradint-build/src/booktime.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
diff --git a/gradint-build/src/filescan.py b/gradint-build/src/filescan.py
index e755c01b003323213ee5e91bd22cc94c2b36b8f6..87f8a0af8a686a03683f21225ce2efeb2747dc5e 100644
--- a/gradint-build/src/filescan.py
+++ b/gradint-build/src/filescan.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
@@ -59,7 +59,7 @@ def import_recordings(destDir=None):
                 if checkFirst:
                   for lang in [firstLanguage,secondLanguage]:
                    for ext in [dotwav,dotmp3]:
-                    if f[:f.rfind(extsep)]+"_"+lang+ext in curFiles: raise CannotOverwriteExisting()
+                    if checkIn(f[:f.rfind(extsep)]+"_"+lang+ext,curFiles): raise CannotOverwriteExisting()
                   continue
                 if not destDir:
                     if not getYN("Import the recordings that are in "+importDir+"?"): break
@@ -101,9 +101,9 @@ def getLsDic(directory):
     if not (directory.find(exclude_from_scan)==-1): return {}
     try: ls = os.listdir(directory)
     except: return {} # (can run without a 'samples' directory at all if just doing synth)
-    if "settings"+dottxt in ls:
+    if checkIn("settings"+dottxt,ls):
         # Sort out the o/p from import_recordings (and legacy record-with-HDogg.bat if anyone's still using that)
-        oddLanguage,evenLanguage = exec_in_a_func(u8strip(read(directory+os.sep+"settings"+dottxt).replace("\r\n","\n")).strip(wsp))
+        oddLanguage,evenLanguage = exec_in_a_func(wspstrip(u8strip(read(directory+os.sep+"settings"+dottxt).replace("\r\n","\n"))))
         if oddLanguage==evenLanguage: oddLanguage,evenLanguage="_"+oddLanguage,"-meaning_"+evenLanguage # if user sets languages the same, assume they want -meaning prompts
         else: oddLanguage,evenLanguage="_"+oddLanguage,"_"+evenLanguage
         for f in ls:
@@ -122,7 +122,7 @@ def getLsDic(directory):
         # in lsDic if it's in the list (any extension); =filename if it's an extension we know about; =None if it's a directory (in which case the key is the full filename), ottherwise =""
         if has_variants and file.find("_",file.find("_")+1)>=0: languageOverride=file[file.find("_")+1:file.find("_",file.find("_")+1)]
         else: languageOverride=None
-        if filelower.endswith(dottxt) and (file+extsep)[:file.rfind(extsep)] in lsDic: continue # don't let a .txt override a recording if both exist
+        if filelower.endswith(dottxt) and checkIn((file+extsep)[:file.rfind(extsep)],lsDic): continue # don't let a .txt override a recording if both exist
         if (filelower.endswith(dottxt) and file.find("_")>=0 and can_be_synthesized(file,directory,languageOverride)) or filelower.endswith(dotwav) or filelower.endswith(dotmp3): val = file
         else:
             val = ""
@@ -131,7 +131,7 @@ def getLsDic(directory):
                 lsDic[file]=None # a directory: store full name even if it has extsep in it.  Note however that we don't check isDirectory() if it's .wav etc as that would take too long.  (however some dirnames can contain dots)
                 # (+ NB need to store the directories specifically due to cases like course/ and course.pdf which may otherwise result in 2 traversals of "course" if we check isDirectory on 'extension is either none or unknown')
                 continue
-            elif (file+extsep)[:file.rfind(extsep)] in lsDic: continue # don't let a .txt~ or other unknown extension override a .txt
+            elif checkIn((file+extsep)[:file.rfind(extsep)],lsDic): continue # don't let a .txt~ or other unknown extension override a .txt
         lsDic[(file+extsep)[:file.rfind(extsep)]] = val # (this means if there's both mp3 and wav, wav will overwrite as comes later)
     if has_variants:
         ls=list2set(ls) ; newVs = {}
@@ -145,7 +145,7 @@ def getLsDic(directory):
             if penult_==-1: continue
             del lsDic[k]
             newK,newV = k[:k.rfind("_")], v[:v.rfind("_")]+v[v.rfind(extsep):]
-            if not newK in lsDic: lsDic[newK] = newV
+            if not checkIn(newK,lsDic): lsDic[newK] = newV
             else: # variants of different file types? better store them all under one (fileToEvent will sort out).  (Testing if the txt can be synth'd has already been done above)
                 if v.endswith(dottxt) and not lsDic[newK].endswith(dottxt): # if any variants are .txt then we'd better ensure the key is, so transliterate etc finds it. So move the key over to the .txt one.
                     old_dirV = B(directory+os.sep+lsDic[newK])
@@ -154,18 +154,18 @@ def getLsDic(directory):
                     lsDic[newK] = newV
                     variantFiles[B(directory)+B(os.sep)+B(newV)] = d
                     lsDic[newK] = newV # just add to the previous key
-                    if old_dirV in newVs:
+                    if checkIn(old_dirV,newVs):
                         del newVs[old_dirV]
                         newVs[B(directory)+B(os.sep)+B(newV)] = 1
                 else: newV = lsDic[newK]
             dir_newV = B(directory)+B(os.sep)+B(newV)
-            if not dir_newV in variantFiles:
+            if not checkIn(dir_newV,variantFiles):
                 variantFiles[dir_newV] = []
-                if S(newV) in ls: variantFiles[dir_newV].append(newV) # the no-variants name is also a valid option
+                if checkIn(S(newV),ls): variantFiles[dir_newV].append(newV) # the no-variants name is also a valid option
             variantFiles[dir_newV].append(v)
             newVs[dir_newV]=1
         for v in list(newVs.keys()):
-            assert v in variantFiles, repr(sorted(list(variantFiles.keys())))+' '+repr(v)
+            assert checkIn(v,variantFiles), repr(sorted(list(variantFiles.keys())))+' '+repr(v)
             random.shuffle(variantFiles[v])
     return lsDic
 
@@ -174,12 +174,12 @@ def scanSamples_inner(directory,retVal,doLimit):
     secLangSuffix = "_"+secondLanguage
     lsDic = getLsDic(directory)
     intro = intro_filename+"_"+firstLanguage
-    if intro in lsDic: dirsWithIntros.append((directory[len(samplesDirectory)+len(os.sep):],lsDic[intro]))
-    if not doLimit: doLimit = limit_filename in lsDic
-    doPoetry = poetry_filename in lsDic
+    if checkIn(intro,lsDic): dirsWithIntros.append((directory[len(samplesDirectory)+len(os.sep):],lsDic[intro]))
+    if not doLimit: doLimit = checkIn(limit_filename,lsDic)
+    doPoetry = checkIn(poetry_filename,lsDic)
     if doPoetry:
         # check which language the poetry is to be in (could be L1-to-L2, L2-to-L3, L2-only, or L3-only)
-        def poetry_language():
+        def poetry_language(firstLangSuffix,secLangSuffix,lsDic):
          ret = ""
          for file,withExt in list(lsDic.items()):
           if withExt:
@@ -189,7 +189,7 @@ def scanSamples_inner(directory,retVal,doLimit):
                 for l in otherLanguages:
                     if not l in llist and file.endswith("_"+l): return "_"+l
          return ret
-        doPoetry = poetry_language()
+        doPoetry = poetry_language(firstLangSuffix,secLangSuffix,lsDic)
     prefix = directory[len(samplesDirectory)+cond(samplesDirectory,len(os.sep),0):] # the directory relative to samplesDirectory
     if prefix: prefix += os.sep
     lastFile = None # for doPoetry
@@ -209,7 +209,7 @@ def scanSamples_inner(directory,retVal,doLimit):
                 wordSuffix=None
                 for l in otherLanguages:
                     if not l in [firstLanguage,secondLanguage] and file.endswith("_"+l):
-                        if l in otherFirstLanguages: swapWithPrompt=1
+                        if checkIn(l,otherFirstLanguages): swapWithPrompt=1
                         wordSuffix="_"+l ; break
                 if not wordSuffix: continue # can't do anything with this file
             if swapWithPrompt or firstLanguage==secondLanguage: promptFile=None
@@ -232,7 +232,7 @@ def scanSamples_inner(directory,retVal,doLimit):
                 # poetry without first-language prompts
                 if lastFile:
                     promptToAdd = prefix+lastFile[-1]
-                    if promptToAdd in singleLinePoems: del singleLinePoems[promptToAdd]
+                    if checkIn(promptToAdd,singleLinePoems): del singleLinePoems[promptToAdd]
                 else:
                     promptToAdd = prefix+withExt # 1st line is its own prompt
                     singleLinePoems[promptToAdd]=1
@@ -258,14 +258,14 @@ def parseSynthVocab(fname,forGUI=0):
         # TODO can we make this any faster on WinCE with large vocab lists? (tried SOME optimising already)
         if not B("=") in l: # might be a special instruction
             if not l: continue
-            canProcess = 0 ; l2=l.strip(bwsp)
+            canProcess = 0 ; l2=bwspstrip(l)
             if not l2 or l2[0:1]==B('#'): continue
             l2=l2.lower()
             if l2.startswith(B("set language ")) or l2.startswith(B("set languages ")):
                 langs=map(S,l.split()[2:]) ; someLangsUnknown = 0
                 maxsplit = len(langs)-1
                 for l in langs:
-                    if not l in allLangs: someLangsUnknown = 1
+                    if not checkIn(l,allLangs): someLangsUnknown = 1
             elif l2.startswith(B("limit on")):
                 doLimit = 1 ; limitNo += 1
             elif l2.startswith(B("limit off")): doLimit = 0
@@ -274,33 +274,33 @@ def parseSynthVocab(fname,forGUI=0):
             elif l2.startswith(B("poetry vocab line")): doPoetry,lastPromptAndWord = 0,cond(lastPromptAndWord,lastPromptAndWord,0) # not None, in case we're at the very start of a poem (see "just processed"... at end)
             else: canProcess=1
             if not canProcess: continue
-        elif B('#') in l and l.strip(bwsp)[0:1]==B('#'): continue # guard condition "'#' in l" improves speed
+        elif B('#') in l and bwspstrip(l)[0:1]==B('#'): continue # guard condition "'#' in l" improves speed
         if forGUI: strCount=""
         else:
             strCount = "%05d!synth:" % (count,)
             count += 1
         langsAndWords = list(zip(langs,l.split(B("="),maxsplit))) # don't try strip on a map() - it's faster to do it as-needed below
         # (maxsplit means you can use '=' signs in the last language, e.g. if using SSML with eSpeak)
-        if someLangsUnknown: langsAndWords = filter(lambda x:x[0] in allLangs, langsAndWords)
+        if someLangsUnknown: langsAndWords = filter(lambda x,a=allLangs:checkIn(x[0],a), langsAndWords)
         # Work out what we'll use for the prompt.  It could be firstLanguage, or it could be one of the other languages if we see it twice (e.g. if 2nd language is listed twice then the second one will be the prompt for 2nd-language-to-2nd-language learning), or it could be the only language if we're simply listing words for cache maintenance
         if firstLanguage==secondLanguage: langsAlreadySeen = {}
         else: langsAlreadySeen = {firstLanguage:True}
-        def findPrompt():
+        def findPrompt(langsAndWords,langsAlreadySeen,doPoetry,strCount):
             i=0
             while i<len(langsAndWords):
                 lang,word = langsAndWords[i] ; i += 1
                 isReminder = cache_maintenance_mode and len(langsAndWords)==1 and not doPoetry
                 if (lang in langsAlreadySeen or isReminder) and (lang in getsynth_cache or can_be_synthesized(B("!synth:")+B(word)+B("_")+B(lang))): # (check cache because most of the time it'll be there and we don't need to go through all the text processing in can_be_synthesized)
                     if not word: continue
-                    elif word[0:1] in bwsp or word[-1:] in bwsp: word=word.strip(bwsp) # avoid call if unnecessary
+                    elif word[0:1] in bwsp or word[-1:] in bwsp: word=bwspstrip(word) # avoid call if unnecessary
                     return B(strCount)+word+B("_"+lang), cond(isReminder,0,i)
                 langsAlreadySeen[lang]=True
             return None,0
-        prompt,onePastPromptIndex = findPrompt()
+        prompt,onePastPromptIndex = findPrompt(langsAndWords,langsAlreadySeen,doPoetry,strCount)
         if not prompt and len(langsAndWords)>1: # 1st language prompt not found; try 2nd language to 3rd language etc
-            langsAlreadySeen = list2dict(otherFirstLanguages) ; prompt,onePastPromptIndex = findPrompt()
+            langsAlreadySeen = list2dict(otherFirstLanguages) ; prompt,onePastPromptIndex = findPrompt(langsAndWords,langsAlreadySeen,doPoetry,strCount)
             if not prompt:
-                langsAlreadySeen = {secondLanguage:True} ; prompt,onePastPromptIndex = findPrompt()
+                langsAlreadySeen = {secondLanguage:True} ; prompt,onePastPromptIndex = findPrompt(langsAndWords,langsAlreadySeen,doPoetry,strCount)
         prompt_L1only = prompt # before we possibly change it into a list etc.  (Actually not necessarily L1 see above, but usually is)
         if doPoetry:
             if prompt and lastPromptAndWord:
@@ -309,7 +309,7 @@ def parseSynthVocab(fname,forGUI=0):
             elif not prompt:
                 if lastPromptAndWord:
                     prompt=lastPromptAndWord[-1]
-                    if lastPromptAndWord[-1] in singleLinePoems: del singleLinePoems[lastPromptAndWord[-1]]
+                    if checkIn(lastPromptAndWord[-1],singleLinePoems): del singleLinePoems[lastPromptAndWord[-1]]
                 else:
                     prompt = 1 # file itself (see below)
         if prompt:
@@ -317,8 +317,8 @@ def parseSynthVocab(fname,forGUI=0):
             while i<len(langsAndWords):
                 lang,word = langsAndWords[i] ; i+=1
                 if i==onePastPromptIndex or (lang==firstLanguage and not firstLanguage==secondLanguage) or not word: continue # if 1st language occurs more than once (target as well as prompt) then don't get confused - this vocab file is probably being used with reverse settings
-                elif word[0:1] in bwsp or word[-1:] in bwsp: word=word.strip(bwsp) # avoid call if unnecessary
-                if lang in getsynth_cache or can_be_synthesized(B("!synth:")+word+B("_"+lang)):
+                elif word[0:1] in bwsp or word[-1:] in bwsp: word=bwspstrip(word) # avoid call if unnecessary
+                if checkIn(lang,getsynth_cache) or can_be_synthesized(B("!synth:")+word+B("_"+lang)):
                   if not (doPoetry and disablePoem):
                     f=B(strCount)+word+B("_"+lang)
                     if prompt==1 or prompt==f: # a file with itself as the prompt (either explicitly or by omitting any other prompt)
@@ -334,9 +334,9 @@ def parseSynthVocab(fname,forGUI=0):
 
 def sanitise_otherLanguages():
     for l in otherFirstLanguages:
-        if not l in otherLanguages: otherLanguages.append(l)
+        if not checkIn(l,otherLanguages): otherLanguages.append(l)
     for l in otherLanguages:
-        if not l in possible_otherLanguages: possible_otherLanguages.append(l)
+        if not checkIn(l,possible_otherLanguages): possible_otherLanguages.append(l)
 sanitise_otherLanguages()
 
 # Prompt file syntax: word_language.wav
@@ -360,7 +360,7 @@ class AvailablePrompts(object):
     def getRandomPromptList(self,promptsData,language):
         random.shuffle(self.prefixes)
         for p in self.prefixes:
-            if p.lower() in self.reservedPrefixes: continue
+            if checkIn(p.lower(),self.reservedPrefixes): continue
             try:
                 theList = self.getPromptList(p,promptsData,language)
                 return theList
@@ -375,11 +375,11 @@ class AvailablePrompts(object):
                 if p > advancedPromptThreshold2:
                     self.user_is_advanced = 1 ; break # got a reasonably advanced user
         beginnerPrompt = prefix+"_"+firstLanguage
-        if not beginnerPrompt in self.lsDic:
+        if not checkIn(beginnerPrompt,self.lsDic):
             if self.user_is_advanced and not language==secondLanguage and prefix+"_"+secondLanguage in self.lsDic: beginnerPrompt=prefix+"_"+secondLanguage # No first language prompt, but in advanced mode may be able to find a second-language prompt for a 3rd language
             else: beginnerPrompt = None
         advancedPrompt = prefix+"_"+language
-        if not advancedPrompt in self.lsDic:
+        if not checkIn(advancedPrompt,self.lsDic):
             # Must use beginnerPrompt
             if beginnerPrompt: r=[self.lsDic[beginnerPrompt]]
             else:
@@ -400,7 +400,7 @@ class AvailablePrompts(object):
         # Increment advancedPrompt, taking care not to go
         # past the threshold if it's not available yet
         adv = promptsData.get(advancedPrompt,0)
-        if advancedPrompt in self.lsDic or adv <= cond(language==secondLanguage,transitionPromptThreshold,transitionPromptThreshold2):
+        if checkIn(advancedPrompt,self.lsDic) or adv <= cond(language==secondLanguage,transitionPromptThreshold,transitionPromptThreshold2):
             adv += 1
         promptsData[advancedPrompt] = adv
         # and finally,
@@ -422,5 +422,5 @@ def introductions(zhFile,progressData):
     return map(lambda x: fileToEvent(cond(x[0],x[0]+os.sep,"")+x[1]), toIntroduce)
 
 def explanations(zhFile):
-    if zhFile in filesWithExplanations: return fileToEvent(zhFile.replace(dotmp3,dotwav).replace(dottxt,dotwav).replace(dotwav,"_explain_"+firstLanguage+filesWithExplanations[zhFile][-len(dotwav):]))
+    if checkIn(zhFile,filesWithExplanations): return fileToEvent(zhFile.replace(dotmp3,dotwav).replace(dottxt,dotwav).replace(dotwav,"_explain_"+firstLanguage+filesWithExplanations[zhFile][-len(dotwav):]))
 
diff --git a/gradint-build/src/frontend.py b/gradint-build/src/frontend.py
index b41a80cb105a545a66d3e4d889f1ea04932a49b6..db881acedb2b9c33094a1ba69451bb19909c7457 100644
--- a/gradint-build/src/frontend.py
+++ b/gradint-build/src/frontend.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
@@ -23,7 +23,9 @@ appTitle += time.strftime(" %A") # in case leave 2+ instances on the desktop
 def waitOnMessage(msg):
     global warnings_printed
     if type(msg)==type(u""): msg2=msg.encode("utf-8")
-    else: msg2,msg=msg,msg.decode("utf-8")
+    else:
+        try: msg2,msg=msg,msg.decode("utf-8")
+        except AttributeError: msg2=msg # Python 2.1 has no .decode
     if appuifw:
         t=appuifw.Text() ; t.add(u"".join(warnings_printed)+msg) ; appuifw.app.body = t # in case won't fit in the query()  (and don't use note() because it doesn't wait)
         appuifw.query(msg,'query')
@@ -175,10 +177,10 @@ tkNumWordsToShow = 10 # the default number of list-box items
 def addStatus(widget,status,mouseOnly=0):
     # Be VERY CAREFUL with status line changes.  Don't do it on things that are focused by default (except with mouseOnly=1).  Don't do it when the default status line might be the widest thing (i.e. when list box is not displayed) or window size could jump about too much.  And in any event don't use lines longer than about 53 characters (the approx default width of the listbox when using monospace fonts).
     # (NB addStatus now takes effect only when the list box is displayed anyway, so OK for buttons that might also be displayed without it)
-    widget.bind('<Enter>',lambda *args:app.set_statusline(status))
+    widget.bind('<Enter>',lambda e=None,status=status:app.set_statusline(status))
     widget.bind('<Leave>',app.restore_statusline)
     if not mouseOnly:
-        widget.bind('<FocusIn>',lambda *args:app.set_statusline(status))
+        widget.bind('<FocusIn>',lambda e=None,app=app,status=status:app.set_statusline(status))
         widget.bind('<FocusOut>',app.restore_statusline)
 def makeButton(parent,text,command):
     button = Tkinter.Button(parent)
@@ -212,7 +214,7 @@ def CXVMenu(e): # callback for right-click
     funclist = [("Paste",paste),("Delete",'<Delete>')]
     if not macsound:
         funclist = [("Cut",cut),("Copy",copy)]+funclist # doesn't work reliably on Mac Tk
-    for l,cmd in funclist: m.add_command(label=l,command=(lambda e=e,c=cmd: e.widget.after(10,evgen,e,c)))
+    for l,cmd in funclist: m.add_command(label=l,command=(lambda e=e,c=cmd,evgen=evgen: e.widget.after(10,evgen,e,c)))
     m.add_command(label="Select All",command=(lambda e=e: e.widget.after(10,selectAll,e)))
     m.tk_popup(e.x_root-3, e.y_root+3,entry="0")
 def selectAll(e):
@@ -235,7 +237,7 @@ def addTextBox(row,wide=0):
         def doRawInput(text,entry):
             app.input_to_set = text
             app.menu_response="input"
-        entry.bind('<Return>',lambda e:doRawInput(text,entry))
+        entry.bind('<Return>',lambda e,doRawInput=doRawInput,text=text,entry=entry:doRawInput(text,entry))
         if wide: # put help in 1st wide textbox
           global had_doRawInput
           try: had_doRawInput
@@ -244,10 +246,10 @@ def addTextBox(row,wide=0):
             text.set("(Push OK to type A-Z)") # (if changing this message, change it below too)
             class E: pass
             e=E() ; e.widget = entry
-            entry.after(10,lambda *args:selectAll(e))
+            entry.after(10,lambda _=None,e=e:selectAll(e))
       else: # PocketPC: try to detect long clicks. This is awkward. time.time is probably 1sec resolution so will get false +ves if go by that only.
         def timeStamp(entry): entry.buttonPressTime=time.time()
-        entry.bind('<ButtonPress-1>',lambda e:timeStamp(entry))
+        entry.bind('<ButtonPress-1>',lambda e,timeStamp=timeStamp,entry=entry:timeStamp(entry))
         global lastDblclkAdvisory,lastDblclk
         lastDblclkAdvisory=lastDblclk=0
         def pasteInstructions(t):
@@ -259,8 +261,8 @@ def addTextBox(row,wide=0):
         def doPaste(text,entry):
             text.set(entry.selection_get(selection="CLIPBOARD"))
             global lastDblclk ; lastDblclk=time.time()
-        entry.bind('<ButtonRelease-1>',lambda e:pasteInstructions(time.time()-getattr(entry,"buttonPressTime",time.time())))
-        entry.bind('<Double-Button-1>',lambda e:doPaste(text,entry))
+        entry.bind('<ButtonRelease-1>',lambda e,entry=entry,pasteInstructions=pasteInstructions:pasteInstructions(time.time()-getattr(entry,"buttonPressTime",time.time())))
+        entry.bind('<Double-Button-1>',lambda e,doPaste=doPaste,text=text,entry=entry:doPaste(text,entry))
     # Tkinter bug workaround (some versions): event_generate from within a key event handler can be unreliable, so the Ctrl-A handler delays selectAll by 10ms:
     entry.bind(cond(macsound,'<Command-a>','<Control-a>'),(lambda e:e.widget.after(10,lambda e=e:selectAll(e))))
     bindUpDown(entry,False)
@@ -297,14 +299,14 @@ def make_output_row(parent):
     # if there aren't any options then return None
     # we also put script-variant selection here, if any
     row = None
-    def getRow(row):
+    def getRow(row,parent):
       if not row:
         row = Tkinter.Frame(parent)
         row.pack(fill=Tkinter.X,expand=1)
       return row
     GUIlang = GUI_languages.get(firstLanguage,firstLanguage)
-    if "@variants-"+GUIlang in GUI_translations: # the firstLanguage has script variants
-        row=getRow(row)
+    if checkIn("@variants-"+GUIlang,GUI_translations): # the firstLanguage has script variants
+        row=getRow(row,parent)
         if not hasattr(app,"scriptVariant"): app.scriptVariant = Tkinter.StringVar(app)
         count = 0
         for variant in GUI_translations["@variants-"+GUIlang]:
@@ -312,7 +314,7 @@ def make_output_row(parent):
             count += 1
         app.scriptVariant.set(str(scriptVariants.get(GUIlang,0)))
     if synth_partials_voices and guiVoiceOptions:
-        row=getRow(row)
+        row=getRow(row,parent)
         if not hasattr(app,"voiceOption"): app.voiceOption = Tkinter.StringVar(app)
         Tkinter.Radiobutton(row, text=u" Normal ", variable=app.voiceOption, value="", indicatoron=forceRadio).pack({"side":"left"})
         for o in guiVoiceOptions: Tkinter.Radiobutton(row, text=u" "+o[1].upper()+o[2:]+u" ", variable=app.voiceOption, value=o, indicatoron=forceRadio).pack({"side":"left"})
@@ -321,9 +323,9 @@ def make_output_row(parent):
     if not hasattr(app,"outputTo"):
         app.outputTo = Tkinter.StringVar(app) # NB app not parent (as parent is no longer app)
         app.outputTo.set("0") # not "" or get tri-state boxes on OS X 10.6
-    row=getRow(row)
+    row=getRow(row,parent)
     rightrow = addRightRow(row) # to show beginners this row probably isn't the most important thing despite being in a convenient place, we'll right-align
-    def addFiletypeButton(fileType):
+    def addFiletypeButton(fileType,rightrow):
         ftu = fileType.upper()
         t = Tkinter.Radiobutton(rightrow, text=cond(forceRadio,""," ")+ftu+" ", variable=app.outputTo, value=fileType, indicatoron=forceRadio)
         bindUpDown(t,True)
@@ -337,13 +339,13 @@ def make_output_row(parent):
     addStatus(t,"Select this to send all sounds to\nthe speaker, not to files on disk")
     bindUpDown(t,True)
     t.pack({"side":"left"})
-    if got_program("lame"): addFiletypeButton("mp3")
-    if got_windows_encoder: addFiletypeButton("wma")
-    if got_program("neroAacEnc") or got_program("faac") or got_program("afconvert"): addFiletypeButton("aac")
-    if got_program("oggenc") or got_program("oggenc2"): addFiletypeButton("ogg")
-    if got_program("toolame"): addFiletypeButton("mp2")
-    if got_program("speexenc"): addFiletypeButton("spx")
-    addFiletypeButton("wav")
+    if got_program("lame"): addFiletypeButton("mp3",rightrow)
+    if got_windows_encoder: addFiletypeButton("wma",rightrow)
+    if got_program("neroAacEnc") or got_program("faac") or got_program("afconvert"): addFiletypeButton("aac",rightrow)
+    if got_program("oggenc") or got_program("oggenc2"): addFiletypeButton("ogg",rightrow)
+    if got_program("toolame"): addFiletypeButton("mp2",rightrow)
+    if got_program("speexenc"): addFiletypeButton("spx",rightrow)
+    addFiletypeButton("wav",rightrow)
     # "Get MP3 encoder" and "Get WMA encoder" changed to "MP3..." and "WMA..." to save width (+ no localisation necessary)
     if unix and not got_program("lame") and got_program("make") and got_program("gcc") and (got_program("curl") or got_program("wget")): addButton(rightrow,"MP3...",app.getEncoder,status="Press this to compile an MP3 encoder\nso Gradint can output to MP3 files") # (checking gcc as well as make because some distros strangely have make but no compiler; TODO what if has a non-gcc compiler)
     # (no longer available) elif (winsound or mingw32) and not got_windows_encoder and not got_program("lame"): addButton(rightrow,"WMA...",app.getEncoder,status="Press this to download a WMA encoder\nso Gradint can output to WMA files")
@@ -381,8 +383,8 @@ def setupScrollbar(parent,rowNo):
     s.config(command=c.yview)
     scrolledFrame=Tkinter.Frame(c) ; c.create_window(0,0,window=scrolledFrame,anchor="nw")
     # Mousewheel binding.  TODO the following bind_all assumes only one scrolledFrame on screen at once (redirect all mousewheel events to the frame; necessary as otherwise they'll go to buttons etc)
-    app.ScrollUpHandler = lambda *args:c.yview("scroll","-1","units")
-    app.ScrollDownHandler = lambda *args:c.yview("scroll","1","units")
+    app.ScrollUpHandler = lambda e=None,c=c:c.yview("scroll","-1","units")
+    app.ScrollDownHandler = lambda e=None,c=c:c.yview("scroll","1","units")
     if macsound:
         def ScrollHandler(event):
             if event.delta>0: app.ScrollUpHandler()
@@ -399,8 +401,8 @@ shortDescriptionName = "short-description"+dottxt
 longDescriptionName = "long-description"+dottxt
 class ExtraButton(object):
     def __init__(self,directory):
-        self.shortDescription = u8strip(read(directory+os.sep+shortDescriptionName)).strip(wsp)
-        if fileExists(directory+os.sep+longDescriptionName): self.longDescription = u8strip(read(directory+os.sep+longDescriptionName)).strip(wsp)
+        self.shortDescription = wspstrip(u8strip(read(directory+os.sep+shortDescriptionName)))
+        if fileExists(directory+os.sep+longDescriptionName): self.longDescription = wspstrip(u8strip(read(directory+os.sep+longDescriptionName)))
         else: self.longDescription = self.shortDescription
         self.directory = directory
     def add(self):
@@ -417,7 +419,7 @@ class ExtraButton(object):
         try: ls = os.listdir(samplesDirectory)
         except: os.mkdir(samplesDirectory)
         name1=newName
-        while newName in ls: newName+="1"
+        while checkIn(newName,ls): newName+="1"
         name2=newName
         newName = samplesDirectory+os.sep+newName
         os.rename(self.directory,newName)
@@ -426,13 +428,13 @@ class ExtraButton(object):
             which_collection = localise(" has been added to your collection.")
             o=open(vocabFile,"a")
             o.write("# --- BEGIN "+self.shortDescription+" ---\n")
-            o.write(u8strip(read(newName+os.sep+"add-to-vocab"+dottxt)).strip(wsp)+"\n")
+            o.write(wspstrip(u8strip(read(newName+os.sep+"add-to-vocab"+dottxt)))+"\n")
             o.write("# ----- END "+self.shortDescription+" ---\n")
             if hasattr(app,"vocabList"): del app.vocabList # so re-reads
             os.remove(newName+os.sep+"add-to-vocab"+dottxt)
         if fileExists(newName+os.sep+"add-to-languages"+dottxt):
             changed = 0
-            for lang in u8strip(read(newName+os.sep+"add-to-languages"+dottxt)).strip(wsp).split():
+            for lang in wspstrip(u8strip(read(newName+os.sep+"add-to-languages"+dottxt))).split():
                 if not lang in [firstLanguage,secondLanguage]+otherLanguages:
                     otherLanguages.append(lang) ; changed = 1
             if changed: sanitise_otherLanguages(), updateSettingsFile("advanced"+dottxt,{"otherLanguages":otherLanguages,"possible_otherLanguages":possible_otherLanguages})
@@ -470,8 +472,8 @@ def focusButton(button):
             try: button.config(state=state)
             except: pass # maybe not a button
         for t in range(250,1000,250): # (NB avoid epilepsy's 5-30Hz!)
-          app.after(t,lambda *args:flashButton(button,"active"))
-          app.after(t+150,lambda *args:flashButton(button,"normal"))
+          app.after(t,lambda e=None,flashButton=flashButton,button=button:flashButton(button,"active"))
+          app.after(t+150,lambda e=None,flashButton=flashButton,button=button:flashButton(button,"normal"))
         # (Don't like flashing, but can't make it permanently active as it won't change when the focus does)
 
 if WMstandard: GUI_omit_statusline = 1 # unlikely to be room (and can disrupt nav)
@@ -482,7 +484,7 @@ def startTk():
             Tkinter.Frame.__init__(self, master)
             class EmptyClass: pass
             self.todo = EmptyClass() ; self.toRestore = []
-            self.ScrollUpHandler = self.ScrollDownHandler = lambda *args:True
+            self.ScrollUpHandler = self.ScrollDownHandler = lambda e=None:True
             global app ; app = self
             make_extra_buttons_waiting_list()
             if olpc: self.master.option_add('*font',cond(extra_buttons_waiting_list,'Helvetica 9','Helvetica 14'))
@@ -522,11 +524,11 @@ def startTk():
                             if nominalSize<0: nominalSize,f[i] = -nominalSize,"-%d"
                             else: f[i]="%d"
                             break
-                      if nominalSize==2147483648: nominalSize = 0 # e.g. Tk 8.6 on Ubuntu 16.04 when using the first eval stirng above
+                      if nominalSize==long(32768)*long(65536): nominalSize = 0 # e.g. Tk 8.6 on Ubuntu 16.04 when using the first eval string above
                       elif f2=='set font [font actual default]': nominalSize *= 0.77 # kludge for Tk 8.6 on Ubuntu 16.04 to make large-print calculation below work
                       if nominalSize: break
                     f=" ".join(f)
-                    if (not "%d" in f) or not nominalSize: raise Exception("wrong format") # caught below
+                    if (not checkIn("%d",f)) or not nominalSize: raise Exception("wrong format") # caught below
                 pixelSize = self.Label.winfo_reqheight()-2*int(str(self.Label["borderwidth"]))-2*int(str(self.Label["pady"]))
                 # NB DO NOT try to tell Tk a desired pixel size - you may get a *larger* pixel size.  Need to work out the desired nominal size.
                 approx_lines_per_screen_when_large = 25 # TODO really? (24 at 800x600 192dpi 15in but misses the status line, but OK for advanced users.  setting 25 gives nominal 7 which is rather smaller.)
@@ -733,7 +735,7 @@ def startTk():
             addLabel(self.LessonRow,localise("mins"))
             self.MakeLessonButton=addButton(self.LessonRow,localise("Start lesson"),self.makelesson,{"side":"left"},status="Press to create customized lessons\nusing the words in your collection")
             self.lastOutTo=-1 # so it updates the Start Lesson button if needed
-            self.MakeLessonButton.bind('<FocusIn>',(lambda *args:app.after(10,lambda *args:app.MinsEntry.selection_clear())))
+            self.MakeLessonButton.bind('<FocusIn>',(lambda e=None,app=app:app.after(10,lambda e=None,app=app:app.MinsEntry.selection_clear())))
         def sync_listbox_etc(self):
             if not hasattr(self,"vocabList"):
                 if hasattr(self,"needVocablist"): return # already waiting for main thread to make one
@@ -766,16 +768,16 @@ def startTk():
                 for t,l in [(text1.encode('utf-8'),secondLanguage),(text2.encode('utf-8'),firstLanguage)]:
                     k,f = synthcache_lookup(B("!synth:")+t+B("_")+B(l),justQueryCache=1)
                     if f:
-                      if (partials_langname(l) in synth_partials_voices or get_synth_if_possible(l,0)): # (no point having these buttons if there's no chance we can synth it by any method OTHER than the cache)
-                        if k in synthCache_transtbl and B(k[:1])==B("_"): cacheManagementOptions.append(("Keep in "+l+" cache",k,k[1:],0,0))
+                      if (checkIn(partials_langname(l),synth_partials_voices) or get_synth_if_possible(l,0)): # (no point having these buttons if there's no chance we can synth it by any method OTHER than the cache)
+                        if checkIn(k,synthCache_transtbl) and B(k[:1])==B("_"): cacheManagementOptions.append(("Keep in "+l+" cache",k,k[1:],0,0))
                         elif B(k[:1])==B("_"): cacheManagementOptions.append(("Keep in "+l+" cache",0,0,f,f[1:]))
-                        if k in synthCache_transtbl: cacheManagementOptions.append(("Reject from "+l+" cache",k,"__rejected_"+k,0,0))
+                        if checkIn(k,synthCache_transtbl): cacheManagementOptions.append(("Reject from "+l+" cache",k,"__rejected_"+k,0,0))
                         else: cacheManagementOptions.append(("Reject from "+l+" cache",0,0,f,"__rejected_"+f))
                     else:
                       k,f = synthcache_lookup(B("!synth:__rejected_")+t+B("_"+l),justQueryCache=1)
                       if not f: k,f = synthcache_lookup(B("!synth:__rejected__")+t+B("_"+l),justQueryCache=1)
                       if f:
-                        if k in synthCache_transtbl: cacheManagementOptions.append(("Undo "+l+" cache reject",k,k[11:],0,0))
+                        if checkIn(k,synthCache_transtbl): cacheManagementOptions.append(("Undo "+l+" cache reject",k,k[11:],0,0))
                         else: cacheManagementOptions.append(("Undo "+l+" cache reject",0,0,f,f[11:]))
                       elif l==secondLanguage and mp3web and not ';' in t: cacheManagementOptions.append(("Get from "+mp3webName,0,0,0,0))
                 if not hasattr(self,"cacheManagementOptions"):
@@ -790,13 +792,13 @@ def startTk():
                 if not (text1 or text2): self.ListBox.selection_clear(0,'end') # probably just added a new word while another was selected (added a variation) - clear selection to reduce confusion
                 else: return # don't try to be clever with searches when editing an existing item (the re-ordering can be confusing)
             text1,text2 = text1.lower().replace(" ",""),text2.lower().replace(" ","") # ignore case and whitespace when searching
-            l=map(lambda x:x[0]+"="+x[1], filter(lambda x:text1 in x[0].lower().replace(" ","") and text2 in x[1].lower().replace(" ",""),self.vocabList)[-tkNumWordsToShow:])
+            l=map(lambda x:x[0]+"="+x[1], filter(lambda x,text1=text1,text2=text2:x[0].lower().replace(" ","").find(text1)>-1 and x[1].lower().replace(" ","").find(text2)>-1,self.vocabList)[-tkNumWordsToShow:])
             l.reverse() ; synchronizeListbox(self.ListBox,l) # show in reverse order, in case the bottom of the list box is off-screen
         def doSynthcacheManagement(self,oldKey,newKey,oldFname,newFname):
             # should be a quick operation - might as well do it in the GUI thread
             if (oldKey,oldFname) == (0,0): # special for mp3web
                 self.menu_response="mp3web" ; return
-            if oldKey in synthCache_transtbl:
+            if checkIn(oldKey,synthCache_transtbl):
                 if newKey: synthCache_transtbl[newKey]=synthCache_transtbl[oldKey]
                 else: del synthCache_transtbl[oldKey]
                 open(synthCache+os.sep+transTbl,'wb').write(B("").join([v+B(" ")+k+B("\n") for k,v in list(synthCache_transtbl.items())]))
@@ -924,8 +926,8 @@ def startTk():
             self.L2Entry.bind('<Return>',self.changeLanguages)
             for e in [self.L1Entry,self.L2Entry]: e.bind('<Button-1>',(lambda e:e.widget.after(10,lambda e=e:selectAll(e))))
             self.ChangeLanguageButton = addButton(self.row3,"",self.changeLanguages,status="Use this button to set your\nfirst and second languages") # will set text in updateLanguageLabels
-            self.ChangeLanguageButton.bind('<FocusIn>',(lambda *args:app.after(10,lambda *args:app.L2Entry.selection_clear())))
-            self.AddButton.bind('<FocusIn>',(lambda *args:app.after(10,lambda *args:app.L1Entry.selection_clear()))) # for backwards tabbing
+            self.ChangeLanguageButton.bind('<FocusIn>',(lambda e=None,app=app:app.after(10,lambda e=None,app=app:app.L2Entry.selection_clear())))
+            self.AddButton.bind('<FocusIn>',(lambda e=None,app=app:app.after(10,lambda e=None,app=app:app.L1Entry.selection_clear()))) # for backwards tabbing
             if GUI_omit_settings and (vocabFile==user0[1] or fileExists(vocabFile)): self.row3.pack_forget()
             if textEditorCommand:
                 self.RecordedWordsButton = addButton(self.row4,"",self.showRecordedWords,{"side":"left"},status="This button lets you manage recorded\n(as opposed to computer-voiced) words")
@@ -972,8 +974,8 @@ def startTk():
             m=Tkinter.Menu(None, tearoff=0, takefocus=0)
             for i in range(len(lastUserNames)):
                 if lastUserNames[i] and not i==intor0(self.userNo.get()):
-                    if fileExists(addUserToFname(user0[1],i)): m.add_command(label=u"Copy vocab list from "+lastUserNames[i],command=(lambda e=None,i=i:self.copyVocabFrom(i)))
-                    m.add_command(label=u"Copy recordings to/from "+lastUserNames[i],command=(lambda e=None,i=i:self.setToOpen((addUserToFname(user0[0],i),addUserToFname(user0[0],intor0(self.userNo.get()))))))
+                    if fileExists(addUserToFname(user0[1],i)): m.add_command(label=u"Copy vocab list from "+lastUserNames[i],command=(lambda e=None,i=i,self=self:self.copyVocabFrom(i)))
+                    m.add_command(label=u"Copy recordings to/from "+lastUserNames[i],command=(lambda e=None,i=i,self=self:self.setToOpen((addUserToFname(user0[0],i),addUserToFname(user0[0],intor0(self.userNo.get()))))))
             m.tk_popup(self.CopyFromButton.winfo_rootx(),self.CopyFromButton.winfo_rooty(),entry="0")
         def setToOpen(self,toOpen): self.menu_response,self.toOpen = "samplesCopy",toOpen
         def copyVocabFrom(self,userNo):
@@ -986,7 +988,7 @@ def startTk():
             if not o: return # IOError
             langs = (secondLanguage,firstLanguage)
             for newLangs,line in vCopyFrom:
-                if (newLangs,line) in vCurrent: continue # already got it
+                if checkIn((newLangs,line),vCurrent): continue # already got it
                 if not newLangs==langs: o.write(B("SET LANGUAGES ")+B(" ").join(list(newLangs))+B("\n"))
                 o.write(B(line)+B("\n"))
                 langs = newLangs
@@ -1010,7 +1012,7 @@ def startTk():
                     if tkMessageBox.askyesno(self.master.title(),msg+"  "+localise("Would you like to see a list of the standard abbreviations for languages that can be computer voiced?")): self.todo.alert = localise("Languages with computer voices (some better than others):")+"\n"+langs
                 else: self.todo.alert = msg+"  "+localise("(Sorry, a list of these is not available on this system - check eSpeak installation.)")
                 return
-            need_redisplay = "@variants-"+GUI_languages.get(firstLanguage,firstLanguage) in GUI_translations or "@variants-"+GUI_languages.get(S(firstLanguage1),S(firstLanguage1)) in GUI_translations # if EITHER old or new lang has variants, MUST reconstruct that row.  (TODO also do it anyway to get the "Speaker" etc updated?  but may cause unnecessary flicker if that's no big problem)
+            need_redisplay = checkIn("@variants-"+GUI_languages.get(firstLanguage,firstLanguage),GUI_translations) or checkIn("@variants-"+GUI_languages.get(S(firstLanguage1),S(firstLanguage1)),GUI_translations) # if EITHER old or new lang has variants, MUST reconstruct that row.  (TODO also do it anyway to get the "Speaker" etc updated?  but may cause unnecessary flicker if that's no big problem)
             firstLanguage,secondLanguage = S(firstLanguage1),S(secondLanguage1)
             updateSettingsFile(settingsFile,{"firstLanguage":firstLanguage,"secondLanguage":secondLanguage})
             if need_redisplay:
@@ -1074,7 +1076,7 @@ def startTk():
             # (also remove the simple visual markup that Wenlin sometimes adds)
             t1,t2=text1,text2
             for zap in ["\n","\r","<b>","</b>","<i>","</i>","<u>","</u>"]: t1,t2=t1.replace(zap,""),t2.replace(zap,"")
-            t1,t2 = t1.strip(wsp), t2.strip(wsp)
+            t1,t2 = wspstrip(t1),wspstrip(t2)
             if not t1==text1: self.Text1.set(t1)
             if not t2==text2: self.Text2.set(t2)
         def getEncoder(self,*args):
@@ -1147,13 +1149,13 @@ def guiVocabList(parsedVocab):
     return ret
 def readText(l): # see utils/transliterate.py (running guiVocabList on txt files from scanSamples)
     l = B(samplesDirectory)+B(os.sep)+B(l)
-    if l in variantFiles: # oops. just read the 1st .txt variant
+    if checkIn(l,variantFiles): # oops. just read the 1st .txt variant
         if B(os.sep) in l: lp=(l+B(os.sep))[:l.rfind(B(os.sep))]+B(os.sep)
         else: lp = B("")
         varList = filter(lambda x:x.endswith(B(dottxt)),variantFiles[l])
         varList.sort() # so at least it consistently returns the same one.  TODO utils/ cache-synth.py list-synth.py synth-batchconvert-helper.py all use readText() now, can we get them to cache the other variants too?
         l = lp + varList[0]
-    return u8strip(read(l)).strip(bwsp)
+    return bwspstrip(u8strip(read(l)))
 
 def singular(number,s):
   s=localise(s)
@@ -1303,7 +1305,7 @@ def sanityCheck(text,language,pauseOnError=0): # text is utf-8; returns error me
             if t in B("12345"): return # got tone numbers
             if t not in B("0123456789. "): allDigits = False
         if allDigits: return
-        return B("Pinyin needs tones.  Please go back and add tone numbers to ")+text+B(".")+cond(startBrowser(B("http://www.mdbg.net/chinese/dictionary?wdqb=")+fix_pinyin(text,[]).strip(bwsp).replace(B("5"),B("")).replace(B(" "),B("+"))),B(" Gradint has pointed your web browser at an online dictionary that might help."),B(""))
+        return B("Pinyin needs tones.  Please go back and add tone numbers to ")+text+B(".")+cond(startBrowser(B("http://www.mdbg.net/chinese/dictionary?wdqb=")+bwspstrip(fix_pinyin(text,[])).replace(B("5"),B("")).replace(B(" "),B("+"))),B(" Gradint has pointed your web browser at an online dictionary that might help."),B(""))
 
 def check_for_slacking():
     if fileExists(progressFile): checkAge(progressFile,localise("It has been %d days since your last Gradint lesson.  Please try to have one every day."))
@@ -1412,7 +1414,7 @@ def delOrReplace(L2toDel,L1toDel,newL2,newL1,action="delete"):
         l2=l.lower()
         if l2.startswith(B("set language ")) or l2.startswith(B("set languages ")):
             langs=map(S,l.split()[2:]) ; writeB(o,l+B("\n")) ; continue
-        thisLine=map(lambda x:x.strip(bwsp),l.split(B("="),len(langs)-1))
+        thisLine=map(bwspstrip,l.split(B("="),len(langs)-1))
         if (langs==[secondLanguage,firstLanguage] and thisLine==[L2toDel.encode('utf-8'),L1toDel.encode('utf-8')]) or (langs==[firstLanguage,secondLanguage] and thisLine==[L1toDel.encode('utf-8'),L2toDel.encode('utf-8')]):
             # delete this line.  and maybe replace it
             found = 1
@@ -1426,7 +1428,7 @@ def delOrReplace(L2toDel,L1toDel,newL2,newL1,action="delete"):
         os.remove(fname)
     return found
 
-def maybeCanSynth(lang): return lang in synth_partials_voices or get_synth_if_possible(lang,0) or synthCache
+def maybeCanSynth(lang): return checkIn(lang,synth_partials_voices) or get_synth_if_possible(lang,0) or synthCache
 def android_main_menu():
   while True:
     menu=[]
@@ -1632,7 +1634,7 @@ def gui_event_loop():
             if not app: break
             found=0
             for f in scanDirs()[0].keys():
-              if not f in oldLs and (f.lower().endswith(dotmp3) or f.lower().endswith(dotwav)) and getYN("Use "+f[f.rfind(os.sep)+1:]+"?"): # TODO don't ask this question too many times if there are many and they're all 'no'
+              if not checkIn(f,oldLs) and (f.lower().endswith(dotmp3) or f.lower().endswith(dotwav)) and getYN("Use "+f[f.rfind(os.sep)+1:]+"?"): # TODO don't ask this question too many times if there are many and they're all 'no'
                 system("mp3gain -r -s r -k -d 10 \""+f+"\"") # (if mp3gain command is available; ignore errors if not (TODO document in advanced.txt)) (note: doing here not after the move, in case synthCache is over ftpfs mount or something)
                 uf=scFile=text1.encode("utf-8")+"_"+secondLanguage+f[-4:].lower()
                 try:
@@ -1668,7 +1670,7 @@ def gui_event_loop():
                 if system("""tar -zxvf lame*.tar.gz && cd lame-* && if ./configure && make; then ln -s $(pwd)/frontend/lame ../lame || true; else cd .. ; rm -rf lame*; exit 1; fi"""): app.todo.alert = "Compile failed"
           app.todo.set_main_menu = 1
         elif (menu_response=="add" or menu_response=="replace") and not (app.Text1.get() and app.Text2.get()): app.todo.alert="You need to type text in both boxes before adding the word/meaning pair to "+vocabFile
-        elif menu_response=="add" and hasattr(app,"vocabList") and (asUnicode(app.Text1.get()),asUnicode(app.Text2.get())) in app.vocabList:
+        elif menu_response=="add" and hasattr(app,"vocabList") and checkIn((asUnicode(app.Text1.get()),asUnicode(app.Text2.get())),app.vocabList):
             # Trying to add a word that's already there - do we interpret this as a progress adjustment?
             app.set_watch_cursor = 1
             t1,t2 = asUnicode(app.Text1.get()),asUnicode(app.Text2.get())
@@ -1680,7 +1682,7 @@ def gui_event_loop():
             for listToCheck in [d.data,d.unavail]:
               if found: break
               for item in listToCheck:
-                if (item[1]==l1find or (type(item[1])==type([]) and l1find in item[1])) and item[2]=="!synth:"+lang2.encode('utf-8')+"_"+secondLanguage:
+                if (item[1]==l1find or (type(item[1])==type([]) and checkIn(l1find,item[1]))) and item[2]=="!synth:"+lang2.encode('utf-8')+"_"+secondLanguage:
                     if not item[0]: break # not done yet - as not-found
                     newItem0 = reviseCount(item[0])
                     app.unset_watch_cursor = 1
@@ -1719,7 +1721,7 @@ def gui_event_loop():
                 lang2,lang1=lang2.lower(),lang1.lower() # because it's .lower()'d in progress.txt
                 l1find = B("!synth:")+lang1.encode('utf-8')+B("_"+firstLanguage)
                 for item in d.data:
-                    if (item[1]==l1find or (type(item[1])==type([]) and l1find in item[1])) and item[2]==B("!synth:")+lang2.encode('utf-8')+B("_"+secondLanguage) and item[0]:
+                    if (item[1]==l1find or (type(item[1])==type([]) and checkIn(l1find,item[1]))) and item[2]==B("!synth:")+lang2.encode('utf-8')+B("_"+secondLanguage) and item[0]:
                         app.unset_watch_cursor = 1
                         if not getYN(localise("You have repeated %s=%s %d times.  Do you want to pretend you already repeated %s=%s %d times?") % (S(lang2),S(lang1),item[0],S(t2),S(t1),item[0])):
                             app.set_watch_cursor = 1 ; break
diff --git a/gradint-build/src/lessonplan.py b/gradint-build/src/lessonplan.py
index b3d16b6569556268bdf9ea035a52aa08425e12d3..188765d0f6b2f45ac0758f1b867c1c011670aabd 100644
--- a/gradint-build/src/lessonplan.py
+++ b/gradint-build/src/lessonplan.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
@@ -25,22 +25,24 @@ class ProgressDatabase(object):
           self.unavail = mergeProgress(self.data,scanSamples()+parseSynthVocab(vocabFile))
           if not cache_maintenance_mode:
             doLabel("Checking transliterations")
+            global tList # for Python 2.1
             tList = {}
             def addVs(ff,dirBase):
                 if dirBase: dirBase += os.sep
                 dirBase,ff = B(dirBase),B(ff)
-                if dirBase+ff in variantFiles:
+                if checkIn(dirBase+ff,variantFiles):
                    if B(os.sep) in ff: ffpath=ff[:ff.rfind(B(os.sep))+1]
                    else: ffpath=B("")
-                   variantList=map(lambda x:ffpath+B(x),variantFiles[dirBase+ff])
+                   variantList=map(lambda x,f=ffpath:f+B(x),variantFiles[dirBase+ff])
                 else: variantList = [ff]
                 l=languageof(ff)
                 for f in variantList:
                   f = B(f)
-                  if f.lower().endswith(B(dottxt)): text=u8strip(read(dirBase+f)).strip(bwsp)
+                  if f.lower().endswith(B(dottxt)):
+                      text=bwspstrip(u8strip(read(dirBase+f)))
                   elif f.find(B("!synth"))==-1: continue # don't need to translit. filenames of wav's etc
                   else: text = textof(f)
-                  if not l in tList: tList[l]={}
+                  if not checkIn(l,tList): tList[l]={}
                   tList[l][text]=1
             for ff in availablePrompts.lsDic.values(): addVs(ff,promptsDirectory)
             for _,l1,l2 in self.data:
@@ -50,6 +52,7 @@ class ProgressDatabase(object):
             for lang,dic in list(tList.items()):
                 s = get_synth_if_possible(lang,0)
                 if s and hasattr(s,"update_translit_cache"): s.update_translit_cache(lang,list(dic.keys()))
+            del tList
         self.didScan = alsoScan
     def _load_from_binary(self):
         if pickledProgressFile and fileExists(pickledProgressFile):
@@ -173,7 +176,7 @@ class ProgressDatabase(object):
         if hasattr(self,"previous_filesNotPlayed"):
             i=0
             while i<len(filesNotPlayed):
-                if filesNotPlayed[i] in self.previous_filesNotPlayed: i+=1
+                if checkIn(filesNotPlayed[i],self.previous_filesNotPlayed): i+=1
                 else: del filesNotPlayed[i] # cumulative effects if managed to play it last time but not this time (and both lessons incomplete)
         self.previous_filesNotPlayed = filesNotPlayed = list2set(filesNotPlayed)
         if not filesNotPlayed:
@@ -187,7 +190,7 @@ class ProgressDatabase(object):
             l.append(self.data[i][2])
             found=0
             for ii in l:
-              if ii in filesNotPlayed:
+              if checkIn(ii,filesNotPlayed):
                   self.data[i] = self.oldData[i]
                   found=1 ; break
             if not found and not self.data[i] == self.oldData[i]: changed = 1
@@ -237,16 +240,16 @@ class ProgressDatabase(object):
                     raise
         # Add note on "long pause", for beginners
         longpause = "longpause_"+firstLanguage
-        if not advancedPromptThreshold and not longpause in availablePrompts.lsDic: longpause = "longpause_"+secondLanguage
+        if not advancedPromptThreshold and not checkIn(longpause,availablePrompts.lsDic): longpause = "longpause_"+secondLanguage
         o=maxLenOfLesson ; maxLenOfLesson = max(l.events)[0]
-        if longpause in availablePrompts.lsDic and self.promptsData.get(longpause,0)==0:
+        if checkIn(longpause,availablePrompts.lsDic) and self.promptsData.get(longpause,0)==0:
             try:
-                def PauseEvent(): return fileToEvent(availablePrompts.lsDic[longpause],promptsDirectory)
-                firstPauseMsg = PauseEvent()
+                def PauseEvent(longpause): return fileToEvent(availablePrompts.lsDic[longpause],promptsDirectory)
+                firstPauseMsg = PauseEvent(longpause)
                 # the 1st potentially-awkward pause is likely to be a beepThreshold-length one
                 l.addSequence([GluedEvent(Glue(1,maxLenOfLesson),CompositeEvent([firstPauseMsg,Event(max(5,beepThreshold-firstPauseMsg.length))]))],False)
                 while True:
-                    l.addSequence([GluedEvent(Glue(1,maxLenOfLesson),CompositeEvent([PauseEvent(),Event(50)]))],False)
+                    l.addSequence([GluedEvent(Glue(1,maxLenOfLesson),CompositeEvent([PauseEvent(longpause),Event(50)]))],False)
                     self.promptsData[longpause] = 1
             except StretchedTooFar: pass
         maxLenOfLesson = o
@@ -279,7 +282,7 @@ class ProgressDatabase(object):
             numFailures = 0 ; startTime = time.time() # for not taking too long
             for i in xrange(len(self.data)):
                 if maxNumToAdd>-1 and numberAdded >= maxNumToAdd: break # too many
-                if i in self.exclude: continue # already had it
+                if checkIn(i,self.exclude): continue # already had it
                 (timesDone,promptFile,zhFile)=self.data[i]
                 if timesDone < minTimesDone or (maxTimesDone>=0 and timesDone > maxTimesDone): continue # out of range this time
                 if timesDone >= knownThreshold: thisNumToTry = min(random.choice([2,3,4]),numToTry)
@@ -288,7 +291,7 @@ class ProgressDatabase(object):
                     # dropping it at random
                     self.exclude[i] = 1 # pretend we've done it
                     continue
-                if i in self.do_as_poem:
+                if checkIn(i,self.do_as_poem):
                     # this is part of a "known poem" and let's try to do it in sequence
                     self.try_add_poem(self.do_as_poem[i]) ; continue
                 oldPromptsData = self.promptsData.copy()
@@ -298,7 +301,7 @@ class ProgressDatabase(object):
                 if not timesDone and type(promptFile)==type([]):
                     # for poems: if any previously-added new word makes part of the prompt, try to ensure this one is introduced AFTER that one
                     for f,t in list(newWordTimes.items()):
-                        if f in promptFile: earliestAllowedEvent = max(earliestAllowedEvent,t)
+                        if checkIn(f,promptFile): earliestAllowedEvent = max(earliestAllowedEvent,t)
                 if not timesDone: newWordTimes[zhFile] = maxLenOfLesson # by default (couldn't fit it in).  (add even if not type(promptFile)==type([]), because it might be a first line)
                 try: self.l.addSequence(seq)
                 except StretchedTooFar: # If this happens, couldn't fit the word in anywhere.  If this is "filling in gaps" then it's likely that we won't be able to fit in any more words this lesson, so stop trying.
@@ -447,9 +450,9 @@ def denumber_synth(z,also_norm_extsep=0):
 
 def norm_filelist(x,y):
     def noext(x): return (B(x)+B(' '))[:B(x).rfind(B(extsep))] # so user can change e.g. wav to mp3 without disrupting progress.txt (the ' ' is simply removed if rfind returns -1)
-    if type(x)==type([]): x=tuple(map(lambda z:denumber_synth(noext(z),1),x))
+    if type(x)==type([]): x=tuple(map(lambda z,noext=noext:denumber_synth(noext(z),1),x))
     else: x=denumber_synth(noext(x),1)
-    if type(y)==type([]): y=tuple(map(lambda z:denumber_synth(noext(z),1),y))
+    if type(y)==type([]): y=tuple(map(lambda z,noext=noext:denumber_synth(noext(z),1),y))
     else: y=denumber_synth(noext(y),1)
     return (x,y)
 def mergeProgress(progList,scan):
@@ -475,7 +478,7 @@ def mergeProgress(progList,scan):
     renames = {}
     for (_,j,k) in scan:
         key = norm_filelist(j,k)
-        if key in proglistDict:
+        if checkIn(key,proglistDict):
             # an existing item - but in the case of synth'd vocab, we need to take the capitals/lower-case status from the scan rather than from the progress file (see comment above in denumber_synth) so:
             progList[proglistDict[key]]=(progList[proglistDict[key]][0],j,k)
         elif type(key[0])==type("") and (key[0]+key[1]).find("!synth")==-1 and ("_" in key[0] and "_" in key[1]):
@@ -488,8 +491,8 @@ def mergeProgress(progList,scan):
                 while ki>lastDirsep and not "0"<=normK[ki]<="9": ki -= 1
                 if ki<=lastDirsep: break
                 key2 = (key[0][:ki+1]+key[0][key[0].rindex("_"):],key[1][:ki+1]+key[1][key[1].rindex("_"):])
-                if key2 in proglistDict:
-                    if not key2 in renames: renames[key2] = []
+                if checkIn(key2,proglistDict):
+                    if not checkIn(key2,renames): renames[key2] = []
                     renames[key2].append((j,k))
                     found=1 ; break
                 while ki>lastDirsep and "0"<=normK[ki]<="9": ki -= 1
@@ -497,7 +500,7 @@ def mergeProgress(progList,scan):
         else: progList.append((0,j,k)) # ditto
         scanlistDict[key]=1
     for k,v in list(renames.items()):
-        if k in scanlistDict or len(v)>1: # can't make sense of this one - just add the new stuff
+        if checkIn(k,scanlistDict) or len(v)>1: # can't make sense of this one - just add the new stuff
             for jj,kk in v: progList.append((0,jj,kk))
         else: progList[proglistDict[k]]=(progList[proglistDict[k]][0],v[0][0],v[0][1])
     # finally, separate off any with non-0 progress that are
@@ -507,7 +510,7 @@ def mergeProgress(progList,scan):
     n = 0 ; unavailList = []
     while n<len(progList):
         i,j,k = progList[n]
-        if not norm_filelist(j,k) in scanlistDict:
+        if not checkIn(norm_filelist(j,k), scanlistDict):
             unavailList.append((i,j,k))
             del progList[n]
         else: n += 1
@@ -563,11 +566,11 @@ def jitter(list):
     # need to work on them more quickly, and can limit manually
     limitCounts = {} ; i = 0 ; imax = len(list)
     while i < imax:
-        if list[i][0]==0 and (list[i][-1] in limitedFiles): # or not languageof(list[i][2])==secondLanguage):
+        if list[i][0]==0 and checkIn(list[i][-1],limitedFiles): # or not languageof(list[i][2])==secondLanguage):
             # if not languageof(list[i][2])==secondLanguage: countNo="other-langs"
             # else:
             countNo = limitedFiles[list[i][-1]]
-            if not countNo in limitCounts: limitCounts [countNo] = 0
+            if not checkIn(countNo,limitCounts): limitCounts [countNo] = 0
             limitCounts [countNo] += 1
             # (below is a hack: if already moved something, set limit_words to 1.  May want to do it better than that e.g. go back and ensure the first thing only left 1 as well, or share out limit_words among any concurrently available new items that are just about to be introduced)
             if limitCounts [countNo] > cond(imax==len(list),limit_words,1) or (countNo=="other-langs" and limitCounts [countNo] > 1):
@@ -592,14 +595,14 @@ def find_known_poems(progressData):
             nextLineDic[line]=response # TODO check what would happen if 2 different poems in vocab.txt share an identical line (or if responseIndex is ambiguous in any way)
             hasPreviousLine[response]=True
     poems = []
-    for poemFirstLine in filter(lambda x:not x in hasPreviousLine,nextLineDic.keys()):
+    for poemFirstLine in filter(lambda x,hasPreviousLine=hasPreviousLine:not x in hasPreviousLine,nextLineDic.keys()):
         poemLines = [] ; line = poemFirstLine
         poem_is_viable = True
         while True:
             poemLines.append(line)
-            if not line in responseIndex or progressData[responseIndex[line]][0] < reallyKnownThreshold:
+            if not checkIn(line,responseIndex) or progressData[responseIndex[line]][0] < reallyKnownThreshold:
                 poem_is_viable = False ; break # whole poem not in database yet, or is but not well-rehearsed
-            if not line in nextLineDic: break
+            if not checkIn(line,nextLineDic): break
             line = nextLineDic[line]
         if poem_is_viable: poems.append(poemLines)
     return poems, responseIndex
diff --git a/gradint-build/src/loop.py b/gradint-build/src/loop.py
index db5cb9ff8ade86a81046f1aebda96d673179ed65..7205c3bc46b32338f3006b710b8e5a1ef4236a86 100644
--- a/gradint-build/src/loop.py
+++ b/gradint-build/src/loop.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
diff --git a/gradint-build/src/makeevent.py b/gradint-build/src/makeevent.py
index b6e1e3a39f414fedfe0702a9e3cbf713f6f49642..b814afeb190f4d3a4700e4e115686be506aefce9 100644
--- a/gradint-build/src/makeevent.py
+++ b/gradint-build/src/makeevent.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
@@ -17,7 +17,7 @@ def filesToEvents(files,dirBase=None):
     # (e.g. when learning poetry, may have a composite
     # prompt)
     if not type(files)==type([]): files = [files]
-    return CompositeEvent(map(lambda x:fileToEvent(x,dirBase),files))
+    return CompositeEvent(map(lambda x,d=dirBase:fileToEvent(x,d),files))
 
 class Partials_Synth(Synth):
     # text is really a list of lists of filenames
@@ -45,7 +45,7 @@ class Partials_Synth(Synth):
             datFileInUse = 0 ; assert type(phrase)==type([])
             for f in phrase:
                 f = S(f)
-                if f in audioDataPartials:
+                if checkIn(f,audioDataPartials):
                     datFile,offset,size = audioDataPartials[f]
                     if not datFileInUse: datFileInUse = open(partialsDirectory+os.sep+datFile,"rb")
                     datFileInUse.seek(offset) ; o.write(datFileInUse.read(size))
@@ -65,11 +65,11 @@ def fileToEvent(fname,dirBase=None):
     if B(os.sep) in fname and fname.find(B("!synth:"))==-1: dirBase,fname = dirBase+fname[:fname.rindex(B(os.sep))+1], fname[fname.rindex(B(os.sep))+1:]
     if B("_") in fname: lang = languageof(fname)
     else: lang="-unknown-" # so can take a simple wav file, e.g. for endAnnouncement
-    if dirBase+fname in variantFiles:
+    if checkIn(dirBase+fname,variantFiles):
         variantFiles[dirBase+fname]=variantFiles[dirBase+fname][1:]+[variantFiles[dirBase+fname][0]] # cycle through the random order of variants
         fname=B(variantFiles[dirBase+fname][0])
     if fname.lower().endswith(B(dottxt)) and B("_") in fname:
-        ftxt = u8strip(read(dirBase+fname)).strip(bwsp)
+        ftxt = bwspstrip(u8strip(read(dirBase+fname)))
         if not ftxt: raise MessageException(B("File ")+fname+B(" in ")+dirBase+B(" has no text in it; please fix this")) # nicer message than catching it at a lower level
         fname = B("!synth:")+B(ftxt)+B('_')+B(lang)
     if fname.find(B("!synth:"))>=0:
@@ -119,11 +119,11 @@ if synthCache:
     synthCache_contents = list2dict(synthCache_contents) # NOT 2set, as the GUI can delete things from it
     if riscos_sound: show_info("done\n")
 synthCache_transtbl = {}
-if synthCache and B(transTbl) in synthCache_contents:
+if synthCache and checkIn(B(transTbl),synthCache_contents):
     ensure_nodups = {} # careful of duplicate filenames being appended to trans.tbl, make sure they override previous entries
     for l in open(synthCache+os.sep+transTbl,'rb').readlines():
-        v,k = l.strip(bwsp).split(None,1)
-        if v in ensure_nodups: del synthCache_transtbl[ensure_nodups[v]]
+        v,k = bwspstrip(l).split(None,1)
+        if checkIn(v,ensure_nodups): del synthCache_transtbl[ensure_nodups[v]]
         ensure_nodups[v]=k ; synthCache_transtbl[k]=v
     del ensure_nodups
 def textof(fname):
@@ -138,7 +138,7 @@ def synthcache_lookup(fname,dirBase=None,printErrors=0,justQueryCache=0,lang=Non
     if not lang: lang = languageof(fname)
     fname = B(fname)
     if fname.lower().endswith(B(dottxt)):
-        try: fname = fname[:fname.rfind(B("_"))]+B("!synth:")+u8strip(read(S(B(dirBase)+B(fname)))).strip(bwsp)+B("_")+B(lang)
+        try: fname = fname[:fname.rfind(B("_"))]+B("!synth:")+bwspstrip(u8strip(read(S(B(dirBase)+B(fname)))))+B("_")+B(lang)
         except IOError: return 0,0 # probably trying to synthcache_lookup a file with variants without first choosing a variant (e.g. in anticipation() to check for sporadic cache entries in old words) - just ignore this
     text = textof(fname)
     useSporadic = -1 # undecided (no point accumulating counters for potentially-unbounded input)
@@ -149,8 +149,8 @@ def synthcache_lookup(fname,dirBase=None,printErrors=0,justQueryCache=0,lang=Non
         for ext in "wav","mp3":
             k=B(init)+text.lower()+B("_"+lang+extsep+ext)
             s=B(synthCache_transtbl.get(k,k))
-            if s in synthCache_contents: ret=s
-            elif s.lower().endswith(B(dotwav)) and s[:-len(dotwav)]+B(dotmp3) in synthCache_contents: ret=s[:-len(dotwav)]+B(dotmp3)
+            if checkIn(s,synthCache_contents): ret=s
+            elif s.lower().endswith(B(dotwav)) and checkIn(s[:-len(dotwav)]+B(dotmp3),synthCache_contents): ret=s[:-len(dotwav)]+B(dotmp3)
             else: ret=0
             if ret:
                 if justQueryCache==1: ret=(k,ret)
@@ -160,8 +160,8 @@ def synthcache_lookup(fname,dirBase=None,printErrors=0,justQueryCache=0,lang=Non
                     elif tryHarder: tryHarder=ret
                 else: return ret
     if justQueryCache==1: return 0,0
-    if lang not in synth_partials_voices: l,translit=None,None # don't bother trying to transliterate here if there aren't even any partials for that language
-    elif (lang,text) not in synth_partials_cache:
+    if not checkIn(lang,synth_partials_voices): l,translit=None,None # don't bother trying to transliterate here if there aren't even any partials for that language
+    elif not checkIn((lang,text),synth_partials_cache):
         # See if we can transliterate the text first.
         synth,translit = get_synth_if_possible(lang,0,to_transliterate=True),None
         if espeak_language_aliases.get(lang,lang) in ["zhy","zh-yue"]:
@@ -174,7 +174,7 @@ def synthcache_lookup(fname,dirBase=None,printErrors=0,justQueryCache=0,lang=Non
         if lang=="zh": t2=sort_out_pinyin_3rd_tones(pinyin_uColon_to_V(t2)) # need to do this BEFORE stripPuncEtc, for correct sandhi blocking
         phraseList = stripPuncEtc(t2.lower())
         l = [synth_from_partials(phrase,lang) for phrase in phraseList] # TODO do we really want to be able to pick new voices at every phrase?  if not, would have to pass the pause points into synth_from_partials itself
-        if None in l: # at least one of the partials-phrases failed
+        if checkIn(None,l): # at least one of the partials-phrases failed
           global scl_disable_recursion
           if len(t2)<100 or not filter(lambda x:x,l) or scl_disable_recursion: l=None # don't mix partials and synth for different parts of a short phrase, it's too confusing (TODO make the 100 configurable?)
           elif type(get_synth_if_possible(lang,0))==EkhoSynth: l=None # some faulty versions of Ekho are more likely to segfault if called on fragments (e.g. if the fragment ends with some English), so don't do this with Ekho (unless can confirm it's at least ekho_4.5-2ubuntu10.04 .. not all versions of ekho can report their version no.)
@@ -260,7 +260,7 @@ def filename2unicode(f):
     return u
 def unicode2filename(u):
     if using_unicode_filenames: return u
-    if u in non_normal_filenames: return non_normal_filenames[u]
+    if checkIn(u,non_normal_filenames): return non_normal_filenames[u]
     f=u.encode("unicode_escape").replace("\\u","_u")
     for unsafe_char in "?+*<=": f=f.replace(unsafe_char,"_u%04x" % ord(unsafe_char))
     return f
@@ -289,7 +289,7 @@ if partialsDirectory and isDirectory(partialsDirectory):
     langs = os.listdir(partialsDirectory)
     dirsToStat.append((partialsDirectory,os.stat(partialsDirectory)))
     audioDataPartials = {} ; synth_partials_voices = {}
-    partials_raw_mode = "header"+dotwav in langs
+    partials_raw_mode = checkIn("header"+dotwav,langs)
     for l in langs:
         try: voices = os.listdir(partialsDirectory+os.sep+l)
         except: voices = []
@@ -298,7 +298,7 @@ if partialsDirectory and isDirectory(partialsDirectory):
         for v in voices:
             if "-" in v and v[:v.index("-")] in voices:
               suffix=v[v.index("-"):]
-              if not suffix in guiVoiceOptions: guiVoiceOptions.append(suffix)
+              if not checkIn(suffix,guiVoiceOptions): guiVoiceOptions.append(suffix)
             start,mid,end = [],[],[] ; flags=0
             try: files = os.listdir(partialsDirectory+os.sep+l+os.sep+v)
             except: files = []
@@ -319,7 +319,7 @@ if partialsDirectory and isDirectory(partialsDirectory):
                         fftell = ff.tell()
                         char = ff.read(1)
                         if not B("0")<=char<=B("9"): break
-                        size,fname = (char+ff.readline(256)).strip(bwsp).split(None,1)
+                        size,fname = bwspstrip(char+ff.readline(256)).split(None,1)
                         try: size=int(size)
                         except: break # binary just happened to start with "0"-"9"
                         fname = S(fname)
@@ -350,12 +350,12 @@ if partialsDirectory and isDirectory(partialsDirectory):
                     kLen=min(kLen,len(key))
                 l = {}
                 for k,i in l2:
-                    if not k[:kLen] in l: l[k[:kLen]]=[]
+                    if not checkIn(k[:kLen],l): l[k[:kLen]]=[]
                     l[k[:kLen]].append((k,i))
                 return l
             thisLangVoices.append((v,toDict(start),toDict(mid),toDict(end),flags))
         synth_partials_voices[l] = thisLangVoices
-        if l in espeak_language_aliases: partials_language_aliases[espeak_language_aliases[l]]=l
+        if checkIn(l,espeak_language_aliases): partials_language_aliases[espeak_language_aliases[l]]=l
     if riscos_sound or winCEsound: show_info("done\n")
     if pickle:
       try: pickle.Pickler(open(partials_cache_file,"wb"),-1).dump((partialsCacheFormat,eval(partialsCacheFormat)))
@@ -366,7 +366,7 @@ if partialsDirectory and isDirectory(partialsDirectory):
     partials_raw_0bytes = int(betweenPhrasePause*wrate)*wchannels*(wbits/8)
 else: synth_partials_voices,partials_raw_mode = {},None
 
-if "cant" in synth_partials_voices: synth_partials_voices["zhy"]=synth_partials_voices["zh-yue"]=synth_partials_voices["cant"]
+if checkIn("cant",synth_partials_voices): synth_partials_voices["zhy"]=synth_partials_voices["zh-yue"]=synth_partials_voices["cant"]
 
 def partials_langname(lang):
     lang = espeak_language_aliases.get(lang,lang)
@@ -375,7 +375,7 @@ def partials_langname(lang):
 
 def synth_from_partials(text,lang,voice=None,isStart=1):
     lang = partials_langname(lang)
-    text=B(text).strip(bwsp) # so whitespace between words is ignored on the recursive call
+    text=bwspstrip(B(text)) # so whitespace between words is ignored on the recursive call
     if lang=="zh": # hack for Mandarin - higher tone 5 after a tone 3 (and ma5 after 4 or 5 also)
         lastNum = None
         for i in range(len(text)):
@@ -389,7 +389,7 @@ def synth_from_partials(text,lang,voice=None,isStart=1):
                 elif lastNum: break # don't look beyond 1st 2
                 lastNum = text[i:i+1]
     if not voice: # try all voices for the language, see if we can find one that can say all the necessary parts
-        if not lang in synth_partials_voices: return None
+        if not checkIn(lang,synth_partials_voices): return None
         needCalibrated=False
         if lang=="zh": # hack for Mandarin - avoid consecutive 1st tones on non-calibrated voices
             # (DON'T do 3rd tone sandhi here - that's the caller's responsibility and we don't want it done twice now there's sandhi-blocking rules)
@@ -412,7 +412,7 @@ def synth_from_partials(text,lang,voice=None,isStart=1):
             r = synth_from_partials(text,lang,v)
             if r:
                 if partials_raw_mode and v[-1]&2: r.insert(0,"header"+dotwav)
-                return map(lambda x:lang+os.sep+v[0]+os.sep+x,r)
+                return map(lambda x,v=v,lang=lang:lang+os.sep+v[0]+os.sep+x,r)
         return None
     dir, start, mid, end, flags = voice
     def lookup_dic(text,dic):
diff --git a/gradint-build/src/play.py b/gradint-build/src/play.py
index 3a8917872c2e86a63e4e3e4b11787985410a0704..6c3e5659e15c93ca1ea157d48757a1cff15cfc07 100644
--- a/gradint-build/src/play.py
+++ b/gradint-build/src/play.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
@@ -27,7 +27,7 @@ def play(event):
     timeout_time = time.time() + max(10,event.length/3) # don't loop *forever* if unable to start playing (especially if we're being used in a reminder system etc, it may be best to exit eventually)
     if lessonStartTime and not soundCollector:
         if hasattr(event,"max_lateness"): timeout_time = min(timeout_time, lessonStartTime + (copy_of_runner_events[0][2]+event.max_lateness))
-        if hasattr(event,"sequenceID") and event.sequenceID in sequenceIDs_to_cancel: timeout_time = 0
+        if hasattr(event,"sequenceID") and checkIn(event.sequenceID,sequenceIDs_to_cancel): timeout_time = 0
     play_error = "firstTime"
     while play_error and time.time()<=timeout_time: # use <= rather than < in case we have only 1sec precision
         if not play_error=="firstTime":
@@ -102,7 +102,9 @@ def maybe_unicode(label):
 
 if (winsound or mingw32) and fileExists("madplay.exe"): madplay_path = "madplay.exe"
 elif unix and hasattr(os,"popen"):
-  madplay_path = os.popen("PATH=$PATH:. which madplay 2>/dev/null").read().strip(wsp)
+  madplay_path = os.popen("PATH=$PATH:. which madplay 2>/dev/null").read()
+  try: madplay_path = wspstrip(madplay_path)
+  except: madplay_path = madplay_path.strip()
   if not fileExists(cond(cygwin,madplay_path+".exe",madplay_path)): madplay_path="" # in case of a Unix 'which' returning error on stdout
   if madplay_path and not winsound and not mingw32: madplay_path='"'+madplay_path+'"' # in case there's spaces etc in the path
 else: madplay_path = None
@@ -605,7 +607,7 @@ tail -1 "$S" | bash\nexit\n""" % (sox_16bit,sox_signed) # S=script P=params for
     def addFile(self,file,length):
         fileType=soundFileType(file)
         self.seconds += length
-        if not file in self.file2command:
+        if not checkIn(file,self.file2command):
             if fileType=="mp3": fileData,fileType = decode_mp3(file),"wav" # because remote sox may not be able to do it
             elif compress_SH and unix: handle=os.popen("cat \""+file+"\" | sox -t "+fileType+" - -t "+fileType+" "+sox_8bit+" - 2>/dev/null",popenRB) # 8-bit if possible (but don't change sample rate, as we might not have floating point)
             else: handle = open(file,"rb")
@@ -696,7 +698,7 @@ class Mp3FileCache(object):
             except: pass # somebody may have removed it already
         except: pass
     def decode_mp3_to_tmpfile(self,file):
-        if not file in self.fileCache:
+        if not checkIn(file,self.fileCache):
             self.fileCache[file] = os.tempnam()+dotwav
             write(self.fileCache[file],decode_mp3(file))
         return self.fileCache[file]
diff --git a/gradint-build/src/recording.py b/gradint-build/src/recording.py
index fddcac5ee4016fde973f091e79f3d1cfb65eeed8..b2f01e15d04e01cda02b220454d7662f5d455196 100644
--- a/gradint-build/src/recording.py
+++ b/gradint-build/src/recording.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
@@ -87,10 +87,10 @@ class PlayerInput(InputSource): # play to speakers while recording to various de
         if elapsedTime>=self.length-self.startSample*1.0/self.sampleRate: self.close()
         else:
             import thread
-            def stopMe(self,thread_id):
+            def stopMe(self,thread_id,elapsedTime):
                 time.sleep(max(0.5,self.length-self.startSample*1.0/self.sampleRate-elapsedTime))
                 self.autostop(thread_id)
-            thread.start_new_thread(stopMe,(self,thread_id))
+            thread.start_new_thread(stopMe,(self,thread_id,elapsedTime))
     def elapsedTime(self):
         try: t=tkSnack.audio.elapsedTime()
         except: t=0.0
@@ -241,7 +241,7 @@ def doAmplify(directory,fileList,factor):
 class ButtonScrollingMixin(object):
     # expects self.ourCanvas
     def bindFocusIn(self,b):
-        b.bind('<FocusIn>',lambda *args:self.scrollIntoView(b))
+        b.bind('<FocusIn>',lambda e=None,self=self,b=b:self.scrollIntoView(b))
         if not hasattr(app,"gotFocusInHandler"):
             # (see scrollIntoView method's use of justGotFocusIn)
             app.gotFocusInHandler=1
@@ -266,7 +266,7 @@ class ButtonScrollingMixin(object):
             if by+bh<=cy+ch: return # make this the last one - don't loop consuming CPU on bottom of list
         elif by < cy: self.ourCanvas.yview("scroll","-1","units")
         else: return # done
-        app.after(10,lambda *args:self.continueScrollIntoView(button))
+        app.after(10,lambda e=None,self=self,button=button:self.continueScrollIntoView(button))
 
 class RecorderControls(ButtonScrollingMixin):
     def __init__(self):
@@ -304,7 +304,7 @@ class RecorderControls(ButtonScrollingMixin):
         self.frame.pack_forget()
         theISM.setInputSource(None)
     def addButton(self,row,col,text,command,colspan=None):
-        if (row,col) in self.coords2buttons: self.coords2buttons[(row,col)].grid_forget()
+        if checkIn((row,col),self.coords2buttons): self.coords2buttons[(row,col)].grid_forget()
         b = makeButton(self.grid,text=text,command=command)
         self.bindFocusIn(b)
         self.coords2buttons[(row,col)] = b
@@ -317,17 +317,18 @@ class RecorderControls(ButtonScrollingMixin):
         if col: self.coords2buttons[(row,col)].grid(row=row,column=col,columnspan=colspan)
         else: self.coords2buttons[(row,col)].grid(row=row,column=0,columnspan=colspan,sticky="w")
     def addLabel(self,row,col,utext):
-        if (row,col) in self.coords2buttons: self.coords2buttons[(row,col)].grid_forget()
+        if checkIn((row,col),self.coords2buttons): self.coords2buttons[(row,col)].grid_forget()
         rc = self.coords2buttons[(row,col)] = self.makeLabel_lenLimit(utext)
         rc.grid(row=row,column=col,sticky="w")
         if col==0:
-          rc.bind('<Button-1>',lambda *args:self.startRename(row,col,utext))
+          rc.bind('<Button-1>',lambda e=None,self=self,row=row,col=col,utext=utext:self.startRename(row,col,utext))
           if not winCEsound:
-            def contextMenu(e): # TODO: document this?
+            def contextMenu(e,row=row,col=col,self=self,utext=utext):
+                # TODO: document this context menu?
                 m=Tkinter.Menu(None, tearoff=0, takefocus=0)
-                m.add_command(label="Rename",command=lambda *args:self.startRename(row,col,utext))
-                if self.currentDir.startswith(samplesDirectory): m.add_command(label="Add extra revision",command=lambda *args:self.addRevision(utext))
-                m.add_command(label="Delete",command=lambda *args:self.delete(utext))
+                m.add_command(label="Rename",command=lambda e=None,self=self,row=row,col=col,utext=utext:self.startRename(row,col,utext))
+                if self.currentDir.startswith(samplesDirectory): m.add_command(label="Add extra revision",command=lambda e=None,self=self,utext=utext:self.addRevision(utext))
+                m.add_command(label="Delete",command=lambda e=None,self=self,utext=utext:self.delete(utext))
                 m.tk_popup(e.x_root-3, e.y_root+3,entry="0")
             rc.bind('<ButtonRelease-3>',contextMenu)
             if macsound:
@@ -361,11 +362,11 @@ class RecorderControls(ButtonScrollingMixin):
         if not found: tkMessageBox.showinfo(filename,localise("Repeat count is 0, so we cannot reduce it for extra revision."))
     def makeLabel_lenLimit(self,utext): return Tkinter.Label(self.grid,text=utext,wraplength=int(self.ourCanvas.winfo_screenwidth()/(1+len(self.languagesToDraw))))
     def addSynthLabel(self,filename,row,col):
-        try: ftext = ensure_unicode(u8strip(read(filename).strip(bwsp)))
+        try: ftext = ensure_unicode(u8strip(bwspstrip(read(filename))))
         except IOError: return False
         l = self.makeLabel_lenLimit(ftext)
         l.grid(row=row,column=col,columnspan=2,sticky="w")
-        l.bind('<Button-1>',lambda *args:self.startSynthEdit(l,row,col,filename))
+        l.bind('<Button-1>',lambda e=None,self=self,l=l,row=row,col=col,filename=filename:self.startSynthEdit(l,row,col,filename))
         return True # do NOT put it in self.coords2buttons (not to do with space bar stuff etc)
     def startSynthEdit(self,l,row,col,filename):
         if hasattr(self,"renameToCancel"):
@@ -373,29 +374,29 @@ class RecorderControls(ButtonScrollingMixin):
           self.cancelRename(rr,cc)
         if l: l.grid_forget()
         editText,editEntry = addTextBox(self.grid,"nopack")
-        try: editText.set(ensure_unicode(u8strip(read(filename).strip(bwsp))))
+        try: editText.set(ensure_unicode(u8strip(bwspstrip(read(filename)))))
         except IOError: pass
         editEntry.grid(row=row,column=col,sticky='we',columnspan=2)
-        editEntry.bind('<Return>',lambda *args:self.doEdit(editText,editEntry,row,col,filename))
-        editEntry.bind('<Escape>',lambda *args:self.cancelEdit(editEntry,row,col,filename))
+        editEntry.bind('<Return>',lambda e=None,self=self,editText=editText,editEntry=editEntry,row=row,col=col,filename=filename:self.doEdit(editText,editEntry,row,col,filename))
+        editEntry.bind('<Escape>',lambda e=None,self=self,editEntry=editEntry,row=row,col=col,filename=filename:self.cancelEdit(editEntry,row,col,filename))
         focusButton(editEntry)
         if hasattr(self.coords2buttons.get((row-1,col+1),""),"is_synth_label"):
             self.addLabel(row-1,col+1,localise("(synth'd)"))
             self.coords2buttons[(row-1,col+1)].is_synth_label = True
     def doEdit(self,editText,editEntry,row,col,filename):
-        text = asUnicode(editText.get()).encode("utf-8").strip(bwsp)
+        text = bwspstrip(asUnicode(editText.get()).encode("utf-8"))
         if text: writeB(open(filename,"w"),text+B("\n"))
         else:
             try: os.remove(filename)
             except: pass
         self.cancelEdit(editEntry,row,col,filename)
-        if row+1 < self.addMoreRow and (row+1,col+1) in self.coords2buttons: focusButton(self.coords2buttons[(row+1,col+1)]) # focus the next "synth" button if it exists (don't press it as well like file renaming because it might be a variant etc, TODO can we skip variants?)
+        if row+1 < self.addMoreRow and checkIn((row+1,col+1),self.coords2buttons): focusButton(self.coords2buttons[(row+1,col+1)]) # focus the next "synth" button if it exists (don't press it as well like file renaming because it might be a variant etc, TODO can we skip variants?)
     def cancelEdit(self,editEntry,row,col,filename):
         editEntry.grid_forget()
         labelAdded = self.addSynthLabel(filename,row,col)
         if hasattr(self.coords2buttons.get((row-1,col+1),""),"is_synth_label"):
             if labelAdded: self.addLabel(row-1,col+1,localise("(synth'd)"))
-            else: self.addButton(row-1,col+1,text=localise("Synthesize"),command=(lambda *args:self.startSynthEdit(None,row,col,filename)))
+            else: self.addButton(row-1,col+1,text=localise("Synthesize"),command=(lambda e=None,self=self,row=row,col=col,filename=filename:self.startSynthEdit(None,row,col,filename)))
             self.coords2buttons[(row-1,col+1)].is_synth_label = True
     def amplify(self,*args):
         self.AmplifyButton["text"] = localise("Please wait") # TODO not in the GUI thread !! (but lock our other buttons while it's doing it)
@@ -429,7 +430,7 @@ class RecorderControls(ButtonScrollingMixin):
             app.todo.alert=self.renamevar_msg
             return
         self.renameToCancel = (row,col)
-        if (row,col) in self.coords2buttons: self.coords2buttons[(row,col)].grid_forget()
+        if checkIn((row,col),self.coords2buttons): self.coords2buttons[(row,col)].grid_forget()
         renameText,renameEntry = addTextBox(self.grid,"nopack")
         renameEntry['width']=min(8,len(filename)+2)
         renameEntry.theText = renameText
@@ -446,18 +447,18 @@ class RecorderControls(ButtonScrollingMixin):
             selectAllFunc = selectAll
         class E: pass
         e=E() ; e.widget = renameEntry
-        self.ourCanvas.after(50,lambda *args:(e.widget.focus(),self.scrollIntoView(e.widget),selectAllFunc(e)))
-        renameEntry.bind('<Return>',lambda *args:self.doRename(row,col))
-        renameEntry.bind('<Escape>',lambda *args:self.cancelRename(row,col))
+        self.ourCanvas.after(50,lambda _=None,e=e,self=self,selectAllFunc=selectAllFunc:(e.widget.focus(),self.scrollIntoView(e.widget),selectAllFunc(e)))
+        renameEntry.bind('<Return>',lambda e=None,self=self,row=row,col=col:self.doRename(row,col))
+        renameEntry.bind('<Escape>',lambda e=None,self=self,row=row,col=col:self.cancelRename(row,col))
     def doRename(self,row,col):
         if hasattr(self,"renameToCancel"): del self.renameToCancel
         try: origName = self.coords2buttons[(row,col)].origName
         except AttributeError: return # event must have fired twice for some reason?
-        newNames = filter(lambda x:x,asUnicode(self.coords2buttons[(row,col)].theText.get()).split("\n")) # multiline paste, ignore blank lines
+        newNames = filter(lambda x,self=self,row=row,col=col:x,asUnicode(self.coords2buttons[(row,col)].theText.get()).split("\n")) # multiline paste, ignore blank lines
         for newName in newNames:
             if not origName: # extra lines - need to get their origNames
                 if row==self.addMoreRow: self.addMore()
-                elif not (row,col) in self.coords2buttons: row += 1 # skip extra row if there are notes
+                elif not checkIn((row,col),self.coords2buttons): row += 1 # skip extra row if there are notes
                 origName=self.coords2buttons[(row,col)]["text"]
             if self.has_variants and origName.find(" (")>=0:
                 app.todo.alert=self.renamevar_msg
@@ -471,7 +472,7 @@ class RecorderControls(ButtonScrollingMixin):
                 except:
                     tkMessageBox.showinfo(app.master.title(),localise("Could not rename %s to %s") % (origName,newName))
                     return
-                self.addButton(row,col,text=newName,command=(lambda e=None,f=self.currentDir+os.sep+newName:self.changeDir(f)))
+                self.addButton(row,col,text=newName,command=(lambda e=None,self=self,f=self.currentDir+os.sep+newName:self.changeDir(f)))
             else: # not a directory - rename individual files
                 self.doStop() # just in case
                 for lang in list2set([firstLanguage,secondLanguage]+otherLanguages+self.languagesToDraw): # not just self.languagesToDraw, as a student of more languages than these might not have them all showing and still expect renames to work
@@ -482,33 +483,33 @@ class RecorderControls(ButtonScrollingMixin):
                         except:
                             tkMessageBox.showinfo(app.master.title(),localise("Could not rename %s to %s") % (origName+"_"+lang+ext,newName+"_"+lang+ext)) # TODO undo any that did succeed first!  + check for destination-already-exists (OS may not catch it)
                             return
-                        if not lang in self.languagesToDraw: continue
+                        if not checkIn(lang,self.languagesToDraw): continue
                         self.updateFile(unicode2filename(newName+"_"+lang+ext),row,self.languagesToDraw.index(lang),cond(ext==dottxt,0,2)) # TODO the 2 should be 1 if and only if we didn't just record it
                         updated=True
-                    if not updated and lang in self.languagesToDraw: self.updateFile(unicode2filename(newName+"_"+lang+dotwav),row,self.languagesToDraw.index(lang),0)
+                    if not updated and checkIn(lang,self.languagesToDraw): self.updateFile(unicode2filename(newName+"_"+lang+dotwav),row,self.languagesToDraw.index(lang),0)
                 self.addLabel(row,col,newName)
             # TODO what about updating progress.txt with wildcard changes (cld be going too far - we have the move script in utilities)
             origName = None # get any others from the form
             row += 1
         if len(newNames)==1 and row<self.addMoreRow: # put cursor on the next one
-            if not (row,col) in self.coords2buttons: row += 1 # skip extra row if there are notes
+            if not checkIn((row,col),self.coords2buttons): row += 1 # skip extra row if there are notes
             if row<self.addMoreRow:
               origName=self.coords2buttons[(row,col)]["text"]
               if not isDirectory(unicode2filename(self.currentDir+os.sep+origName)): self.startRename(row,0,origName)
     def cancelRename(self,row,col):
         if hasattr(self,"renameToCancel"): del self.renameToCancel
         origName = self.coords2buttons[(row,col)].origName
-        if isDirectory(unicode2filename(self.currentDir+os.sep+origName)): self.addButton(row,col,text=origName,command=(lambda e=None,f=ensure_unicode(self.currentDir+os.sep+origName).encode('utf-8'):self.changeDir(f)))
+        if isDirectory(unicode2filename(self.currentDir+os.sep+origName)): self.addButton(row,col,text=origName,command=(lambda e=None,self=self,f=ensure_unicode(self.currentDir+os.sep+origName).encode('utf-8'):self.changeDir(f)))
         else: self.addLabel(row,col,origName)
     def updateFile(self,filename,row,languageNo,state,txtExists="unknown"): # state: 0 not exist, 1 already existed, 2 we just created it
         if not os.sep in filename: filename = self.currentDir+os.sep+filename
         recFilename = filename
         if recFilename.lower().endswith(dotmp3): recFilename=recFilename[:-len(dotmp3)]+dotwav # always record in WAV; can compress to MP3 after
         if state: # sound file exists
-            if not tkSnack or tkSnack=="MicOnly" or wavPlayer_override: self.addButton(row,2+3*languageNo,text=localise("Play"),command=(lambda e=None,f=filename:(self.doStop(),SampleEvent(f).play())))  # but if got full tkSnack, might as well use setInputSource instead to be consistent with the non-_ version:
-            else: self.addButton(row,2+3*languageNo,text=localise("Play"),command=(lambda e=None,f=filename:(self.doStop(),theISM.setInputSource(PlayerInput(f,not self.syncFlag)),self.setSync(False))))
+            if not tkSnack or tkSnack=="MicOnly" or wavPlayer_override: self.addButton(row,2+3*languageNo,text=localise("Play"),command=(lambda e=None,f=filename,self=self:(self.doStop(),SampleEvent(f).play())))  # but if got full tkSnack, might as well use setInputSource instead to be consistent with the non-_ version:
+            else: self.addButton(row,2+3*languageNo,text=localise("Play"),command=(lambda e=None,f=filename,self=self:(self.doStop(),theISM.setInputSource(PlayerInput(f,not self.syncFlag)),self.setSync(False))))
             if tkSnack and (state==2 or self.always_enable_rerecord):
-                self.addButton(row,3+3*languageNo,text=localise("Re-record"),command=(lambda e=None,f=recFilename,r=row,l=languageNo:self.doRecord(f,r,l,needToUpdatePlayButton=(not filename==recFilename))))
+                self.addButton(row,3+3*languageNo,text=localise("Re-record"),command=(lambda e=None,f0=filename,f=recFilename,r=row,l=languageNo,self=self:self.doRecord(f,r,l,needToUpdatePlayButton=(not f0==f))))
             else:
                 self.addLabel(row,3+3*languageNo,"")
                 self.need_reRecord_enabler = not (not tkSnack)
@@ -516,19 +517,19 @@ class RecorderControls(ButtonScrollingMixin):
             synthFilename = filename[:filename.rfind(extsep)]+dottxt
             if txtExists=="unknown": txtExists=fileExists(synthFilename)
             if txtExists: self.addLabel(row,2+3*languageNo,localise("(synth'd)"))
-            elif self.always_enable_synth and get_synth_if_possible(self.languagesToDraw[languageNo],0): self.addButton(row,2+3*languageNo,text=localise("Synthesize"),command=(lambda *args:self.startSynthEdit(None,row+1,1+3*languageNo,synthFilename)))
+            elif self.always_enable_synth and get_synth_if_possible(self.languagesToDraw[languageNo],0): self.addButton(row,2+3*languageNo,text=localise("Synthesize"),command=(lambda e=None,self=self,row=row,languageNo=languageNo,synthFilename=synthFilename:self.startSynthEdit(None,row+1,1+3*languageNo,synthFilename)))
             else: self.addLabel(row,2+3*languageNo,localise("(empty)"))
             self.coords2buttons[(row,2+3*languageNo)].is_synth_label = True
             if winCEsound and not tkSnack: self.addLabel(row,3+3*languageNo,"")
-            else: self.addButton(row,3+3*languageNo,text=localise("Record"),command=(lambda e=None,f=recFilename,r=row,l=languageNo:self.doRecord(f,r,l)))
+            else: self.addButton(row,3+3*languageNo,text=localise("Record"),command=(lambda e=None,f=recFilename,r=row,l=languageNo,self=self:self.doRecord(f,r,l)))
     def add_addMore_button(self):
         if winCEsound and not tkSnack: pass # no 'add more words' button on WinCE; use PocketPC record button instead
-        else: self.addButton(self.addMoreRow,0,text=localise("Add more words"),command=(lambda *args:self.addMore()),colspan=cond(self.need_reRecord_enabler,2,4))
-        if self.need_reRecord_enabler: self.addButton(self.addMoreRow,2,text=localise("Re-record"),command=(lambda *args:self.global_rerecord()),colspan=2)
-        self.addButton(self.addMoreRow,4,text=localise("New folder"),command=(lambda *args:self.newFolder()),colspan=3)
+        else: self.addButton(self.addMoreRow,0,text=localise("Add more words"),command=(lambda e=None,self=self:self.addMore()),colspan=cond(self.need_reRecord_enabler,2,4))
+        if self.need_reRecord_enabler: self.addButton(self.addMoreRow,2,text=localise("Re-record"),command=(lambda e=None,self=self:self.global_rerecord()),colspan=2)
+        self.addButton(self.addMoreRow,4,text=localise("New folder"),command=(lambda e=None,self=self:self.newFolder()),colspan=3)
     def del_addMore_button(self):
-        if (self.addMoreRow,0) in self.coords2buttons: self.coords2buttons[(self.addMoreRow,0)].grid_forget() # old 'add more' button
-        if (self.addMoreRow,2) in self.coords2buttons: self.coords2buttons[(self.addMoreRow,2)].grid_forget() # old 're-record' button
+        if checkIn((self.addMoreRow,0),self.coords2buttons): self.coords2buttons[(self.addMoreRow,0)].grid_forget() # old 'add more' button
+        if checkIn((self.addMoreRow,2),self.coords2buttons): self.coords2buttons[(self.addMoreRow,2)].grid_forget() # old 're-record' button
         self.coords2buttons[(self.addMoreRow,4)].grid_forget() # old 'new folder' button
     def addMore(self,*args):
         self.del_addMore_button()
@@ -549,7 +550,7 @@ class RecorderControls(ButtonScrollingMixin):
         self.coords2buttons[(row,3+3*languageNo)]["text"]=localise("Stop")
         self.updateForStopOrChange()
         self.currentRecording = (filename,row,languageNo)
-        self.coords2buttons[(row,3+3*languageNo)]["command"]=(lambda *args:self.doStop())
+        self.coords2buttons[(row,3+3*languageNo)]["command"]=(lambda e=None,self=self:self.doStop())
         if app.scanrow.get()=="2": # "stop"
           focusButton(self.coords2buttons[(row,3+3*languageNo)])
         else:
@@ -557,14 +558,14 @@ class RecorderControls(ButtonScrollingMixin):
           if app.scanrow.get()=="1": # move along 1st
             while languageNo+1<len(self.languagesToDraw):
               languageNo += 1
-              if (row,3+3*languageNo) in self.coords2buttons:
+              if checkIn((row,3+3*languageNo),self.coords2buttons):
                   focusButton(self.coords2buttons[(row,3+3*languageNo)])
                   return
             languageNo = 0 # start of the row
           # fall-through - vertical movement
           for r in [row+1,row+2]:
             if r==self.addMoreRow: self.addMore()
-            if (r,3+3*languageNo) in self.coords2buttons:
+            if checkIn((r,3+3*languageNo),self.coords2buttons):
                 return focusButton(self.coords2buttons[(r,3+3*languageNo)])
     def doStop(self,*args):
         theISM.stopRecording()
@@ -584,7 +585,7 @@ class RecorderControls(ButtonScrollingMixin):
               self.oldCanvasBbox = bbox
               c.config(scrollregion=bbox,width=bbox[2],height=min(int(c["height"]),int(c.winfo_screenheight()/2),int(bbox[3])))
         if hasattr(self,"currentRecording") and not theISM.currentOutfile: self.doStop() # ensure GUI updates the recording button after player auto-stop (for want of a better place to put it)
-        app.after(cond(winCEsound,3000,600),lambda *args:self.reconfigure_scrollbar())
+        app.after(cond(winCEsound,3000,600),lambda e=None,self=self:self.reconfigure_scrollbar())
     def setSync(self,syncFlag): self.syncFlag = syncFlag
     def newFolder(self,*args):
         count=0
@@ -604,12 +605,12 @@ class RecorderControls(ButtonScrollingMixin):
         self.doStop()
         theISM.setInputSource(PlayerInput(filename,not self.syncFlag))
         self.current_recordFrom_button = (row, self.coords2buttons[(row,0)])
-        self.addButton(row,0,text=localise("Stop"),command=(lambda *args:(self.doStop(),theISM.setInputSource(MicInput()))),colspan=1)
+        self.addButton(row,0,text=localise("Stop"),command=(lambda e=None,self=self,theISM=theISM:(self.doStop(),theISM.setInputSource(MicInput()))),colspan=1)
         col = 1
         for inc in [-30, -5, 5, 30]:
             if inc<0: text="<"+str(-inc)
             else: text=str(inc)+">"
-            self.addButton(row,col,text=text,command=(lambda e=None,i=inc:self.handleSkip(filename,i)))
+            self.addButton(row,col,text=text,command=(lambda e=None,i=inc,self=self,filename=filename:self.handleSkip(filename,i)))
             col += 1
     def handleSkip(self,filename,i):
         self.protect_currentRecordFrom()
@@ -623,7 +624,7 @@ class RecorderControls(ButtonScrollingMixin):
         if hasattr(self,"current_recordFrom_button") and self.current_recordFrom_button:
             row, button = self.current_recordFrom_button
             for col in range(1+3*len(self.languagesToDraw)):
-                if (row,col) in self.coords2buttons:
+                if checkIn((row,col),self.coords2buttons):
                     self.coords2buttons[(row,col)].grid_forget()
                     del self.coords2buttons[(row,col)]
             button.grid(row=row,column=0,columnspan=1+3*len(self.languagesToDraw),sticky="w")
@@ -689,8 +690,8 @@ class RecorderControls(ButtonScrollingMixin):
               # Try to make up for the fact that we can't always increase the width of the scrollbar (and the keyboard often loses focus).  Add extra up/down buttons. (TODO: does any other platform need this?)
               r2 = Tkinter.Frame(r)
               r2.pack({"side":"right"})
-              addButton(r2,unichr(8593),lambda *args:app.ScrollUpHandler(),"left")
-              addButton(r2,unichr(8595),lambda *args:app.ScrollDownHandler(),"left")
+              addButton(r2,unichr(8593),lambda e=None,app=app:app.ScrollUpHandler(),"left")
+              addButton(r2,unichr(8595),lambda e=None,app=app:app.ScrollDownHandler(),"left")
               Tkinter.Label(r,text="    ").pack({"side":"right"}) # TODO: more flexible spacer
               r = Tkinter.Frame(r)
               r.pack({"side":"right"})
@@ -710,7 +711,7 @@ class RecorderControls(ButtonScrollingMixin):
         maxPrefix = 0 ; self.has_recordFrom_buttons = False
 
         if not self.currentDir==samplesDirectory and os.sep in self.currentDir:
-            self.addButton(curRow,0,text=localise("(Up)"),command=(lambda e=None,f=self.currentDir[:self.currentDir.rindex(os.sep)]:self.changeDir(f)))
+            self.addButton(curRow,0,text=localise("(Up)"),command=(lambda e=None,f=self.currentDir[:self.currentDir.rindex(os.sep)],self=self:self.changeDir(f)))
             curRow += 1
         l = os.listdir(self.currentDir)
         def cmpfunc(a,b): # sort alphabetically but ensure L2 comes before L1 for tab order
@@ -734,7 +735,7 @@ class RecorderControls(ButtonScrollingMixin):
             if isDirectory(self.currentDir+os.sep+fname):
                  if not flwr in ["zips","utils","advanced utilities"]: # NOT "prompts", that can be browsed
                     newDir = self.currentDir+os.sep+fname
-                    self.addButton(curRow,0,text=filename2unicode(fname),command=(lambda e=None,f=newDir:self.changeDir(f)))
+                    self.addButton(curRow,0,text=filename2unicode(fname),command=(lambda e=None,f=newDir,self=self:self.changeDir(f)))
                     # TODO if _disabled have an Enable button ?
                     # if not have a Disable ??
                     # (NB though the above button will have a column span)
@@ -742,8 +743,8 @@ class RecorderControls(ButtonScrollingMixin):
                         focusButton(self.coords2buttons[(curRow,0)])
                         dirToHighlight = None # done
                     curRow += 1
-                    if fileExists(self.currentDir+os.sep+fname+os.sep+longDescriptionName): description=u8strip(read(self.currentDir+os.sep+fname+os.sep+longDescriptionName)).strip(wsp)
-                    elif fileExists(self.currentDir+os.sep+fname+os.sep+shortDescriptionName): description=u8strip(read(self.currentDir+os.sep+fname+os.sep+shortDescriptionName)).strip(wsp)
+                    if fileExists(self.currentDir+os.sep+fname+os.sep+longDescriptionName): description=wspstrip(u8strip(read(self.currentDir+os.sep+fname+os.sep+longDescriptionName)))
+                    elif fileExists(self.currentDir+os.sep+fname+os.sep+shortDescriptionName): description=wspstrip(u8strip(read(self.currentDir+os.sep+fname+os.sep+shortDescriptionName)))
                     else: description=None
                     if description:
                         try: sbarWidth = app.sbarWidth
@@ -774,24 +775,24 @@ class RecorderControls(ButtonScrollingMixin):
               if iprefix>maxPrefix: maxPrefix=iprefix # max existing numerical prefix
               
               if (flwr.endswith(dotwav) or flwr.endswith(dotmp3) or flwr.endswith(dottxt)): # even if not languageOverride in self.languagesToDraw e.g. for prompts - helps setting up gradint in a language it doesn't have prompts for (creates blank rows for the prefixes that other languages use). TODO do we want to add 'and languageOverride in self.languagesToDraw' if NOT in prompts?
-                if not prefix in prefix2row:
+                if not checkIn(prefix,prefix2row):
                     self.addLabel(curRow,0,utext=filename2unicode(prefix))
                     foundTxt = {}
                     for lang in self.languagesToDraw:
-                        if realPrefix+"_"+lang+afterLang+dottxt in l: foundTxt[lang]=(self.currentDir+os.sep+realPrefix+"_"+lang+afterLang+dottxt,2+3*self.languagesToDraw.index(lang))
+                        if checkIn(realPrefix+"_"+lang+afterLang+dottxt,l): foundTxt[lang]=(self.currentDir+os.sep+realPrefix+"_"+lang+afterLang+dottxt,2+3*self.languagesToDraw.index(lang))
                     prefix2row[prefix] = curRow
                     for lang in self.languagesToDraw: # preserve tab order
                         if lang==languageOverride and not flwr.endswith(dottxt):
                             self.updateFile(fname,curRow,self.languagesToDraw.index(lang),state=1)
                             languageOverride=None # so not done again
-                        else: self.updateFile(prefix+"_"+lang+dotwav,curRow,self.languagesToDraw.index(lang),state=0,txtExists=(lang in foundTxt))
+                        else: self.updateFile(prefix+"_"+lang+dotwav,curRow,self.languagesToDraw.index(lang),state=0,txtExists=checkIn(lang,foundTxt))
                         self.gridLabel(lang,curRow)
                     for filename,col in foundTxt.values(): self.addSynthLabel(filename,curRow+1,col)
                     curRow += 2
-                if languageOverride in self.languagesToDraw and not flwr.endswith(dottxt):
+                if checkIn(languageOverride,self.languagesToDraw) and not flwr.endswith(dottxt):
                     self.updateFile(fname,prefix2row[prefix],self.languagesToDraw.index(languageOverride),state=1)
             elif (flwr.endswith(dotwav) or flwr.endswith(dotmp3)) and tkSnack and not tkSnack=="MicOnly": # no _ in it but we can still play it for splitting
-                self.addButton(curRow,0,text=(localise("Record from %s") % (filename2unicode(fname),)),command=(lambda e=None,r=curRow,f=self.currentDir+os.sep+fname:self.doRecordFrom(f,r)))
+                self.addButton(curRow,0,text=(localise("Record from %s") % (filename2unicode(fname),)),command=(lambda e=None,r=curRow,f=self.currentDir+os.sep+fname,self=self:self.doRecordFrom(f,r)))
                 self.has_recordFrom_buttons = True
                 curRow += 1
         self.addMoreRow = curRow ; self.maxPrefix = maxPrefix+1
@@ -810,7 +811,7 @@ class RecorderControls(ButtonScrollingMixin):
         addButton(r2,localise("Record from file"),self.do_recordFromFile,"left")
         if got_program("lame"): self.CompressButton = addButton(r2,localise("Compress all"),self.all2mp3_or_zip,"left") # was "Compress all recordings" but it takes too much width
         # TODO else can we see if it's possible to get the encoder on the fly, like in the main screen? (would need some restructuring)
-        elif got_program("zip") and (explorerCommand or winCEsound): self.CompressButton = addButton(r2,localise("Zip for email"),lambda *args:self.all2mp3_or_zip(),"left")
+        elif got_program("zip") and (explorerCommand or winCEsound): self.CompressButton = addButton(r2,localise("Zip for email"),lambda e=None,self=self:self.all2mp3_or_zip(),"left")
         addButton(r2,localise(cond(recorderMode,"Quit","Back to main menu")),self.finished,"left")
         
         if winCEsound and not tkSnack: msg="Click on filenames at left to rename; click synthesized text to edit it"
@@ -877,13 +878,13 @@ def droidOrS60RecWord(recFunc,inputFunc):
        os.remove(l2) ; break
   if not l1: continue
   ls = list2set(os.listdir(samplesDirectory))
-  def inLs(prefix):
+  def inLs(prefix,ls):
     for l in ls:
         if l.startswith(prefix) and len(l) > len(prefix) and l[len(prefix)] not in "0123456789": return True
   global recCount
   try: recCount += 1
   except: recCount = 1
-  while inLs("%02d" % recCount): recCount += 1
+  while inLs("%02d" % recCount,ls): recCount += 1
   origPrefix = prefix = ensure_unicode("%02d" % recCount)
   while True:
     prefix = inputFunc(u"Filename:",prefix)
@@ -892,7 +893,7 @@ def droidOrS60RecWord(recFunc,inputFunc):
         recCount-=1;os.remove(l1);os.remove(l2);return
       else:
         prefix = origPrefix ; continue
-    if not inLs(prefix) or getYN("File exists.  overwrite?"): break
+    if not inLs(prefix,ls) or getYN("File exists.  overwrite?"): break
   if samplesDirectory: prefix=samplesDirectory+os.sep+prefix
   os.rename(l1,prefix+l1[l1.index("_"):])
   os.rename(l2,prefix+l2[l2.index("_"):])
@@ -901,15 +902,15 @@ def s60_recordFile(language):
  fname = "newfile_"+language+dotwav
  while True:
   S=audio.Sound.open(os.getcwd()+os.sep+fname)
-  def forgetS():
+  def forgetS(fname,S):
     S.close()
     try: os.remove(fname)
     except: pass
-  if not getYN("Press OK to record "+language+" word"): return forgetS()
+  if not getYN("Press OK to record "+language+" word"): return forgetS(fname,S)
   S.record()
   ret = getYN("Press OK to stop") ; S.stop()
   if not ret:
-    forgetS() ; continue
+    forgetS(fname,S) ; continue
   S.play()
   ret = getYN("Are you happy with this?")
   S.stop() ; S.close()
diff --git a/gradint-build/src/sequence.py b/gradint-build/src/sequence.py
index a971a607080ab3349d4e165470424d7e262e6726..3c9f991c718ae0467fe7f2cc5166fdf7d8ea1cd2 100644
--- a/gradint-build/src/sequence.py
+++ b/gradint-build/src/sequence.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
@@ -53,7 +53,7 @@ def anticipation(promptFile,zhFile,numTimesBefore,promptsData):
     else: numRepeats = 1
     if numRepeats==1:
       k,f = synthcache_lookup(zhFile,justQueryCache=1)
-      if f and B(k[:1])==B("_") and not textof(zhFile) in subst_synth_counters:
+      if f and B(k[:1])==B("_") and not checkIn(textof(zhFile),subst_synth_counters):
         # Hack: an experimental cache entry but only 1 repetition - what do we do?
         c=random.choice([1,2,3])
         if c==1: pass # do nothing
@@ -65,7 +65,7 @@ def anticipation(promptFile,zhFile,numTimesBefore,promptsData):
     theList = []
     if instrIsPrefix: theList = instructions
     theList.append(promptEvent)
-    if promptFile==zhFile and not promptFile in singleLinePoems:
+    if promptFile==zhFile and not checkIn(promptFile,singleLinePoems):
         # A multi-line poem with no first-language prompts, so we're using each fragment as a prompt for the next, but the first fragment is its own prompt, which means that 'prompt' is ambiguous.  Say "beginning" to disambiguate it.
         theList = theList + map(lambda x:fileToEvent(x,promptsDirectory), availablePrompts.getPromptList("begin",promptsData,languageof(zhFile)))
     if not instrIsPrefix: theList += instructions
diff --git a/gradint-build/src/synth.py b/gradint-build/src/synth.py
index 7ddd2a1641ac2b31aeefed245e05e8223030cfd3..a12cc7f8430ef3b58d45d18feb1222c931f3a934 100644
--- a/gradint-build/src/synth.py
+++ b/gradint-build/src/synth.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
@@ -37,7 +37,7 @@ class Synth(object):
     def makefile_cached(self,lang,text):
         if type(text)==type([]): textKey=repr(text)
         else: textKey=text
-        if (lang,textKey) in self.fileCache: return self.fileCache[(lang,textKey)]
+        if checkIn((lang,textKey),self.fileCache): return self.fileCache[(lang,textKey)]
         t = self.makefile(lang,text)
         self.fileCache[(lang,textKey)] = t
         return t
@@ -74,7 +74,7 @@ class OSXSynth_Say(Synth):
         try: osxSayVoicesScan # singleton
         except: osxSayVoicesScan = self.scanVoices()
         self.voices = osxSayVoicesScan ; return True
-    def supports_language(self,lang): return lang in self.voices
+    def supports_language(self,lang): return checkIn(lang,self.voices)
     def guess_length(self,lang,text): return quickGuess(len(text),12) # TODO need a better estimate
     def play(self,lang,text): return system("say %s\"%s\"" % (self.voices[lang],self.transliterate(lang,text).replace('"','')))
     # TODO 10.7+ may also support -r rate (WPM), make that configurable in advanced.txt ?
@@ -107,8 +107,8 @@ class OSXSynth_Say(Synth):
                 voiceAttrs.append({'VoiceName':name,'VoiceLanguage':lang.replace('_','-')})
             if not voiceAttrs: return {"en":""} # maybe we're on ancient OS X: don't use a -v parameter at all
         for vocAttrib in voiceAttrs:
-            if not 'VoiceName' in vocAttrib: continue
-            if not 'VoiceLanguage' in vocAttrib:
+            if not checkIn('VoiceName',vocAttrib): continue
+            if not checkIn('VoiceLanguage',vocAttrib):
                 lang={"Damayanti":"id","Maged":"ar","Stine":"nb"}.get(vocAttrib['VoiceName'],None) # TODO: can sometimes use VoiceLocaleIdentifier instead, dropping the _ part (but can't even do that with Damayanti on 10.7)
                 if not lang: continue # TODO: output VoiceName in a warning?
             else: lang = vocAttrib['VoiceLanguage']
@@ -207,7 +207,7 @@ class PttsSynth(Synth):
                 # must keep the full path even on non-cygwin because we're adding ,1 to changeToDirOf (hope we don't hit a Windows version that doesn't like this).  But we can keep relative paths if tempdir_is_curdir. (TODO if this breaks when not tempdir_is_curdir, could try copying ptts.exe to temp, but would need to delete it afterwards)
                 if cygwin or not tempdir_is_curdir: self.program='"'+os.getcwd()+cwd_addSep+i+'"'
                 else: self.program = i
-                self.offlineOnly = 'offline' in i
+                self.offlineOnly = checkIn('offline',i)
                 break
         if not self.program:
             # (in case someone's running on Windows from source)
@@ -216,15 +216,15 @@ class PttsSynth(Synth):
         else: self.lily_file = lily_file
         if fileExists(self.lily_file):
             self.old_lily_data=read(self.lily_file)
-            if "zh" in sapiVoices and sapiVoices["zh"][0].lower()=="vw lily": del sapiVoices["zh"] # because we don't want to bypass our own interface to lily if a user set that without realising it's not needed
+            if checkIn("zh",sapiVoices) and sapiVoices["zh"][0].lower()=="vw lily": del sapiVoices["zh"] # because we don't want to bypass our own interface to lily if a user set that without realising it's not needed
         else: self.lily_file = None
-    def supports_language(self,lang): return lang in sapiVoices or lang=="en" or (self.lily_file and lang=="zh")
+    def supports_language(self,lang): return checkIn(lang,sapiVoices) or lang=="en" or (self.lily_file and lang=="zh")
     # Voice list: os.popen("echo | "+self.program+" -vl").read().split("\n").  If any .lower() contain "espeak-" then after the "-" is an espeak language code see ESpeakSynth (it may not want unicode).  Other voices may also have support for specific languages - may sometimes be able to use <lang langid="locale-hex-code"/> (e.g. 809 UK, 804 Chinese (PRC) 404 Taiwan, 411 Japan) but no way for gradint to tell if successful
     def works_on_this_platform(self): return self.program
     def guess_length(self,lang,text): return quickGuess(len(text),12) # TODO need a better estimate, especially if they're going to set the speed in the control panel!
     def play(self,lang,text):
         if self.offlineOnly: return SampleEvent(self.makefile_cached(lang,text)).play()
-        if lang in sapiVoices:
+        if checkIn(lang,sapiVoices):
             d=os.getcwd()
             ret=self.sapi_unicode(sapiVoices[lang][0],ensure_unicode(text),speed=sapiSpeeds.get(lang,None))
             os.chdir(d) ; return ret
@@ -253,7 +253,7 @@ class PttsSynth(Synth):
     def makefile(self,lang,text):
         fname = os.tempnam()+dotwav
         oldcwd=os.getcwd()
-        if lang in sapiVoices: r=self.sapi_unicode(sapiVoices[lang][0],ensure_unicode(text),fname,sapiVoices[lang][1],speed=sapiSpeeds.get(lang,None))
+        if checkIn(lang,sapiVoices): r=self.sapi_unicode(sapiVoices[lang][0],ensure_unicode(text),fname,sapiVoices[lang][1],speed=sapiSpeeds.get(lang,None))
         elif lang=="en":
             p=os.popen(self.program+' -c 1 -w '+changeToDirOf(fname,1)+self.speedParam(sapiSpeeds.get(lang,None))+toNull,"w") # (can specify mono but can't specify sample rate if it wasn't mentioned in sapiVoices - might make en synth-cache bigger than necessary but otherwise no great problem)
             p.write(text+"\n")
@@ -406,7 +406,7 @@ class ESpeakSynth(Synth):
         Synth.__init__(self)
         self.languages = {} ; self.program=""
         tryList = []
-        if riscos_sound and 'eSpeak$dir' in os.environ: tryList=[os.environ['eSpeak$dir']+'.espeak-dat',os.environ['eSpeak$dir']+'.espeak-data']
+        if riscos_sound and checkIn('eSpeak$dir',os.environ): tryList=[os.environ['eSpeak$dir']+'.espeak-dat',os.environ['eSpeak$dir']+'.espeak-data']
         elif winsound or mingw32: tryList=[programFiles+"\\eSpeak\\espeak-data"]
         elif winCEsound: tryList=["\\espeak-data"] # Can't try \\Storage Card because our eSpeak compile can't cope with spaces (and quoting it does not work)
         else:
@@ -447,7 +447,7 @@ class ESpeakSynth(Synth):
         self.languages[lang]=fname
         for l in open(self.place+os.sep+"voices"+os.sep+fname,"rb").read(256).replace(B("\r"),B("\n")).split(B("\n")):
             if l.startswith(B("language ")):
-                l=l[9:].strip(bwsp)
+                l=bwspstrip(l[9:])
                 if not l==B(lang):
                     Sl = S(l)
                     if Sl in espeak_language_aliases.values(): # aliasing to an alias - update it
@@ -469,8 +469,8 @@ class ESpeakSynth(Synth):
                     ret.append(k+"="+lname)
                     break
         return " ".join(ret)
-    def supports_language(self,lang): return espeak_language_aliases.get(lang,lang) in self.languages
-    def not_so_good_at(self,lang): return lang not in prefer_espeak
+    def supports_language(self,lang): return checkIn(espeak_language_aliases.get(lang,lang),self.languages)
+    def not_so_good_at(self,lang): return not checkIn(lang,prefer_espeak)
     def works_on_this_platform(self):
         if len(self.languages.items())==0: return 0
         if winCEsound:
@@ -482,7 +482,7 @@ class ESpeakSynth(Synth):
         elif cygwin: toTry=[programFiles+"/eSpeak/command_line/espeak.exe"]
         else: toTry = []
         if toTry: # windows or cygwin
-            if "ESPEAK_DATA_PATH" in os.environ:
+            if checkIn("ESPEAK_DATA_PATH",os.environ):
                 toTry.insert(0,os.environ["ESPEAK_DATA_PATH"]+os.sep+"espeak.exe")
                 toTry.insert(0,os.environ["ESPEAK_DATA_PATH"]+os.sep+"command_line"+os.sep+"espeak.exe")
             for t in toTry:
@@ -494,7 +494,7 @@ class ESpeakSynth(Synth):
         else: # not windows or cygwin
             self.program="speak"
             if riscos_sound: return True # we've already confirmed <eSpeak$dir> works in the constructor
-            loc=getoutput("locale -a 2>/dev/null|grep -i 'utf-*8$'|head -1").strip(wsp)
+            loc=wspstrip(getoutput("locale -a 2>/dev/null|grep -i 'utf-*8$'|head -1"))
             if loc: loc="LC_CTYPE="+loc+" " # in case espeak can't find a utf-8 locale by itself
             self.program=loc+"speak"
             if got_program("speak"): return True
@@ -533,7 +533,7 @@ class ESpeakSynth(Synth):
         self.hProcess = s.hProcess # TODO check it's not NULL (failed to run)
     def winCE_wait(self,expectedOutputFile,infileToDel=None,needDat=1):
         # won't always work: if app and not app.Label["text"].strip(): app.setLabel("Waiting for eSpeak") # in case it doesn't produce output
-        ctypes.cdll.coredll.WaitForSingleObject(self.hProcess,4294967295) # i.e. 0xFFFFFFFF but that throws up a warning on Python 2.3
+        ctypes.cdll.coredll.WaitForSingleObject(self.hProcess,long(65535)*long(65537)) # i.e. 0xFFFFFFFF but that throws up a warning on Python 2.3; Python 2.1 won't accept 4294967295 without L but Python 3 says syntax error if L, so need to multiply
         ctypes.cdll.coredll.CloseHandle(self.hProcess)
         # In some rare circumstances, that command won't wait (e.g. process hadn't started despite the fact we delayed), so check the output files also.
         # (Leave WaitForSingleObject in as it could save some CPU cycles / potential OS crashes on some WinCE versions)
@@ -555,7 +555,7 @@ class ESpeakSynth(Synth):
             return dat
     def update_translit_cache(self,lang,textList): # forPartials=1 assumed
         if not lang=="zh": return # TODO if expand 'transliterate' to do other languages, make sure to update this also, and the cache format
-        if self.translitCache: textList=filter(lambda x:x not in self.translitCache, textList)
+        if self.translitCache: textList=filter(lambda x,self=self:not checkIn(x,self.translitCache), textList)
         step = 1000 # should be about right?
         for i in range(0,len(textList),step):
             tl = textList[i:i+step]
@@ -567,12 +567,12 @@ class ESpeakSynth(Synth):
             try: pickle.Pickler(open(espeakTranslitCacheFile,"wb"),-1).dump((tuple(os.stat(self.place+os.sep+"espeak-data")),self.translitCache))
             except IOError: pass # 'permission denied' is ok
     def transliterate(self,lang,text,forPartials=1):
-        if lang=="zh" and text in self.translitCache: return self.translitCache[text] # (TODO add "and forPartials"? but don't need to bother with this extra condition on slow systems)
+        if lang=="zh" and checkIn(text,self.translitCache): return self.translitCache[text] # (TODO add "and forPartials"? but don't need to bother with this extra condition on slow systems)
         return self.transliterate_multiple(lang,[text],forPartials)[0] # and don't cache it - could be experimental, and we don't want cache to grow indefinitely
     if unix:
         def check_dicts(self,lang,txt):
             if not hasattr(self,"dictsChecked"): self.dictsChecked = {}
-            if lang in self.dictsChecked or not lang in ["zh","zhy","ru"]: return
+            if checkIn(lang,self.dictsChecked) or not lang in ["zh","zhy","ru"]: return
             if type(txt)==list: txt=B("").join(txt)
             if re.match(B("^[ -~]*$"),txt): return # don't need to warn about dictionaries if we were given all-ASCII input (TODO: and tone marks?)
             if filelen(self.place+os.sep+"espeak-data"+os.sep+lang+"_dict")<100000: show_warning("Warning: the eSpeak on this system has only a short dictionary for language '"+lang+"' - please install the Additional Data at espeak.sourceforge.net/data")
@@ -641,7 +641,7 @@ class ESpeakSynth(Synth):
           r=[] ; lastWasBlank=False
           delete_last_r_if_blank = 0 ; appendNext = 0
           thisgroup_max_priority,thisgroup_enWord_priority = 0.5,0
-          for l in dat.strip(bwsp).split(B("\n")):
+          for l in bwspstrip(dat).split(B("\n")):
               if appendNext: # (see below)
                   r.append(l[l.index(B("["))+1:l.index(B("]"))])
                   appendNext = 0 ; continue
@@ -666,7 +666,7 @@ class ESpeakSynth(Synth):
               foundLetter=0
               if l.startswith(B("Translate ")):
                   toAppend=l[l.index(B("'"))+1:-1].replace(LB("\xc3\xbc"),B("v"))
-                  if not (toAppend in en_words and r and toAppend==r[-1]):
+                  if not (checkIn(toAppend,en_words) and r and toAppend==r[-1]):
                     # TODO what about partial English words? e.g. try "kao3 testing" - translate 'testing' results in a translate of 'test' also (which assumes it's already in en mode), resulting in a spurious word "test" added to the text box; not sure how to pick this up without parsing the original text and comparing with the Replace rules that occurred
                     r.append(toAppend)
                     delete_last_r_if_blank = 1
@@ -762,7 +762,7 @@ def fix_commas(text):
   i=0 ; text=B(text)
   while i<len(text)-1:
     if text[i:i+1] in B('.,?;!'):
-      tRest = text[i+1:].strip(bwsp)
+      tRest = bwspstrip(text[i+1:])
       if tRest and (ord(tRest[:1])>=128 or B('a')<=tRest[:1].lower()<=B('z')):
         text=text[:i+1]+cond(text[i:i+1] in B(".?!"),B("  ")+tRest[:1].upper(),B(" ")+tRest[:1])+tRest[1:]
     i+=1
@@ -778,7 +778,7 @@ def fix_pinyin(pinyin,en_words):
       while j>1 and not (B('a')<=w[j-1:j]<=B('z') or B('1')<w[j-1:j]<=B('5')): j-=1
       return w[i:j]
     for w in pinyin.split():
-      if stripPunc(w) in en_words: ret.append(w)
+      if checkIn(stripPunc(w),en_words): ret.append(w)
       else: ret.append(fix_pinyin(w,[]))
     return B(' ').join(ret)
   i=0
@@ -877,7 +877,7 @@ def espeak_stdout_works():
     except ValueError: return False
 def espeak_volume_ok():
     # if has "zh", should be recent enough
-    return "zh" in ESpeakSynth().languages
+    return checkIn("zh",ESpeakSynth().languages)
 if wavPlayer_override or (unix and not macsound and not (oss_sound_device=="/dev/sound/dsp" or oss_sound_device=="/dev/dsp")):
     if wavPlayer=="aplay" and espeak_stdout_works(): espeak_pipe_through="--stdout|aplay -q" # e.g. NSLU2
     else: del ESpeakSynth.play # because we have no way of sending it to the alternative device, so do it via a file
@@ -960,7 +960,7 @@ class GeneralFileSynth(Synth):
         return 0
     def works_on_this_platform(self): return extra_speech_tofile
     def guess_length(self,lang,text):
-        if not lang in self.letters: self.letters[lang]=self.duration[lang]=0
+        if not checkIn(lang,self.letters): self.letters[lang]=self.duration[lang]=0
         if self.letters[lang]<25:
             self.letters[lang] += len(text)
             self.duration[lang] += SampleEvent(self.makefile_cached(lang,text)).exactLen
@@ -996,9 +996,9 @@ def setSoundCollector(sc):
     soundCollector,viable_synths,getsynth_cache = sc,[],{}
 def get_synth_if_possible(language,warn=1,to_transliterate=False):
     language = S(language)
-    if language in getsynth_cache and not to_transliterate: return getsynth_cache[language] # most common case (vocab.txt parse)
+    if checkIn(language,getsynth_cache) and not to_transliterate: return getsynth_cache[language] # most common case (vocab.txt parse)
     if language==None:
-        if not None in getsynth_cache: getsynth_cache[None]=Partials_Synth()
+        if not checkIn(None,getsynth_cache): getsynth_cache[None]=Partials_Synth()
         return getsynth_cache[None]
     global viable_synths, warned_about_nosynth
     if not viable_synths:
@@ -1022,10 +1022,10 @@ def get_synth_if_possible(language,warn=1,to_transliterate=False):
                 getsynth_cache[language]=synth # only if warn (otherwise wait until we're called again, then warn)
             return synth
     if (not warn) or language not in [firstLanguage,secondLanguage]+possible_otherLanguages: return None # without printing a warning
-    if not language in warned_about_nosynth:
+    if not checkIn(language,warned_about_nosynth):
         warned_about_nosynth[language] = 1
         canSay = []
-        if language in synth_partials_voices: canSay.append("recorded syllables (partials)")
+        if checkIn(language,synth_partials_voices): canSay.append("recorded syllables (partials)")
         if synthCache: canSay.append("recorded phrases (synthCache)")
         if canSay: canSay="\n  - can use only "+" and ".join(canSay)
         else: canSay="\n  (did you read ALL the comments in vocab.txt?)"
@@ -1096,7 +1096,7 @@ class SynthEvent(Event):
             self.sound = self.synthesizer.makefile_cached(self.language,self.modifiedText)
             self.synthesizer.finish_makefile()
         if sample_table_hack:
-            if not self.sound in sample_table_hack_lengthDic: sample_table_hack_lengthDic[self.sound]=SampleEvent(self.sound).exactLen
+            if not checkIn(self.sound,sample_table_hack_lengthDic): sample_table_hack_lengthDic[self.sound]=SampleEvent(self.sound).exactLen
             soundCollector.addFile(self.sound,sample_table_hack_lengthDic[self.sound])
             open(self.sound,"wb") # i.e. truncate at 0 bytes to save space (but keep around so no name clashes)
         elif self.sound:
@@ -1177,7 +1177,8 @@ def just_synthesize(callSanityCheck=0,lastLang_override=None):
           if ret: return fileToEvent(fname)
           else: show_warning("Can't say "+repr(fname)) # previous warnings should have said why (e.g. partials-only language)
       for line in B(justSynthesize).split(B('#')):
-        line = line.strip(bwsp) ; l = line.split(None,1)
+        line = bwspstrip(line)
+        l = line.split(None,1)
         if B(extsep) in line and fileExists(line): event = fileToEvent(line,"")
         elif B(extsep) in line and fileExists(abspath_from_start(line)): event = fileToEvent(abspath_from_start(line),"")
         elif line==B('R'):
diff --git a/gradint-build/src/system.py b/gradint-build/src/system.py
index fb1c8539c5c668098c1e224d14355147e5516b4e..8276d6d8fd88c00e1e81d04b2070193d28a742f7 100644
--- a/gradint-build/src/system.py
+++ b/gradint-build/src/system.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
@@ -82,6 +82,7 @@ wsp = '\t\n\x0b\x0c\r ' ; bwsp=B(wsp) # whitespace characters - ALWAYS use .stri
 warnings_printed = [] ; app = False # False is a hack for "maybe later"
 warnings_toprint = []
 def show_warning(w):
+    if w+"\n" in warnings_printed: return
     if not app and not app==False and not appuifw and not android:
         if winCEsound and len(w)>100: w=w[:100]+"..." # otherwise can hang winCEsound's console (e.g. a long "assuming that" message from justSynthesize)
         sys.stderr.write(w+"\n")
@@ -97,11 +98,6 @@ def show_info(i,always_stderr=False):
     try: writeB(sys.stderr,i)
     except IOError: pass
 
-# For pre-2.3 versions of Python (e.g. 2.2 on Symbian S60 and Mac OS 10.3):
-try: True
-except: exec("True = 1 ; False = 0")
-# TODO make sure to avoid writing "string1 in string2" without thinking - if string1 is multiple characters it won't work on pre-2.3
-# TODO check all lambda functions for Python2.2 compatibility
 # (TODO: GUI_translations, if not set in advanced.txt, won't work properly on pre-2.3 - it'll take them as Latin-1)
 # (TODO: and if it *IS* set in advanced.txt, will 2.2's exec() correctly exec a unicode string?)
 
@@ -126,6 +122,14 @@ def list2dict(l):
   return d
 try: list2set = set
 except NameError: list2set = list2dict
+def checkIn(k,obj):
+    try: return k in obj # dict or set
+    except:
+        try: return obj.has_key(k) # Python 2.1 (may raise TypeError, AttributeError etc if try to use the "in" operator as above, but has_key rm'd from Python3)
+        except: return obj.find(k) > -1 # Python 2.1 strings
+try: object
+except:
+    class object: pass # Python 2.1
 
 # settings.txt and advanced.txt
 # (done here before the variables start to be used in
@@ -179,12 +183,18 @@ def u8strip(d):
         last_u8strip_found_BOM = 1
         return d[3:] # ignore Notepad's UTF-8 BOM's
     else: return d
+def bwspstrip(s):
+    try: return s.strip(bwsp)
+    except: return s.strip() # Python 2.1
+def wspstrip(s):
+    try: return s.strip(wsp)
+    except: return s.strip() # Python 2.1
 GUI_translations_old = GUI_translations
 configFiles = map(lambda x:x+dottxt,["advanced","settings"]) # MUST have settings last so can have per-user override of scriptVariants
 if not hasattr(sys,"argv"): sys.argv=" " # some Symbian versions
 starting_directory = os.getcwd()
 if not fileExists(configFiles[0]):
-  if macsound and "_" in os.environ:
+  if macsound and checkIn("_",os.environ):
     s=os.environ["_"] ; s=s[:s.rfind(os.sep)]
     os.chdir(s)
     if not fileExists(configFiles[0]):
@@ -245,7 +255,7 @@ synth_priorities = "eSpeak MacOS SAPI Ekho" # old advanced.txt had this instead
 dir1 = list2set(dir()+["dir1","f","last_u8strip_found_BOM","__warningregistry__"])
 for f in configFiles: readSettings(f)
 for d in dir():
-  if not d in dir1 and eval(d) and not type(eval(d))==type(lambda *args:0): # (ignore unrecognised options that evaluate false - these might be an OLD unused option with a newer gradint rather than vice versa; also ignore functions as these could be used in command-line parameters)
+  if not checkIn(d,dir1) and eval(d) and not type(eval(d))==type(lambda *args:0): # (ignore unrecognised options that evaluate false - these might be an OLD unused option with a newer gradint rather than vice versa; also ignore functions as these could be used in command-line parameters)
     show_warning("Warning: Unrecognised option in config files: "+d)
 del dir1
 GUI_translations_old.update(GUI_translations) ; GUI_translations = GUI_translations_old # in case more have been added since advanced.txt last update
@@ -281,15 +291,15 @@ if paranoid_file_management:
             time.sleep(0.5)
   def read(file): return tryIO(lambda x=file:_old_open(x,"rb").read())
   def _write(fn,data):
-    tryIO(lambda x=fn,y=data:_old_open(x,"wb").write(data))
+    tryIO(lambda x=fn,y=data:_old_open(x,"wb").write(y))
     time.sleep(0.5)
     if not filelen(fn)==len(data):
       # might be a version of curlftpfs that can't shorten files - try delete and restart (although this can erase permissions info)
       os.remove(fn)
-      tryIO(lambda x=fn,y=data:_old_open(x,"wb").write(data))
+      tryIO(lambda x=fn,y=data:_old_open(x,"wb").write(y))
       if not filelen(fn)==len(data): raise IOError("wrong length")
     if not read(fn)==data: raise IOError("verification failure on "+repr(fn))
-  def write(fn,data): return tryIO(lambda x=fn,y=data:_write(x,data))
+  def write(fn,data): return tryIO(lambda x=fn,y=data:_write(x,y))
   def open(file,mode="r",forAppend=0):
     if "a" in mode:
         try: dat = open(file,"rb").read()
@@ -388,8 +398,8 @@ def progressFileOK():
         except: return 0
 if winsound:  # will try these dirs in reverse order:
     tryList = ["C:\\TEMP\\gradint-progress.txt", "C:\\gradint-progress.txt", "C:gradint-progress.txt"]
-    if "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ: tryList.append(os.environ["HOMEDRIVE"]+os.environ["HOMEPATH"]+os.sep+"gradint-progress.txt")
-elif "HOME" in os.environ: tryList=[os.environ["HOME"]+os.sep+"gradint-progress.txt"]
+    if checkIn("HOMEDRIVE",os.environ) and checkIn("HOMEPATH",os.environ): tryList.append(os.environ["HOMEDRIVE"]+os.environ["HOMEPATH"]+os.sep+"gradint-progress.txt")
+elif checkIn("HOME",os.environ): tryList=[os.environ["HOME"]+os.sep+"gradint-progress.txt"]
 elif riscos_sound: tryList=["$.gradint-progress/txt"]
 else: tryList = []
 foundPF = okPF = 0 ; defaultProgFile = progressFile
@@ -479,9 +489,9 @@ except: orig_onceperday=0
 if winsound:
     # check for users putting support files/folders in the desktop shortcuts folder and thinking it's the gradint folder
     # We can't do much about detecting users on non-English Windows who have heeded the warning about moving the "Desktop" folder to the real desktop but then mistook this for the gradint folder when adding flite (but hopefully they'll be using ptts/espeak anyway, and yali has an installer)
-    if "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ: dr=os.environ["HOMEDRIVE"]+os.environ["HOMEPATH"]
+    if checkIn("HOMEDRIVE",os.environ) and checkIn("HOMEPATH",os.environ): dr=os.environ["HOMEDRIVE"]+os.environ["HOMEPATH"]
     else: dr="C:\\Program Files" # as setup.bat (location for gradint on Win95 etc)
-    if "USERPROFILE" in os.environ: dr=os.environ["USERPROFILE"]
+    if checkIn("USERPROFILE",os.environ): dr=os.environ["USERPROFILE"]
     if not dr[-1]=="\\": dr += "\\"
     try: dirList = os.listdir(dr+"Desktop\\gradint\\") # trailing \ important, otherwise it can include gradint.zip etc on Desktop
     except: dirList = []
@@ -507,7 +517,7 @@ def got_program(prog):
     elif unix:
         try:
             import distutils.spawn
-            if ":." in ":"+os.environ.get("PATH",""):
+            if (":"+os.environ.get("PATH","")).find(":.")>-1:
                 prog = distutils.spawn.find_executable(prog)
             else: # at least some distutils assume that "." is in the PATH even when it isn't, so do it ourselves without checking "."
                 oldCwd = os.getcwd()
@@ -523,7 +533,7 @@ def got_program(prog):
                     os.chdir(oldCwd)
         except ImportError:
             # fall back to running 'which' in a shell (probably slower if got_program is called repeatedly)
-            prog = os.popen("which "+prog+" 2>/dev/null").read().strip(wsp)
+            prog = wspstrip(os.popen("which "+prog+" 2>/dev/null").read())
             if not fileExists_stat(prog): prog=None # some Unix 'which' output an error to stdout instead of stderr, so check the result exists
         return prog
 
@@ -557,8 +567,8 @@ def check_for_interrupts(): # used on platforms where thread.interrupt_main won'
         raise KeyboardInterrupt
 
 # If forking, need to do so BEFORE importing any Tk module (we can't even verify Tk exists 1st)
-if outputFile or justSynthesize or appuifw or not (winsound or winCEsound or mingw32 or macsound or riscos_sound or cygwin or "DISPLAY" in os.environ): useTK = 0
-if useTK and runInBackground and not (winsound or mingw32) and hasattr(os,"fork") and not "gradint_no_fork" in os.environ:
+if outputFile or justSynthesize or appuifw or not (winsound or winCEsound or mingw32 or macsound or riscos_sound or cygwin or checkIn("DISPLAY",os.environ)): useTK = 0
+if useTK and runInBackground and not (winsound or mingw32) and hasattr(os,"fork") and not checkIn("gradint_no_fork",os.environ):
     if os.fork(): sys.exit()
     os.setsid()
     if os.fork(): sys.exit()
diff --git a/gradint-build/src/top.py b/gradint-build/src/top.py
index ed37b3869724da8a3013688f5d44399c0b94067e..44b70b092d162157a7253d413ab13d1bee213958 100644
--- a/gradint-build/src/top.py
+++ b/gradint-build/src/top.py
@@ -2,7 +2,7 @@
 # -*- coding: utf-8 -*-
 #   (Python 2 or Python 3, but more fully tested on 2)
 
-program_name = "gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+."
+program_name = "gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+."
 
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
@@ -28,7 +28,7 @@ if sys.version_info[0]>2:
     def filter(*args): return list(_filter(*args))
     from functools import cmp_to_key
     def sort(l,c): l.sort(key=cmp_to_key(c))
-    raw_input,unichr,xrange = input,chr,range
+    raw_input,unichr,xrange,long = input,chr,range,int
     def chr(x): return unichr(x).encode('latin1')
     from subprocess import getoutput
     popenRB,popenWB = "r","w"
@@ -39,6 +39,9 @@ else: # Python 2
     bytes = str
     try: from commands import getoutput
     except ImportError: pass
+    # For pre-2.3 versions of Python (e.g. 2.2 on Symbian S60 and Mac OS 10.3):
+    try: True
+    except: exec("True = 1 ; False = 0")
 def readB(f,m=None):
     if hasattr(f,"buffer"): f=f.buffer # Python 3 non-"b" file
     if m: return f.read(m)
diff --git a/gradint-build/src/users.py b/gradint-build/src/users.py
index fe64589f7e1cdb910587ddbff0953d3b6ea6c13e..44d82ffa06d806560ee6bc6b7c2d911131b25297 100644
--- a/gradint-build/src/users.py
+++ b/gradint-build/src/users.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or
@@ -49,7 +49,7 @@ def get_userNames(): # list of unicode user names or []
   ret=[]
   u=userNameFile ; c=0
   while fileExists(u):
-    ret.append(unicode(u8strip(read(u)).strip(bwsp),'utf-8'))
+    ret.append(unicode(bwspstrip(u8strip(read(u)),'utf-8')))
     c += 1 ; u=addUserToFname(userNameFile,c)
   global lastUserNames ; lastUserNames = ret
   return ret
@@ -57,7 +57,7 @@ def get_userNames(): # list of unicode user names or []
 def set_userName(N,unicodeName): writeB(open(addUserToFname(userNameFile,N),"w"),unicodeName.encode("utf-8")+B("\n")) # implicitly adds if N=num+1
 
 def wrapped_set_userName(N,unicodeName):
-  if unicodeName.strip(wsp): set_userName(N,unicodeName)
+  if wspstrip(unicodeName): set_userName(N,unicodeName)
   else: app.todo.alert="You need to type the person's name in the box before you press "+localise("Add new name") # don't waitOnMessage because we're in the GUI thread
 
 GUI_usersRow = lastUserNames = None
@@ -91,7 +91,7 @@ def updateUserRow(fromMainMenu=0):
         userBSM = ButtonScrollingMixin() ; userBSM.ourCanvas = c
     else: userBSM = None
     for i in range(len(names)):
-      if names[i].strip(wsp):
+      if wspstrip(names[i]):
         r=Tkinter.Radiobutton(row, text=names[i], variable=app.userNo, value=str(i), takefocus=0)
         r.grid(row=i+1,column=0,sticky="w")
         r["command"]=cmd=lambda e=None,i=i: select_userNumber(i)
@@ -107,22 +107,22 @@ def updateUserRow(fromMainMenu=0):
         r=Tkinter.Frame(row) ; r.grid(row=i+1,column=0,columnspan=4)
         text,entry = addTextBox(r)
         if not fromMainMenu: entry.focus() # because user has just pressed the "add other students" button, or has just added a name and may want to add another
-        l=lambda *args:(wrapped_set_userName(i,asUnicode(text.get())),updateUserRow())
+        l=lambda e=None,wrapped_set_userName=wrapped_set_userName,i=i,text=text:(wrapped_set_userName(i,asUnicode(text.get())),updateUserRow())
         addButton(r,localise("Add new name"),l)
         entry.bind('<Return>',l)
         if not i: Tkinter.Label(row,text="The first name should be that of the\nEXISTING user (i.e. YOUR name).").grid(row=i+2,column=0,columnspan=4)
       if userBSM: userBSM.bindFocusIn(r) # for shift-tab from the bottom
       if hasattr(row,"widgetsToDel"): row.widgetsToDel.append(r)
       if not names[i]: break
-    if userBSM: c.after(cond(winCEsound,1500,300),lambda *args:c.config(scrollregion=c.bbox(Tkinter.ALL),width=c.bbox(Tkinter.ALL)[2],height=min(c["height"],c.winfo_screenheight()/2,c.bbox(Tkinter.ALL)[3]))) # hacky (would be better if it could auto shrink on resize)
-  else: row.widgetsToDel.append(addButton(row,localise("Family mode (multiple user)"),lambda *args:(set_userName(0,""),updateUserRow())))
+    if userBSM: c.after(cond(winCEsound,1500,300),lambda e=None,c=c:c.config(scrollregion=c.bbox(Tkinter.ALL),width=c.bbox(Tkinter.ALL)[2],height=min(c["height"],c.winfo_screenheight()/2,c.bbox(Tkinter.ALL)[3]))) # hacky (would be better if it could auto shrink on resize)
+  else: row.widgetsToDel.append(addButton(row,localise("Family mode (multiple user)"),lambda e=None:(set_userName(0,""),updateUserRow())))
 
 def renameUser(i,radioButton,parent,cancel=0):
     if hasattr(radioButton,"in_renaming"): # finish the rename
         del radioButton.in_renaming
         n=asUnicode(radioButton.renameText.get())
         if cancel: pass
-        elif not n.strip(wsp) and (len(lastUserNames)>1 and not (len(lastUserNames)==2 and not lastUserNames[-1])): tkMessageBox.showinfo(app.master.title(),"You can't have blank user names unless there is only one user.  Keeping the original name instead.")
+        elif not wspstrip(n) and (len(lastUserNames)>1 and not (len(lastUserNames)==2 and not lastUserNames[-1])): tkMessageBox.showinfo(app.master.title(),"You can't have blank user names unless there is only one user.  Keeping the original name instead.")
         else:
             set_userName(i,n)
             lastUserNames[i] = n
@@ -136,9 +136,9 @@ def renameUser(i,radioButton,parent,cancel=0):
         radioButton.renameEntry.grid(row=i+1,column=0)
         radioButton.renameText.set(lastUserNames[i])
         radioButton.renameEntry.focus()
-        radioButton.after(10,lambda *args:radioButton.renameEntry.event_generate('<End>'))
-        radioButton.renameEntry.bind('<Return>',lambda *args:renameUser(i,radioButton,parent))
-        radioButton.renameEntry.bind('<Escape>',lambda *args:renameUser(i,radioButton,parent,cancel=1))
+        radioButton.after(10,lambda e=None,radioButton=radioButton:radioButton.renameEntry.event_generate('<End>'))
+        radioButton.renameEntry.bind('<Return>',lambda e=None,radioButton=radioButton,i=i,parent=parent:renameUser(i,radioButton,parent))
+        radioButton.renameEntry.bind('<Escape>',lambda e=None,i=i,radioButton=radioButton,parent=parent:renameUser(i,radioButton,parent,cancel=1))
 
 def deleteUser(i):
     for n in ["Are you sure","Are you REALLY sure","This is your last chance: Are you REALLY SURE"]:
diff --git a/gradint-build/thindown.py b/gradint-build/thindown.py
index 8d2ffa0ff7eb5c1e1fb99cff43c26754c97c565e..4872d0d78dfdf45d9f7ee04d678938c70475793a 100644
--- a/gradint-build/thindown.py
+++ b/gradint-build/thindown.py
@@ -1,5 +1,5 @@
 # This file is part of the source code of
-# gradint v3.03 (c) 2002-20 Silas S. Brown. GPL v3+.
+# gradint v3.04 (c) 2002-20 Silas S. Brown. GPL v3+.
 #    This program is free software; you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation; either version 3 of the License, or