Skip to content

Commit

Permalink
Hello, GitHub.
Browse files Browse the repository at this point in the history
  • Loading branch information
steinbro committed Jan 21, 2013
0 parents commit 6ee3dbb
Show file tree
Hide file tree
Showing 5 changed files with 300 additions and 0 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>
100 changes: 100 additions & 0 deletions audio_app.py
Original file line number Diff line number Diff line change
@@ -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))
Binary file added example.sf2
Binary file not shown.
78 changes: 78 additions & 0 deletions tests.py
Original file line number Diff line number Diff line change
@@ -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()
110 changes: 110 additions & 0 deletions text_editor.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit 6ee3dbb

Please sign in to comment.