#!/usr/pkg/bin/python Copyright = """ sedit.py - sound editing program and function library Copyright (C) 2004 John Comeau 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 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. """ import sys, wave, struct, os, StringIO from Tkinter import * from tkFileDialog import * # Note: FFT comes from the numpy (Numeric) package, find it easily with Google try: import FFT from Numeric import conjugate except: FFT = None try: import winsound except: winsound = None # global stuff debug = True def dbgprint(*info): if debug: sys.stderr.write(repr(info) + "\n") class NoError: pass class sedit(Tk): try: if len(sys.argv[1]) > 0: filename = sys.argv[1] else: raise Exception, 'No input file specified' except: filename = "stest.wav" file = None # all dimensions in pixels, naturally fileposition = 0 format = {1: 'b', 2: 'h', 4: 'i'} color = ['red', 'green', 'cyan', 'magenta'] inversecolor = {'red': 'cyan', 'green': 'magenta', 'cyan': 'red', 'magenta': 'green'} hidden = {0: False, 1: False, 2: False, 3: False} multiplier = 10 # for scale when zooming in and out beginselect = None endselect = None keyboard_shift = False keypress, keyrelease, buttonpress, buttonrelease = 2, 3, 4, 5 shiftkeycode = 16 # can't figure out how to use '' leftarrow, rightarrow = 37, 39 # keycodes found with debugging code playsound = False if type(winsound) is ModuleType: playsound = True maxheight = 0 maxwidth = 0 def __init__(self, master = None): Tk.__init__(self, master) self.canvas = Canvas(self, bg = 'white') self.canvas.pack({'expand': True, 'fill': 'both'}) self.maxheight = self.canvas.winfo_height() self.maxwidth = self.canvas.winfo_width() self.file = wave.open(self.filename, "r") compression = self.file.getcomptype() if compression != 'NONE': self.die("cannot handle file compression " + compression) samplewidth = self.file.getsampwidth() if samplewidth not in self.format.keys(): self.die("cannot handle sample width %d" % samplewidth) self.draw() self.bind('', self.draw) self.bind('<>', self.selectsounds) """find keynames in /usr/X11R6/include/X11/keysymdef.h remove the XK_ from the beginning""" self.canvas.bind('<1>', self.mousehandler) self.canvas.bind('', self.mousehandler) self.bind('', self.zoom_in) self.bind('', self.zoom_out) self.bind('

', self.play) self.bind('

