1"""
2tkSnack
3An interface to Kare Sjolander's Snack Tcl extension
4http://www.speech.kth.se/snack/index.html
5
6by Kevin Russell and Kare Sjolander
7last modified: Mar 28, 2003
8"""
9
10import Tkinter
11import types
12import string
13
14Tkroot = None
15audio = None
16mixer = None
17
18def initializeSnack(newroot):
19    global Tkroot, audio, mixer
20    Tkroot = newroot
21    Tkroot.tk.call('eval', 'package require snack')
22    Tkroot.tk.call('snack::createIcons')
23    Tkroot.tk.call('snack::setUseOldObjAPI')
24    audio = AudioControllerSingleton()
25    mixer = MixerControllerSingleton()
26
27
28def _cast(astring):
29    """This function tries to convert a string returned by a Tcl call
30    to a Python integer or float, if possible, and otherwise passes on the
31    raw string (or None instead of an empty string)."""
32    try:
33        return int(astring)
34    except ValueError:
35        try:
36            return float(astring)
37        except ValueError:
38            if astring:
39                return astring
40            else:
41                return None
42
43
44class NotImplementedException(Exception):
45    pass
46
47
48class TkObject:
49    """A mixin class for various Python/Tk communication functions,
50    such as reading and setting the object's configuration options.
51    We put them in a mixin class so we don't have to keep repeating
52    them for sounds, filters, and spectrograms.
53    These are mostly copied from the Tkinter.Misc class."""
54
55    def _getboolean(self, astring):
56        if astring:
57            return self.tk.getboolean(astring)
58
59    def _getints(self, astring):
60        if astring:
61            return tuple(map(int, self.tk.splitlist(astring)))
62
63    def _getdoubles(self, astring):
64        if astring:
65            return tuple(map(float, self.tk.splitlist(astring)))
66
67    def _options(self, cnf, kw=None):
68        if kw:
69            cnf = Tkinter._cnfmerge((cnf, kw))
70        else:
71            cnf = Tkinter._cnfmerge(cnf)
72        res = ()
73        for k,v in cnf.items():
74            if v is not None:
75                if k[-1] == '_': k = k[:-1]
76                #if callable(v):
77                #    v = self._register(v)
78                res = res + ('-'+k, v)
79        return res
80
81    def configure(self, cnf=None, **kw):
82        self._configure(cnf, kw)
83
84    def _configure(self, cnf=None, kw={}):
85        if kw:
86            cnf = Tkinter._cnfmerge((cnf, kw))
87        elif cnf:
88            cnf = Tkinter._cnfmerge(cnf)
89        if cnf is None:
90            cnf = {}
91            for x in self.tk.split(
92                self.tk.call(self.name, 'configure')):
93                cnf[x[0][1:]] = (x[0][1:],) + x[1:]
94                return cnf
95        if type(cnf) is types.StringType:
96            x = self.tk.split(self.tk.call(self.name, 'configure', '-'+cnf))
97            return (x[0][1:],) + x[1:]
98        self.tk.call((self.name, 'configure') + self._options(cnf))
99    config = configure
100
101    def cget(self, key):
102        return _cast(self.tk.call(self.name, 'cget' , '-'+key))
103
104    # Set "cget" as the method to handle dictionary-like attribute access
105    __getitem__ = cget
106
107    def __setitem__(self, key, value):
108        self.configure({key: value})
109
110    def keys(self):
111        return map(lambda x: x[0][1:],
112                   self.tk.split(self.tk.call(self.name, 'configure')))
113
114    def __str__(self):
115        return self.name
116
117
118
119class Sound (TkObject):
120
121    def __init__(self, name=None, master=None, **kw):
122        self.name = None
123        if not master:
124            if Tkroot:
125                master = Tkroot
126            else:
127                raise RuntimeError, \
128                      'Tk not intialized or not registered with Snack'
129        self.tk = master.tk
130        if not name:
131            self.name = self.tk.call(('sound',) + self._options(kw))
132        else:
133            self.name = self.tk.call(('sound', name) + self._options(kw))
134        #self._configure(cnf, kw)
135
136    def append(self, binarydata, **kw):
137        """Appends binary string data to the end of the sound."""
138        self.tk.call((self.name, 'append', binarydata) + self._options(kw))
139
140    def concatenate(self, othersound):
141        """Concatenates the sample data from othersound to the end of
142        this sound.  Both sounds must be of the same type."""
143        self.tk.call(self.name, 'concatenate', othersound.name)
144
145    def configure(self, **kw):
146        """The configure command is used to set options for a sound."""
147        self.tk.call((self.name, 'configure') + self._options(kw))
148
149    def copy(self, sound, **kw):
150        """Copies sample data from another sound into self."""
151        self.tk.call((self.name, 'copy', sound.name) + self._options(kw))
152
153    def changed(self, flag):
154        """This command is used to inform Snack that the sound object has been
155        modified. Normally Snack tracks changes to sound objects automatically,
156        but in a few cases this must be performed explicitly. For example,
157        if individual samples are changed using the sample command these
158        will not be tracked for performance reasons."""
159        self.tk.call((self.name, 'changed', flag))
160
161    def convert(self, **kw):
162        """Convert a sound to a different sample encoding, sample rate,
163        or number of channels."""
164        self.tk.call((self.name, 'convert') + self._options(kw))
165
166    def crop(self, start=1, end=None, **kw):
167        """Removes all samples outside of the range [start..end]."""
168        if end is None:
169            end = self.length()
170        self.tk.call((self.name, 'crop', start, end) + self._options(kw))
171
172    def cut(self, start=1, end=None, **kw):
173        """Removes all samples inside the range [start..end]."""
174        if end is None:
175            end = self.length()
176        self.tk.call((self.name, 'cut', start, end) + self._options(kw))
177
178    def data(self, binarydata=None, **kw):
179        """Loads sound data from, or writes to, a binary string."""
180        if binarydata: # copy data to sound
181            self.tk.call((self.name, 'data', binarydata) + self._options(kw))
182        else: # return sound data
183            return self.tk.call((self.name, 'data') + self._options(kw))
184
185    def destroy(self):
186        """Removes the Tcl command for this sound and frees the storage
187        associated with it."""
188        self.tk.call(self.name, 'destroy')
189
190    def dBPowerSpectrum(self, **kw):
191        """Computes the log FFT power spectrum of the sound (at the time
192        given by the start option) and returns a list of dB values."""
193        result = self.tk.call((self.name, 'dBPowerSpectrum')
194                              + self._options(kw))
195        return self._getdoubles(result)
196
197    def powerSpectrum(self, **kw):
198        """Computes the FFT power spectrum of the sound (at the time
199        given by the start option) and returns a list of magnitude values."""
200        result = self.tk.call((self.name, 'powerSpectrum')
201                              + self._options(kw))
202        return self._getdoubles(result)
203
204    def filter(self, filter, **kw):
205        """Applies the given filter to the sound."""
206        return self.tk.call((self.name, 'filter', filter.name) +
207                            self._options(kw))
208
209    def formant(self, **kw):
210        """Returns a list of formant trajectories."""
211        result = self.tk.call((self.name, 'formant') + self._options(kw))
212        return map(self._getdoubles, self.tk.splitlist(result))
213
214    def flush(self):
215        """Removes all audio data from the sound."""
216        self.tk.call(self.name, 'flush')
217
218    def info(self, format='string'):
219        """Returns a list with information about the sound.  The entries are
220        [length, rate, max, min, encoding, channels, fileFormat, headerSize]
221        """
222        result = self.tk.call(self.name, 'info')
223        if format == 'list':
224            return map(self._cast, string.split(result))
225        else:
226            return result
227
228    def insert(self, sound, position, **kw):
229        """Inserts sound at position."""
230        self.tk.call((self.name, 'insert', sound.name, position) + self._options(kw))
231
232    def length(self, n=None, **kw):
233        """Gets/sets the length of the sound in number of samples (default)
234        or seconds, as determined by the 'units' option."""
235        if n is not None:
236            result = self.tk.call((self.name, 'length', n) + self._options(kw))
237        else:
238            result = self.tk.call((self.name, 'length') + self._options(kw))
239        return _cast(result)
240
241    def load(self, filename, **kw):
242        """Reads new sound data from a file.  Synonym for "read"."""
243        self.tk.call((self.name, 'read', filename) + self._options(kw))
244
245    def max(self, **kw):
246        """Returns the largest positive sample value of the sound."""
247        return _cast(self.tk.call((self.name, 'max') + self._options(kw)))
248
249    def min(self, **kw):
250        """Returns the largest negative sample value of the sound."""
251        return _cast(self.tk.call((self.name, 'min') + self._options(kw)))
252
253    def mix(self, sound, **kw):
254        """Mixes sample data from another sound into self."""
255        self.tk.call((self.name, 'mix', sound.name) + self._options(kw))
256
257    def pause(self):
258        """Pause current record/play operation.  Next pause invocation
259        resumes play/record."""
260        self.tk.call(self.name, 'pause')
261
262    def pitch(self, method=None, **kw):
263        """Returns a list of pitch values."""
264        if method is None or method is "amdf" or method is "AMDF":
265            result = self.tk.call((self.name, 'pitch') + self._options(kw))
266            return self._getdoubles(result)
267        else:
268            result = self.tk.call((self.name, 'pitch', '-method', method) +
269                                  self._options(kw))
270            return map(self._getdoubles, self.tk.splitlist(result))
271
272    def play(self, **kw):
273        """Plays the sound."""
274        self.tk.call((self.name, 'play') + self._options(kw))
275
276    def power(self, **kw):
277        """Computes the FFT power spectrum of the sound (at the time
278        given by the start option) and returns a list of power values."""
279        result = self.tk.call((self.name, 'power')
280                              + self._options(kw))
281        return self._getdoubles(result)
282
283    def read(self, filename, **kw):
284        """Reads new sound data from a file."""
285        self.tk.call((self.name, 'read', filename) + self._options(kw))
286
287    def record(self, **kw):
288        """Starts recording data from the audio device into the sound object."""
289        self.tk.call((self.name, 'record') + self._options(kw))
290
291    def reverse(self, **kw):
292        """Reverses a sound."""
293        self.tk.call((self.name, 'reverse') + self._options(kw))
294
295    def sample(self, index, left=None, right=None):
296        """Without left/right, this gets the sample value at index.
297        With left/right, it sets the sample value at index in the left
298        and/or right channels."""
299        if right is not None:
300            if left is None:
301                left = '?'
302            opts = (left, right)
303        elif left is not None:
304            opts = (left,)
305        else:
306            opts = ()
307        return _cast(self.tk.call((self.name, 'sample', index) + opts))
308
309    def stop(self):
310        """Stops current play or record operation."""
311        self.tk.call(self.name, 'stop')
312
313    def stretch(self, **kw):
314        self.tk.call((self.name, 'stretch') + self._options(kw))
315
316    def write(self, filename, **kw):
317        """Writes sound data to a file."""
318        self.tk.call((self.name, 'write', filename) + self._options(kw))
319
320
321class AudioControllerSingleton(TkObject):
322    """This class offers functions that control various aspects of the
323    audio devices.
324    It is written as a class instead of as a set of module-level functions
325    so that we can piggy-back on the Tcl-interface functions in TkObject,
326    and so that the user can invoke the functions in a way more similar to
327    how they're invoked in Tcl, e.g., snack.audio.rates().
328    It is intended that there only be once instance of this class, the
329    one created in snack.initialize.
330    """
331
332    def __init__(self):
333        self.tk = Tkroot.tk
334
335    def encodings(self):
336        """Returns a list of supported sample encoding formats for the
337        currently selected device."""
338        result = self.tk.call('snack::audio', 'encodings')
339        return self.tk.splitlist(result)
340
341    def rates(self):
342        """Returns a list of supported sample rates for the currently
343        selected device."""
344        result = self.tk.call('snack::audio', 'frequencies')
345        return self._getints(result)
346
347    def frequencies(self):
348        """Returns a list of supported sample rates for the currently
349        selected device."""
350        result = self.tk.call('snack::audio', 'frequencies')
351        return self._getints(result)
352
353    def inputDevices(self):
354        """Returns a list of available audio input devices"""
355        result = self.tk.call('snack::audio', 'inputDevices')
356        return self.tk.splitlist(result)
357
358    def playLatency(self, latency=None):
359        """Sets/queries (in ms) how much sound will be queued up at any
360        time to the audio device to play back."""
361        if latency is not None:
362            return _cast(self.tk.call('snack::audio', 'playLatency', latency))
363        else:
364            return _cast(self.tk.call('snack::audio', 'playLatency'))
365
366    def pause(self):
367        """Toggles between play/pause for all playback on the audio device."""
368        self.tk.call('snack::audio', 'pause')
369
370    def play(self):
371        """Resumes paused playback on the audio device."""
372        self.tk.call('snack::audio', 'play')
373
374    def play_gain(self, gain=None):
375        """Returns/sets the current play gain.  Valid values are integers
376        in the range 0-100."""
377        if gain is not None:
378            return _cast(self.tk.call('snack::audio', 'play_gain', gain))
379        else:
380            return _cast(self.tk.call('snack::audio', 'play_gain'))
381
382    def outputDevices(self):
383        """Returns a list of available audio output devices."""
384        result = self.tk.call('snack::audio', 'outputDevices')
385        return self.tk.splitlist(result)
386
387    def selectOutput(self, device):
388        """Selects an audio output device to be used as default."""
389        self.tk.call('snack::audio', 'selectOutput', device)
390
391    def selectInput(self, device):
392        """Selects an audio input device to be used as default."""
393        self.tk.call('snack::audio', 'selectInput', device)
394
395    def stop(self):
396        """Stops all playback on the audio device."""
397        self.tk.call('snack::audio', 'stop')
398
399    def elapsedTime(self):
400        """Return the time since the audio device started playback."""
401        result = self.tk.call('snack::audio', 'elapsedTime')
402        return self.tk.getdouble(result)
403
404class Filter(TkObject):
405
406    def __init__(self, name, *args, **kw):
407        global Tkroot
408        self.name = None
409        if Tkroot:
410            master = Tkroot
411        else:
412            raise RuntimeError, \
413                 'Tk not intialized or not registered with Snack'
414        self.tk = master.tk
415        self.name = self.tk.call(('snack::filter', name) + args +
416                                 self._options(kw))
417
418    def configure(self, *args):
419        """Configures the filter."""
420        self.tk.call((self.name, 'configure') + args)
421
422    def destroy(self):
423        """Removes the Tcl command for the filter and frees its storage."""
424        self.tk.call(self.name, 'destroy')
425
426
427class MixerControllerSingleton(TkObject):
428
429    """Like AudioControllerSingleton, this class is intended to have only
430    a single instance object, which will control various aspects of the
431    mixers."""
432
433    def __init__(self):
434        self.tk = Tkroot.tk
435
436    def channels(self, line):
437        """Returns a list with the names of the channels for the
438        specified line."""
439        result = self.tk.call('snack::mixer', 'channels', line)
440        return self.tk.splitlist(result)
441
442    def devices(self):
443        """Returns a list of the available mixer devices."""
444        result = self.tk.call('snack::mixer', 'devices')
445        return self.tk.splitlist(result)
446
447    def input(self, jack=None, tclVar=None):
448        """Gets/sets the current input jack.  Optionally link a boolean
449        Tcl variable."""
450        opts = ()
451        if jack is not None:
452            opts = opts + jack
453        if tclVar is not None:
454            opts = opts + tclVar
455        return self.tk.call(('snack::mixer', 'input') + opts)
456
457    def inputs(self):
458        """Returns a list of available input ports."""
459        result = self.tk.call('snack::mixer', 'inputs')
460        return self.tk.splitlist(result)
461
462    def lines(self):
463        """Returns a list with the names of the lines of the mixer device."""
464        result = self.tk.call('snack::mixer', 'lines')
465        return self.tk.splitlist(result)
466
467    def output(self, jack=None, tclVar=None):
468        """Gets/sets the current output jack.  Optionally link a boolean
469        Tcl variable."""
470        opts = ()
471        if jack is not None:
472            opts = opts + jack
473        if tclVar is not None:
474            opts = opts + tclVar
475        return self.tk.call(('snack::mixer', 'output') + opts)
476
477    def outputs(self):
478        """Returns a list of available output ports."""
479        result = self.tk.call('snack::mixer', 'outputs')
480        return self.tk.splitlist(result)
481
482    def update(self):
483        """Updates all linked variables to reflect the status of the
484        mixer device."""
485        self.tk.call('snack::mixer', 'update')
486
487    def volume(self, line, leftVar=None, rightVar=None):
488        if self.channels(line)[0] == 'Mono':
489            return self.tk.call('snack::mixer', 'volume', line, rightVar)
490        else:
491            return self.tk.call('snack::mixer', 'volume', line, leftVar, rightVar)
492
493    def select(self, device):
494        """Selects a device to be used as default."""
495        self.tk.call('snack::mixer', 'select', device)
496
497
498
499class SoundFrame(Tkinter.Frame):
500
501    """A simple "tape recorder" widget."""
502
503    def __init__(self, parent=None, sound=None, *args, **kw):
504        Tkinter.Frame.__init__(self)
505        if sound:
506            self.sound = sound
507        else:
508            self.sound = Sound()
509        self.canvas = SnackCanvas(self, height=100)
510        kw['sound'] = self.sound.name
511        self.canvas.create_waveform(0, 0, kw)
512        self.canvas.pack(side='top')
513        bbar = Tkinter.Frame(self)
514        bbar.pack(side='left')
515        Tkinter.Button(bbar, image='snackOpen', command=self.load
516                       ).pack(side='left')
517        Tkinter.Button(bbar, bitmap='snackPlay', command=self.play
518                       ).pack(side='left')
519        Tkinter.Button(bbar, bitmap='snackRecord', fg='red',
520                       command=self.record).pack(side='left')
521        Tkinter.Button(bbar, bitmap='snackStop', command=self.stop
522                       ).pack(side='left')
523        Tkinter.Button(bbar, text='Info', command=self.info).pack(side='left')
524
525
526    def load(self):
527        file = Tkroot.tk.call('eval', 'snack::getOpenFile')
528        self.sound.read(file, progress='snack::progressCallback')
529
530    def play(self):
531        self.sound.play()
532
533    def stop(self):
534        self.sound.stop()
535
536    def record(self):
537        self.sound.record()
538
539    def info(self):
540        print self.sound.info()
541
542def createSpectrogram(canvas, *args, **kw):
543    """Draws a spectrogram of a sound on canvas."""
544    return canvas._create('spectrogram', args, kw)
545
546def createSection(canvas, *args, **kw):
547    """Draws and FFT log power spectrum section on canvas."""
548    return canvas._create('section', args, kw)
549
550def createWaveform(canvas, *args, **kw):
551    """Draws a waveform on canvas."""
552    return canvas._create('waveform', args, kw)
553
554
555class SnackCanvas(Tkinter.Canvas):
556
557    def __init__(self, master=None, cnf={}, **kw):
558        Tkinter.Widget.__init__(self, master, 'canvas', cnf, kw)
559
560    def create_spectrogram(self, *args, **kw):
561        """Draws a spectrogram of a sound on the canvas."""
562        return self._create('spectrogram', args, kw)
563
564    def create_section(self, *args, **kw):
565        """Draws an FFT log power spectrum section."""
566        return self._create('section', args, kw)
567
568    def create_waveform(self, *args, **kw):
569        """Draws a waveform."""
570        return self._create('waveform', args, kw)
571
572
573if __name__ == '__main__':
574    # Create a test SoundFrame if the module is called as the main program
575    root = Tkinter.Tk()
576    initializeSnack(root)
577    frame = SoundFrame(root)
578    frame.pack(expand=0)
579    root.mainloop()
580