From 6ee3dbb07b39737a363ee16e0e6df2d607cb724c Mon Sep 17 00:00:00 2001 From: "Daniel W. Steinbrook" Date: Sun, 20 Jan 2013 21:59:25 -0500 Subject: [PATCH] Hello, GitHub. --- README.md | 12 ++++++ audio_app.py | 100 ++++++++++++++++++++++++++++++++++++++++++++ example.sf2 | Bin 0 -> 3576 bytes tests.py | 78 +++++++++++++++++++++++++++++++++++ text_editor.py | 110 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 300 insertions(+) create mode 100644 README.md create mode 100644 audio_app.py create mode 100644 example.sf2 create mode 100644 tests.py create mode 100644 text_editor.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ebab58 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +A text editor with no visual output whatsoever. A text-to-speech engine reads +edits aloud, and an auditory bell provides navigational feedback. + +Usage resembles TextEdit with VoiceOver enabled on OS X. This, however, is +implemented in just a couple hundred lines of Python; plus, it's +cross-platform, thanks to the [pyttsx](http://pypi.python.org/pypi/pyttsx) +and [pyfluidsynth](http://code.google.com/p/pyfluidsynth/) libraries. + +Pre-alpha stability. BSD licensed. + +--- +Daniel W. Steinbrook \ No newline at end of file diff --git a/audio_app.py b/audio_app.py new file mode 100644 index 0000000..48601ab --- /dev/null +++ b/audio_app.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python + +import curses +import time + +import fluidsynth +import pyttsx + + +class AudioApp(object): + '''Base class for auditory UI.''' + def __init__(self, args): + # initialize text-to-speech + self.narrator = pyttsx.init() + #self.narrator.setProperty('voice', 'english-us') # espeak + self.narrator.setProperty('rate', 500) + + # initialize audio synthesizer + self.synth = fluidsynth.Synth() + self.synth.start() + + example = self.synth.sfload('example.sf2') + self.synth.program_select(0, example, 0, 0) + + def speak(self, phrase): + self.narrator.say(phrase) + self.narrator.runAndWait() + + def speak_char(self, char): + # TTS engine might not know how to pronounce these characters + mapping = { + 'y': 'why', + '.': 'dot', + ' ': 'space', + ',': 'comma', + ';': 'semicolon', + '-': 'dash', + ':': 'colon', + '/': 'slash', + '\\': 'backslash', + '?': 'question mark', + '!': 'bang', + '@': 'at', + '#': 'pound', + '$': 'dollar', + '%': 'percent', + '*': 'star', + '^': 'caret', + '~': 'squiggle' + } + speakable = char.lower() + + if speakable in mapping: + speakable = mapping[speakable] + elif char.isalpha(): + speakable = char + else: + speakable = 'splork' # say something better + + return self.speak(speakable) + + def play_interval(self, size, duration, root=80, delay=0, intensity=30, channel=0): + self.synth.noteon(channel, root, intensity) + time.sleep(delay) + self.synth.noteon(channel, root + size, intensity) + time.sleep(duration) + self.synth.noteoff(channel, root) + self.synth.noteoff(channel, root + size) + + def handle_key(self, key): + '''Individual apps (subclasses) must override this method.''' + raise NotImplementedError + + def run(self): + '''Loop indefinitely, passing keystrokes to handle_key until that + method returns false.''' + try: + stdscr = curses.initscr() + curses.noecho() + curses.cbreak() + stdscr.keypad(1) + + while True: + c = stdscr.getch() + if not self.handle_key(c): break + + finally: + curses.nocbreak() + stdscr.keypad(0) + curses.echo() + curses.endwin() + + # for debugging + def simulate_keystroke(self, key): + #time.sleep(random.uniform(0.01, 0.05)) + self.handle_key(key) + + def simulate_typing(self, string): + for char in string: + self.simulate_keystroke(ord(char)) diff --git a/example.sf2 b/example.sf2 new file mode 100644 index 0000000000000000000000000000000000000000..7fb39c07e4ae5ab71cb76f1bdf9a212e61e56fa8 GIT binary patch literal 3576 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 literal 0 HcmV?d00001 diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..aeb070e --- /dev/null +++ b/tests.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python + +import curses +import curses.ascii +import random +import string +import tempfile +import unittest + +from text_editor import TextEditor + + +class TextEditorTest(unittest.TestCase): + def setUp(self): + self.outfile = tempfile.NamedTemporaryFile() + self.args = ['text_editor.py', self.outfile.name] + self.app = TextEditor(self.args) + + # Override the sound-generating methods with silent recordkeeping. + self.utterances = [] + def instead_of_sounds(phrase, a=0, b=0, c=0, d=0, e=0): + if type(phrase) is str: + self.utterances.append(phrase) + else: + self.utterances.append('[tone]') + self.app.speak = instead_of_sounds + self.app.play_interval = instead_of_sounds + + def tearDown(self): + #print repr(self.utterances) + pass + + def test_simple(self): + # should speak previous word after non-alpha character + self.app.simulate_typing('hello world ') + self.assertEqual(self.utterances[-1], 'world') + + # should speak character just deleted + self.app.simulate_keystroke(curses.ascii.DEL) + self.assertEqual(self.utterances[-1], 'space') + + # should play tone when attempting to move cursor beyond bounds + self.app.simulate_keystroke(curses.KEY_RIGHT) + self.assertEqual(self.utterances[-1], '[tone]') + + for i in range(5): # move cursor five characters left + self.app.simulate_keystroke(curses.KEY_LEFT) + self.app.simulate_typing('awesome ') + + self.app.simulate_keystroke(curses.ascii.ESC) # quit + self.outfile.seek(0) + + # check that contents were properly saved + self.assertEqual(self.outfile.read(), "hello awesome world") + + def test_symbols(self): + self.app.simulate_typing('foo') + self.app.simulate_keystroke(curses.ascii.ESC) # quit + + self.app = TextEditor(self.args) # re-open + self.app.simulate_typing('~ ~') + self.app.simulate_keystroke(curses.ascii.ESC) # quit + + self.outfile.seek(0) + self.assertEqual(self.outfile.read(), "~ ~foo") + + def test_gibberish(self): + # type n random printable characters + n = 100 + self.app.simulate_typing( + random.choice(string.printable) for i in range(n)) + self.app.simulate_keystroke(curses.ascii.ESC) # quit + self.outfile.seek(0) + + self.assertEqual(n, len(self.outfile.read())) + +if __name__ == '__main__': + unittest.main() diff --git a/text_editor.py b/text_editor.py new file mode 100644 index 0000000..7b952d1 --- /dev/null +++ b/text_editor.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python + +import curses +import curses.ascii +import os +import sys + +from audio_app import AudioApp + + +class TextEditor(AudioApp): + def __init__(self, args): + self.buffer = '' + self.cursor = 0 + + if len(args) > 1: + path = args[1] + if os.path.exists(path): + f = open(path) + self.buffer = f.read() + f.close() + self.fd = open(path, 'w') + + super(TextEditor, self).__init__(args) + + def move_left(self): + if self.cursor > 0: + self.cursor -= 1 + self.speak_char(self.buffer[self.cursor]) + return True + else: + self.play_interval(1, 0.1) + return False + + def move_right(self): + if self.cursor < len(self.buffer): + self.speak_char(self.buffer[self.cursor]) + self.cursor += 1 + return True + else: + self.play_interval(1, 0.1) + return False + + def insert_char(self, char): + self.buffer = self.buffer[:self.cursor] + char + self.buffer[self.cursor:] + self.cursor += 1 + + def remove_char(self): + if self.cursor == 0: + self.play_interval(1, 0.1) + return False + + old_char = self.buffer[self.cursor - 1] + self.buffer = self.buffer[:self.cursor - 1] + self.buffer[self.cursor:] + self.cursor -= 1 + return old_char + + def backspace(self): + deleted = self.remove_char() + if deleted: + self.speak_char(deleted) + + def last_word(self, ending_at=None): + if ending_at is None: + ending_at = self.cursor - 1 + + if self.cursor < 2: + return self.buffer[0] + + start = ending_at - 1 + while self.buffer[start].isalpha() and start > 0: + start -= 1 + + return self.buffer[start:ending_at + 1].strip() + + def close(self): + if hasattr(self, 'fd'): + self.fd.write(self.buffer) # write changes to disk + self.fd.close() + + def handle_key(self, key): + #sys.stderr.write(str(key)) + + if key == curses.ascii.ESC: + self.close() + return False # exit + + elif key == curses.KEY_LEFT: + self.move_left() + + elif key == curses.KEY_RIGHT: + self.move_right() + + elif key == curses.ascii.DEL: #curses.KEY_BACKSPACE: + self.backspace() + + elif curses.ascii.isalpha(key): + self.insert_char(chr(key)) + + elif curses.ascii.isprint(key) or curses.ascii.isspace(key): + self.insert_char(chr(key)) + self.speak(self.last_word()) + #sys.stderr.write(self.buffer + '\r') + + return True # keep reading keystrokes + + +if __name__ == '__main__': + app = TextEditor(sys.argv) + app.run()