', self.play_all) self.bind('', self.dumpall) if type(FFT) is ModuleType: self.bind('', self.drawfft) self.bind('', lambda n: self.canvas.delete('f')) self.bind('', self.cutsounds) self.bind('', self.home) self.bind('', self.end) self.bind('', self.deselect_all) self.bind('', self.keyhandler) self.bind('', self.keyhandler) def die(errormessage): sys.stderr.write(errormessage + "\n") sys.exit(1) def main(self): if len(sys.argv) == 2: self.filename = sys.argv[1] app = self app.title("sedit") app.mainloop() """event properties (copied from Tkinter.py for reference): serial - serial number of event num - mouse button pressed (ButtonPress, ButtonRelease) focus - whether the window has the focus (Enter, Leave) height - height of the exposed window (Configure, Expose) width - width of the exposed window (Configure, Expose) keycode - keycode of the pressed key (KeyPress, KeyRelease) state - state of the event as a number (ButtonPress, ButtonRelease, Enter, KeyPress, KeyRelease, Leave, Motion) state - state as a string (Visibility) time - when the event occurred x - x-position of the mouse y - y-position of the mouse x_root - x-position of the mouse on the screen (ButtonPress, ButtonRelease, KeyPress, KeyRelease, Motion) y_root - y-position of the mouse on the screen (ButtonPress, ButtonRelease, KeyPress, KeyRelease, Motion) char - pressed character (KeyPress, KeyRelease) send_event - see X/Windows documentation keysym - keysym of the the event as a string (KeyPress, KeyRelease) keysym_num - keysym of the event as a number (KeyPress, KeyRelease) type - type of the event as a number widget - widget in which the event occurred delta - delta of wheel movement (MouseWheel) """ def mousehandler(self, *args): """handle mouse events for the canvas selecting areas for editing, moving view left and right, ...?""" midscreen = self.canvas.winfo_width() / 2 if type(args) is not None: if len(args) > 0 and type(args[0]) is InstanceType: event = args[0] if isinstance(event.widget, Canvas): #dbgprint((event.type, event.state)) if int(event.type) == self.buttonpress: if (event.y < .25 * self.canvas.winfo_height() or \ event.y > .75 * self.canvas.winfo_height()): #dbgprint(("buttonpress outside center, moving")) self.move_over(event.x) else: #dbgprint(("buttonpress inside center, selecting")) self.beginselect = self.sample_at(event.x) self.selectsounds(event) self.bind('', self.selectsounds) elif int(event.type) == self.buttonrelease: #dbgprint(("buttonrelease, select stops here")) try: self.unbind('', self.selectsounds) except: pass if self.beginselect != None: self.endselect = self.sample_at(event.x) "clear any overshoot" self.selectsounds(event) def selectsounds(self, *args): "select samples for cut and paste operations" try: #dbgprint(("selectsounds starting")) self.canvas.delete('s') start = self.x_of(self.beginselect) step = 1 event = args[0] end = event.x if end < start: step = -1 #dbgprint(("start, end, step", start, end, step)) for x in range(start, end + step, step): self.selectsound(x) except: raise def rename_once(self, filename, extension): # renames original file just once per session, then deletes any more try: os.rename(filename, "%s.%s" % (filename, extension)) except OSError, instance: if instance.errno == 17: os.unlink(filename) def cutsounds(self, *args): "cut selected sounds for removal or pasting elsewhere" try: self.canvas.delete('s') start = min(self.beginselect, self.endselect) end = max(self.beginselect, self.endselect) tempfilename = "%s.%d" % (self.filename, self.beginselect) tempfile = wave.open(tempfilename, "wb") tempfile.setnchannels(self.file.getnchannels()) tempfile.setsampwidth(self.file.getsampwidth()) tempfile.setframerate(self.file.getframerate()) samplesize = self.file.getsampwidth() * self.file.getnchannels() count = 0 self.file.setpos(count) while count < start: frames = min(4096, start - count) buffer = self.file.readframes(frames) tempfile.writeframes(buffer) count = count + (len(buffer) / samplesize) total = count count = end + 1 self.file.setpos(count) nframes = self.file.getnframes() while count < nframes: frames = min(4096, nframes - count) buffer = self.file.readframes(frames) tempfile.writeframes(buffer) total = total + (len(buffer) / samplesize) count = count + (len(buffer) / samplesize) buffer = [] tempfile.close() self.file.close() self.rename_once(self.filename, 'bak') os.rename(tempfilename, self.filename) self.file = wave.open(self.filename, "rb") self.deselect_all() self.draw() except: raise def selectsound(self, *args): x = args[0] y_max = self.canvas.winfo_height() lines = list(self.canvas.find_overlapping(x, 0, x, y_max)) #dbgprint(("lines at %d" % x, lines)) if len(lines) % 2 == 1: # odd number of lines? # it means we called invertcolors before and added a # black background #dbgprint(("line at %d already selected" % x)) pass else: #dbgprint(("selecting sound at %d" % x)) self.canvas.create_line(x, 0, x, y_max, {'fill': 'black', 'tags': ('c', 's')}) while len(lines) > 0: line = min(lines) # we need to use the same order x1, y1, x2, y2 = self.canvas.bbox(line) # x1 and x2 are bogus, lines are only 1 pixel wide... # but since what we get are 2 on either side, we would # need to average them if we didn't already know x linecolor = self.canvas.itemcget(line, 'fill') self.canvas.create_line(x, y1, x, y2, {'fill': self.inversecolor[linecolor], 'tags': ('c', 's')}) lines.remove(line) def deselect_all(self, *args): self.canvas.delete('s') self.canvas.delete('f') self.beginselect = None self.endselect = None def sample_at(self, x): value = x * self.multiplier + self.fileposition #dbgprint(("sample_at", value)) return value def x_of(self, sample): # opposite of sample_at value = (sample - self.fileposition) / self.multiplier #dbgprint(("x_of", value)) return value def dumpall(self, *args): for attribute in dir(self): object = eval("self.%s" % attribute) if type(object) is not MethodType: dbgprint((attribute, object)) elif attribute == "sedit": self.dumpall(sedit) def keyhandler(self, *args): #dbgprint(("entering keyhandler")) midscreen = self.canvas.winfo_width() / 2 incremental = midscreen / 5 # 1/10 screen width try: event = args[0] keycode = int(event.keycode) type = int(event.type) if type == self.keypress: if keycode == self.rightarrow: self.move_over(midscreen + incremental) elif keycode == self.leftarrow: self.move_over(midscreen - incremental) elif keycode == self.shiftkeycode: self.keyboard_shift = True #dbgprint(("shifted")) else: raise NoError elif type == self.keyrelease: if keycode == self.shiftkeycode: self.keyboard_shift = False #dbgprint(("unshifted")) else: raise NoError else: raise NoError except NoError: dbgprint(("type", event.type, "key", event.keycode, event.char)) pass def move_over(self, *args): #dbgprint(("entering move_over")) midscreen = self.canvas.winfo_width() / 2 shiftmultiplier = 1 startlastscreen = self.file.getnframes() - \ (self.maxwidth * self.multiplier) if self.keyboard_shift: shiftmultiplier = 9 x = 0 try: x = args[0] self.fileposition = (x - midscreen) * (self.multiplier * \ shiftmultiplier) + \ self.fileposition if self.fileposition < 1: self.fileposition = 1 if self.fileposition >= self.file.getnframes(): self.fileposition = self.file.getnframes() - \ (self.maxwidth * self.multiplier) self.draw() except: #dbgprint(("move_over", args)) raise def home(self, *args): """set file position to beginning of file and redraw screen""" self.fileposition = 0 self.draw() def end(self, *args): """set file position to end of file and redraw screen""" self.fileposition = self.file.getnframes() - \ (self.maxwidth * self.multiplier) self.draw() def zoom_in(self, *args): if self.multiplier > 1: self.multiplier = int(self.multiplier / 10) if (self.multiplier) < 1: self.multiplier = 1 self.draw() def zoom_out(self, *args): if self.multiplier < 10000: self.multiplier = self.multiplier * 10 self.draw() def draw(self, *args): #dbgprint(repr(args)) #dbgprint(("starting draw")) self.canvas.delete('c') # clear the board first channels = self.file.getnchannels() samplewidth = self.file.getsampwidth() # set globals to new values in case window was resized () self.maxheight = self.canvas.winfo_height() self.maxwidth = self.canvas.winfo_width() # scale so largest possible sample will fit canvas # for example, if canvas is 10 bytes tall and samples are (signed) # bytes, then 127 must fit in 5 pixels, so you must multiply each # sample value by 5 / 128 scale = self.maxheight / float(1L << (samplewidth * 8)) midpoint = int(self.maxheight / 2.0) try: self.file.setpos(self.fileposition) data = self.file.readframes(self.maxwidth * self.multiplier) # now that the data is in string form, samplewidth is needed #dbgprint(("maxwidth", self.maxwidth, "len(data)", len(data))) chunksize = self.multiplier * samplewidth * channels #dbgprint(("chunksize", chunksize)) for index in range(0, len(data), chunksize): self.drawsample(index / chunksize, data[index:index + chunksize], scale, midpoint, channels, samplewidth) # now redraw selected area if self.beginselect != None and self.endselect != None: self.event_generate('<>', x = self.x_of(self.endselect)) except: sys.stderr.write("error occurred, barfing\n") raise def drawsample(self, *args): x, data, scale, midpoint, channels, samplewidth = args try: datalist = list(struct.unpack(self.format[samplewidth] * \ (len(data) / samplewidth), data)) chunksize = self.multiplier * channels #dbgprint((datalist)) datalist = self.x_scale(datalist, channels, chunksize) #dbgprint((datalist)) # now sort in such a way that the greatest absolute values # get drawn first, otherwise some parts of waveforms will # be hidden behind others datalist.sort(lambda a, b: cmp(abs(b[1]), abs(a[1]))) #dbgprint((datalist)) for channel, sample in datalist: self.canvas.create_line(x, # x start midpoint, # y start x, # x end -(scale * sample) + midpoint, # y end fill = self.color[channel], width = 1, tags = ('c', str(channel)) ) except: raise def x_scale(self, *args): datalist, channels, chunksize = args #dbgprint(("datalist", len(datalist), "samples")) if self.multiplier > 1: # we need to create a new list of samples which are the # average of the samples within the scale specified. # for example, assume a scale of 2, one channel, and # one byte per sample. here is the data stream: # | -8 | -6 | -3 | 0 | 2 | 5 | # that needs to be reduced to: | -7 | -2 | 4 | scaled_data = [] for index in range(0, len(datalist), chunksize): for channel in range(0, channels): start = index + channel end = index + channel + chunksize skip = channels sample = datalist[start:end:skip] scaled_data.append([channel, sum(sample) / len(sample)]) datalist = scaled_data else: datalist = map(lambda x: x, enumerate(datalist)) #dbgprint(("datalist", len(datalist), "samples")) return datalist def drawfft(self, *args): #dbgprint(repr(args)) #dbgprint(("starting draw")) channels = self.file.getnchannels() samplewidth = self.file.getsampwidth() start = 0 end = self.maxwidth if self.beginselect is not None and self.endselect is not None: start = self.x_of(self.beginselect) end = self.x_of(self.endselect) + 1 #dbgprint(("start", start, "end", end)) try: self.file.setpos(self.sample_at(start)) #dbgprint(("starting fft at sample %d" % self.file.tell())) data = self.file.readframes((end - start) * self.multiplier) # now that the data is in string form, samplewidth is needed #dbgprint(("maxwidth", self.maxwidth, "len(data)", len(data))) chunksize = self.multiplier * samplewidth * channels #dbgprint(("chunksize", chunksize)) datalist = [] for index in range(0, len(data), chunksize): datalist.append(self.fftsample(index / chunksize, data[index:index + chunksize], channels, samplewidth)) #dbgprint(("datalist", datalist)) fft = [] for channel in range(0,channels): channeldata = map(lambda sample: sample[channel], datalist) fft.append(self.powerspectrum(FFT.fft(channeldata))) #dbgprint(("fft", fft)) # now redraw selected area # scale so largest possible frequency will fit canvas # for example, if canvas is 10 bytes tall and max FFT value # is 10000, we must make 10000 fit in the 5 pixels of the upper # two quadrants, that is, we must multiply by 5/10000 maxfft = max(map(max, fft)) #dbgprint(("maxfft", maxfft, "of %d samples" % len(fft[0]))) scale = self.maxheight / float(2 * maxfft) midpoint = int(self.maxheight / 2.0) for sample in range(0, end - start): #dbgprint(("about to draw sample %d" % sample)) self.drawfftsample(start + sample, map(None, range(0, channels), map(lambda channeldata: channeldata[sample], fft)), scale, midpoint, channels) except: sys.stderr.write("error occurred, barfing\n") raise def fftsample(self, *args): x, data, channels, samplewidth = args try: datalist = list(struct.unpack(self.format[samplewidth] * \ (len(data) / samplewidth), data)) chunksize = self.multiplier * channels #dbgprint((datalist)) datalist = self.x_scale_fft(datalist, channels, chunksize) #dbgprint((datalist)) return datalist #dbgprint((datalist)) except: raise def drawfftsample(self, *args): """draw a representation of an fft element""" x, datalist, scale, midpoint, channels = args #dbgprint(("drawing fft sample at %d" % x)) try: chunksize = self.multiplier * channels #dbgprint((datalist)) # now sort in such a way that the greatest absolute values # get drawn first, otherwise some parts of waveforms will # be hidden behind others datalist.sort(lambda a, b: cmp(abs(b[1]), abs(a[1]))) #dbgprint((datalist)) "first a white background separating fft from waveform" self.canvas.create_line(x, 0, x, self.maxheight, fill = 'white', width = 1, tags = ('f', 'c', 'w')) for channel, sample in datalist: self.canvas.create_line(x, # x start midpoint, # y start x, # x end -(scale * sample) + midpoint, # y end fill = self.inversecolor[self.color[channel]], width = 1, tags = ('f', 'c', str(channel)) ) except: raise def x_scale_fft(self, *args): datalist, channels, chunksize = args #dbgprint(("datalist", len(datalist), "samples")) if self.multiplier > 1: # we need to create a new list of samples which are the # average of the samples within the scale specified. # for example, assume a scale of 2, one channel, and # one byte per sample. here is the data stream: # | -8 | -6 | -3 | 0 | 2 | 5 | # that needs to be reduced to: | -7 | -2 | 4 | scaled_data = [] for index in range(0, len(datalist), chunksize): for channel in range(0, channels): start = index + channel end = index + channel + chunksize skip = channels sample = datalist[start:end:skip] scaled_data.append(sum(sample) / len(sample)) datalist = scaled_data #dbgprint(("datalist", len(datalist), "samples")) return datalist def play(self, *args): if self.playsound: try: start = self.fileposition end = start + (self.canvas.winfo_width() * self.multiplier) if self.beginselect is not None and self.endselect is not None: start = self.beginselect end = self.endselect samplesize = end - start soundstring = StringIO.StringIO() soundout = wave.open(soundstring, "wb") self.file.setpos(start) data = self.file.readframes(samplesize) soundout.setnchannels(self.file.getnchannels()) soundout.setsampwidth(self.file.getsampwidth()) soundout.setframerate(self.file.getframerate()) soundout.writeframes(data) #dbgprint(soundstring.getvalue()) winsound.PlaySound(soundstring.getvalue(), winsound.SND_MEMORY) soundout.close() soundstring.close() except: dbgprint(("could not play sound")) raise def play_all(self, *args): if self.playsound: try: winsound.PlaySound(self.filename, winsound.SND_FILENAME) except: dbgprint(("cannot play soundfile")) raise def powerspectrum(self, *args): transform = args[0] """Power spectrum code from University of Cape Town website http://www.phy.uct.ac.za/courses/phy400w/cp/pyfrags.htm""" spectrum = abs(transform * conjugate(transform)) / len(transform) ** 2 #dbgprint(spectrum) return spectrum if __name__ == "__main__": sedit.main(sedit())