diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c20c2ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ + diff --git a/README.md b/README.md index b1142c8..b0c7969 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Features: - Limits output to an authentic 10 characters per second. Hit F5 to make it go faster (toggle on tkinter frontend, hold on pygame) +- Sound (with the pygame frontend)! If it's too loud, hit F7 to close the lid. + - Scrolling (with page up and down - tkinter frontend has a scrollbar) - Output a form feed to clear everything diff --git a/sounds.py b/sounds.py new file mode 100644 index 0000000..3c72093 --- /dev/null +++ b/sounds.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 + +""" +Teletype sounds +(requires pygame) +""" + +import os +import random +import logging +import pygame +from pygame.mixer import Sound + + +logger = logging.getLogger(__name__) + + +class PygameSounds: + """Teletype sounds, using pygame mixer""" + + # Some events + EVENT_HUM = pygame.USEREVENT+2 + EVENT_KEY = pygame.USEREVENT+3 + EVENT_CHR = pygame.USEREVENT+4 + EVENT_SYNC = pygame.USEREVENT+5 + EVENTS = [EVENT_HUM, EVENT_KEY, EVENT_CHR, EVENT_SYNC] + + def __init__(self): + pygame.mixer.pre_init(frequency=48000, size=-16, channels=2, buffer=512) + # Load the sounds into a dict for easy access + self.sounds = {} + # Start with the lid up (close the lid with F7 if you want peace and quiet) + self.lid_state = "up" + # Channels for playback + self.ch0 = None + self.ch1 = None + self.ch2 = None + # Channels for effects + self._chfx = [None, None, None] + # The current channel for effects + self._fx = 0 + # Sounds that we keep using + self.hum_sound = None + self.spaces_sound = None + self.chars_sound = None + # How many keypresses are queued (including current) + self.active_key_count = 0 + # What characters are queued to print (including current) + self.active_printout = "" + + def get(self, sound_name): + # Get a sound by name. + # All sounds depend on whether the lid is open, that's part of the name. + actual_name = self.lid_state + "-" + sound_name + if sound_name in self.sounds: + return self.sounds[actual_name] + # There are some 'repeated sample' sounds (e.g. keys) where we choose one at random from the set + sounds = [sound for name, sound in self.sounds.items() if name.startswith(actual_name)] + return random.sample(sounds, 1)[0] + + def start(self): + # Load the sound library + try: + with os.scandir(path=os.path.join(os.path.dirname(__file__), "sounds")) as scan: + for entry in scan: + if entry.is_file: + filename, ext = os.path.splitext(entry.name) + if ext == ".wav": + self.sounds[filename] = Sound(entry.path) + except pygame.error: + logging.exception("Could not initialize sounds.") + return + if not self.sounds: + logging.error("Could not load sounds.") + return + + pygame.mixer.set_reserved(6) + self.ch0 = pygame.mixer.Channel(0) # used for on/off, background hum, lid + self.ch1 = pygame.mixer.Channel(1) # printing spaces (loop) + self.ch2 = pygame.mixer.Channel(3) # printing characters (loop) + self._chfx = [ # fx: input (keypresses), platen, bells, etc + pygame.mixer.Channel(3), + pygame.mixer.Channel(4), + pygame.mixer.Channel(5) + ] + + self.hum_sound = self.get("hum") + self.spaces_sound = self.get("print-spaces") + self.chars_sound = self.get("print-chars") + + # Play the power-on sound, then to background after 1.5 + self.ch0.play(self.get("motor-on")) + pygame.time.set_timer(self.EVENT_SYNC, 1500) + pygame.time.wait(1000) + self._start_paused() + + @property + def chfx(self): + # Get a channel for effects + for i in range(len(self._chfx)): + channel = self._chfx[i] + if not channel.get_busy(): + self._fx = i + return channel + self._fx = (self._fx + 1) % len(self._chfx) + return self._chfx[self._fx] + + def stop(self): + if not self.sounds: + return + # Play the power-off sound + self.ch0.play(self.get("motor-off")) + # Wait until it plays out a bit + pygame.time.wait(500) + # Fade out over 1 second + self.ch0.fadeout(1000) + pygame.time.wait(1000) + + def lid(self): + """Open or close the lid.""" + if not self.sounds: + return + logger.debug("lid") + self._fade_to_hum() + self.chfx.play(self.get("lid")) + # Flip the lid state + if self.lid_state == "down": + self.lid_state = "up" + else: + self.lid_state = "down" + # The main sounds will change with the new lid position + pygame.time.set_timer(self.EVENT_SYNC, 250) + + def platen(self): + """Hand-scrolled platen for page up & down""" + if not self.sounds: + return + logger.debug("platen") + self.chfx.play(self.get("platen")) + + def _start_loops(self): + self.hum_sound = self.get("hum") + self.spaces_sound = self.get("print-spaces") + self.chars_sound = self.get("print-chars") + self.ch0.play(self.hum_sound, loops=-1) + self.ch1.play(self.spaces_sound, loops=-1) + self.ch2.play(self.chars_sound, loops=-1) + self.hum_sound.set_volume(0.0) + self.spaces_sound.set_volume(0.0) + self.chars_sound.set_volume(0.0) + + def _start_paused(self): + self._start_loops() + self.ch1.pause() + self.ch2.pause() + + def keypress(self, key): + """Key pressed at the keyboard (may or may not echo)""" + if not self.sounds: + return + logger.debug("keypress") + self.active_key_count = self.active_key_count + 1 + if self.active_key_count > 1: + # Just queue it and keep going + return + # In a while we can press another key + pygame.time.set_timer(self.EVENT_KEY, 100) + self._sound_for_keypress() + + def _sound_for_keypress(self): + if self.active_key_count <= 0: + # No next keypress. Cancel the timer. + pygame.time.set_timer(self.EVENT_KEY, 0) + else: + # Press any key (they all sound similar) + self.chfx.play(self.get("key")) + + def print_chars(self, chars): + if not self.sounds: + return + logger.debug("print: %s", chars) + # Add to the string that we're printing + self.active_printout = self.active_printout + chars + # Set the print timer for 100ms (repeats) + pygame.time.set_timer(self.EVENT_CHR, 100) + self._sound_for_char() + + def _sound_for_char(self): + next_char = self.active_printout[:1] + if next_char == "": + # No next character. Go back to hum. + pygame.time.set_timer(self.EVENT_CHR, 0) + pygame.time.set_timer(self.EVENT_HUM, 100) + elif next_char == '\r': + # Carriage return (not newline, that just sounds as a space) + self.hum_sound.set_volume(0.0) + self.spaces_sound.set_volume(1.0) + self.chars_sound.set_volume(0.0) + self.chfx.play(self.get("cr")) + # Reset the loop timing + pygame.time.set_timer(self.EVENT_SYNC, 10) + elif next_char == '\007': + # Mute the hum/print while we do this + self.hum_sound.set_volume(0.0) + self.spaces_sound.set_volume(0.0) + self.chars_sound.set_volume(0.0) + self.chfx.play(self.get("bell")) + elif ord(next_char) <= 32 or next_char.isspace(): + # Control characters and spaces + self._fade_to_spaces() + else: + # Treat anything else as printable + self._fade_to_chars() + + def _fade_to_hum(self): + if self.hum_sound.get_volume() <= 0.99: + self.hum_sound.set_volume(0.3) + self.spaces_sound.set_volume(self.spaces_sound.get_volume() * 0.7) + self.chars_sound.set_volume(self.chars_sound.get_volume() * 0.7) + pygame.time.wait(3) + self.hum_sound.set_volume(0.5) + self.spaces_sound.set_volume(self.spaces_sound.get_volume() * 0.7) + self.chars_sound.set_volume(self.chars_sound.get_volume() * 0.7) + pygame.time.wait(3) + self.hum_sound.set_volume(0.7) + self.spaces_sound.set_volume(self.spaces_sound.get_volume() * 0.7) + self.chars_sound.set_volume(self.chars_sound.get_volume() * 0.7) + pygame.time.wait(3) + self.hum_sound.set_volume(1.0) + self.spaces_sound.set_volume(0.0) + self.chars_sound.set_volume(0.0) + self.ch1.pause() + self.ch2.pause() + + def _fade_to_spaces(self): + if self.spaces_sound.get_volume() <= 0.99: + self.hum_sound.set_volume(self.hum_sound.get_volume() * 0.7) + self.spaces_sound.set_volume(0.3) + self.chars_sound.set_volume(self.chars_sound.get_volume() * 0.7) + pygame.time.wait(3) + self.hum_sound.set_volume(self.hum_sound.get_volume() * 0.7) + self.spaces_sound.set_volume(0.5) + self.chars_sound.set_volume(self.chars_sound.get_volume() * 0.7) + pygame.time.wait(3) + self.hum_sound.set_volume(self.hum_sound.get_volume() * 0.7) + self.spaces_sound.set_volume(0.7) + self.chars_sound.set_volume(self.chars_sound.get_volume() * 0.7) + pygame.time.wait(3) + self.hum_sound.set_volume(0.0) + self.spaces_sound.set_volume(1.0) + self.chars_sound.set_volume(0.0) + self.ch1.unpause() + self.ch2.unpause() + + def _fade_to_chars(self): + if self.chars_sound.get_volume() <= 0.99: + self.hum_sound.set_volume(self.hum_sound.get_volume() * 0.7) + self.spaces_sound.set_volume(self.spaces_sound.get_volume() * 0.7) + self.chars_sound.set_volume(0.3) + pygame.time.wait(3) + self.hum_sound.set_volume(self.hum_sound.get_volume() * 0.7) + self.spaces_sound.set_volume(self.spaces_sound.get_volume() * 0.7) + self.chars_sound.set_volume(0.5) + pygame.time.wait(3) + self.hum_sound.set_volume(self.hum_sound.get_volume() * 0.7) + self.spaces_sound.set_volume(self.spaces_sound.get_volume() * 0.7) + self.chars_sound.set_volume(0.7) + pygame.time.wait(3) + self.hum_sound.set_volume(0.0) + self.spaces_sound.set_volume(0.0) + self.chars_sound.set_volume(1.0) + self.ch1.unpause() + self.ch2.unpause() + + def event(self, evt): + # A pygame event happened + if not self.sounds: + return + if evt == self.EVENT_HUM: + logger.debug("EVENT_HUM") + # Cancel the hum timer + pygame.time.set_timer(self.EVENT_HUM, 0) + # Background hum (unless there's print pending) + if self.active_printout: + # No hum yet, we're printing + return + # Go back to playing the hum on loop, and pause the spaces/chars loops + self._fade_to_hum() + + elif evt == self.EVENT_KEY: + logger.debug("EVENT_KEY") + self.active_key_count = self.active_key_count - 1 + self._sound_for_keypress() + + elif evt == self.EVENT_CHR: + self.active_printout = self.active_printout[1:] + self._sound_for_char() + + elif evt == self.EVENT_SYNC: + # Sync after startup and CR: reset the spaces/chars loops. + pygame.time.set_timer(self.EVENT_SYNC, 0) + pygame.time.set_timer(self.EVENT_HUM, 100) + self._start_loops() + + else: + logger.debug("Event: %s", evt) diff --git a/sounds/README.md b/sounds/README.md new file mode 100644 index 0000000..4cdd64c --- /dev/null +++ b/sounds/README.md @@ -0,0 +1,7 @@ +# Sounds of the Teletype + +Recorded with a Zoom H3-VR microphone. + +## License + +These audio materials are licensed under a [Creative Commons Attribution–ShareAlike 4.0 International License](http://creativecommons.org/licenses/by-sa/4.0/). This allows you to use these materials without additional permission provided that you cite Hugh Pyle as the source and you license anything you create using the materials under the same or equivalent license. diff --git a/sounds/down-bell.wav b/sounds/down-bell.wav new file mode 100644 index 0000000..9c21058 Binary files /dev/null and b/sounds/down-bell.wav differ diff --git a/sounds/down-cr-01.wav b/sounds/down-cr-01.wav new file mode 100644 index 0000000..773e535 Binary files /dev/null and b/sounds/down-cr-01.wav differ diff --git a/sounds/down-cr-02.wav b/sounds/down-cr-02.wav new file mode 100644 index 0000000..2f9299e Binary files /dev/null and b/sounds/down-cr-02.wav differ diff --git a/sounds/down-cr-03.wav b/sounds/down-cr-03.wav new file mode 100644 index 0000000..af7e39d Binary files /dev/null and b/sounds/down-cr-03.wav differ diff --git a/sounds/down-hum.wav b/sounds/down-hum.wav new file mode 100644 index 0000000..16528e5 Binary files /dev/null and b/sounds/down-hum.wav differ diff --git a/sounds/down-key-01.wav b/sounds/down-key-01.wav new file mode 100644 index 0000000..e0d88cd Binary files /dev/null and b/sounds/down-key-01.wav differ diff --git a/sounds/down-key-02.wav b/sounds/down-key-02.wav new file mode 100644 index 0000000..c9f9695 Binary files /dev/null and b/sounds/down-key-02.wav differ diff --git a/sounds/down-key-03.wav b/sounds/down-key-03.wav new file mode 100644 index 0000000..3f66e8e Binary files /dev/null and b/sounds/down-key-03.wav differ diff --git a/sounds/down-key-04.wav b/sounds/down-key-04.wav new file mode 100644 index 0000000..cd31bdc Binary files /dev/null and b/sounds/down-key-04.wav differ diff --git a/sounds/down-key-05.wav b/sounds/down-key-05.wav new file mode 100644 index 0000000..e42bbf9 Binary files /dev/null and b/sounds/down-key-05.wav differ diff --git a/sounds/down-key-06.wav b/sounds/down-key-06.wav new file mode 100644 index 0000000..e2f2610 Binary files /dev/null and b/sounds/down-key-06.wav differ diff --git a/sounds/down-key-07.wav b/sounds/down-key-07.wav new file mode 100644 index 0000000..37bc63e Binary files /dev/null and b/sounds/down-key-07.wav differ diff --git a/sounds/down-lid.wav b/sounds/down-lid.wav new file mode 100644 index 0000000..38e8ff7 Binary files /dev/null and b/sounds/down-lid.wav differ diff --git a/sounds/down-motor-off.wav b/sounds/down-motor-off.wav new file mode 100644 index 0000000..c09aeef Binary files /dev/null and b/sounds/down-motor-off.wav differ diff --git a/sounds/down-motor-on.wav b/sounds/down-motor-on.wav new file mode 100644 index 0000000..2a7720d Binary files /dev/null and b/sounds/down-motor-on.wav differ diff --git a/sounds/down-platen.wav b/sounds/down-platen.wav new file mode 100644 index 0000000..f590426 Binary files /dev/null and b/sounds/down-platen.wav differ diff --git a/sounds/down-print-chars-01.wav b/sounds/down-print-chars-01.wav new file mode 100644 index 0000000..27f366b Binary files /dev/null and b/sounds/down-print-chars-01.wav differ diff --git a/sounds/down-print-chars-02.wav b/sounds/down-print-chars-02.wav new file mode 100644 index 0000000..4756896 Binary files /dev/null and b/sounds/down-print-chars-02.wav differ diff --git a/sounds/down-print-spaces-01.wav b/sounds/down-print-spaces-01.wav new file mode 100644 index 0000000..13f19cf Binary files /dev/null and b/sounds/down-print-spaces-01.wav differ diff --git a/sounds/down-print-spaces-02.wav b/sounds/down-print-spaces-02.wav new file mode 100644 index 0000000..cc66b0a Binary files /dev/null and b/sounds/down-print-spaces-02.wav differ diff --git a/sounds/up-bell.wav b/sounds/up-bell.wav new file mode 100644 index 0000000..ab816b8 Binary files /dev/null and b/sounds/up-bell.wav differ diff --git a/sounds/up-cr-01.wav b/sounds/up-cr-01.wav new file mode 100644 index 0000000..14b3f33 Binary files /dev/null and b/sounds/up-cr-01.wav differ diff --git a/sounds/up-cr-02.wav b/sounds/up-cr-02.wav new file mode 100644 index 0000000..a1259b9 Binary files /dev/null and b/sounds/up-cr-02.wav differ diff --git a/sounds/up-hum.wav b/sounds/up-hum.wav new file mode 100644 index 0000000..f320705 Binary files /dev/null and b/sounds/up-hum.wav differ diff --git a/sounds/up-key-01.wav b/sounds/up-key-01.wav new file mode 100644 index 0000000..34469b1 Binary files /dev/null and b/sounds/up-key-01.wav differ diff --git a/sounds/up-key-02.wav b/sounds/up-key-02.wav new file mode 100644 index 0000000..2a49cc7 Binary files /dev/null and b/sounds/up-key-02.wav differ diff --git a/sounds/up-key-03.wav b/sounds/up-key-03.wav new file mode 100644 index 0000000..ef50694 Binary files /dev/null and b/sounds/up-key-03.wav differ diff --git a/sounds/up-key-04.wav b/sounds/up-key-04.wav new file mode 100644 index 0000000..5869839 Binary files /dev/null and b/sounds/up-key-04.wav differ diff --git a/sounds/up-key-05.wav b/sounds/up-key-05.wav new file mode 100644 index 0000000..d1e5e68 Binary files /dev/null and b/sounds/up-key-05.wav differ diff --git a/sounds/up-key-06.wav b/sounds/up-key-06.wav new file mode 100644 index 0000000..dbc9ea0 Binary files /dev/null and b/sounds/up-key-06.wav differ diff --git a/sounds/up-key-07.wav b/sounds/up-key-07.wav new file mode 100644 index 0000000..b09ff7e Binary files /dev/null and b/sounds/up-key-07.wav differ diff --git a/sounds/up-lid.wav b/sounds/up-lid.wav new file mode 100644 index 0000000..e95c55a Binary files /dev/null and b/sounds/up-lid.wav differ diff --git a/sounds/up-motor-off.wav b/sounds/up-motor-off.wav new file mode 100644 index 0000000..683858f Binary files /dev/null and b/sounds/up-motor-off.wav differ diff --git a/sounds/up-motor-on.wav b/sounds/up-motor-on.wav new file mode 100644 index 0000000..9bc6a17 Binary files /dev/null and b/sounds/up-motor-on.wav differ diff --git a/sounds/up-platen.wav b/sounds/up-platen.wav new file mode 100644 index 0000000..55f72ff Binary files /dev/null and b/sounds/up-platen.wav differ diff --git a/sounds/up-print-chars-01.wav b/sounds/up-print-chars-01.wav new file mode 100644 index 0000000..e869768 Binary files /dev/null and b/sounds/up-print-chars-01.wav differ diff --git a/sounds/up-print-chars-02.wav b/sounds/up-print-chars-02.wav new file mode 100644 index 0000000..e003295 Binary files /dev/null and b/sounds/up-print-chars-02.wav differ diff --git a/sounds/up-print-spaces-01.wav b/sounds/up-print-spaces-01.wav new file mode 100644 index 0000000..3bff753 Binary files /dev/null and b/sounds/up-print-spaces-01.wav differ diff --git a/sounds/up-print-spaces-02.wav b/sounds/up-print-spaces-02.wav new file mode 100644 index 0000000..ccfa075 Binary files /dev/null and b/sounds/up-print-spaces-02.wav differ diff --git a/ttyemu.py b/ttyemu.py index 9097cbd..2b8527a 100755 --- a/ttyemu.py +++ b/ttyemu.py @@ -7,6 +7,7 @@ import tkinter.font import abc import subprocess +import logging import os import shlex try: @@ -20,9 +21,14 @@ pass try: import pygame + from sounds import PygameSounds except ImportError: pass + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + COLUMNS = 72 TEXT_COLOR = (0x33, 0x33, 0x33) @@ -226,8 +232,9 @@ class PygameFrontend: "Front-end using pygame for rendering" # pylint: disable=too-many-instance-attributes def __init__(self, target_surface=None, lines_per_page=8): + self.sounds = PygameSounds() pygame.init() - self.font = pygame.font.SysFont('monospace', 24) + self.font = self._findfont(22) self.font_width, self.font_height = self.font.size('X') self.width_pixels = COLUMNS * self.font_width if target_surface is None: @@ -242,6 +249,24 @@ def __init__(self, target_surface=None, lines_per_page=8): self.char_event_num = pygame.USEREVENT+1 self.terminal = None + def _findfont(self, fontsize): + # pygame SysFont doesn't help on Windows, so look for specific files in known locations + paths = [] + if "WINDIR" in os.environ: + path = os.path.join(os.environ["WINDIR"], "Fonts") + paths.append(os.path.join(path, "TELE.TTF")) + paths.append(os.path.join(path, "TELETYPE1945-1985.ttf")) + if "USERPROFILE" in os.environ: + path = os.path.join(os.environ["USERPROFILE"], "AppData", "Local", "Microsoft", "Windows", "Fonts") + paths.append(os.path.join(path, "TELE.TTF")) + paths.append(os.path.join(path, "TELETYPE1945-1985.ttf")) + for path in paths: + try: + return pygame.font.Font(path, fontsize) + except FileNotFoundError: + pass + return pygame.font.SysFont('Teleprinter,TELETYPE 1945-1985,monospace', fontsize) + def reinit(self, lines_per_page=None): "Clears and resets all terminal state" self.page_surfaces.clear() @@ -313,13 +338,18 @@ def postchars(self, chars): def handle_key(self, event): "Handle a keyboard event" if event.unicode: + self.sounds.keypress(event.unicode) self.terminal.backend.write_char(event.unicode) pygame.display.update() elif event.key == pygame.K_F5: self.terminal.backend.fast_mode = True + elif event.key == pygame.K_F7: + self.sounds.lid() elif event.key == pygame.K_PAGEUP: + self.sounds.platen() self.terminal.page_up() elif event.key == pygame.K_PAGEDOWN: + self.sounds.platen() self.terminal.page_down() else: pass @@ -328,9 +358,12 @@ def handle_key(self, event): def mainloop(self, terminal): "Run game loop" self.terminal = terminal + self.sounds.start() while True: for event in pygame.event.get(): if event.type == pygame.QUIT: + self.sounds.stop() + pygame.mixer.quit() pygame.quit() sys.exit() if event.type == pygame.KEYDOWN: @@ -348,6 +381,11 @@ def mainloop(self, terminal): self.terminal.refresh_screen() if event.type == self.char_event_num: self.terminal.output_chars(event.chars) + self.sounds.print_chars(event.chars) + if event.type in self.sounds.EVENTS: + # Sound events notify that playback is ended on a sound or channel + self.sounds.event(event.type) + # pylint: disable=unused-argument,no-self-use,missing-docstring class DummyFrontend: @@ -502,11 +540,12 @@ def thread_target(self): class ParamikoBackend: "Connects a remote host to the terminal" - def __init__(self, host, username, keyfile, postchars=lambda chars: None): + def __init__(self, host, username, keyfile, port=22, postchars=lambda chars: None): self.fast_mode = False self.channel = None self.postchars = postchars self.host = host + self.port = port self.username = username self.keyfile = keyfile @@ -519,7 +558,7 @@ def write_char(self, char): def thread_target(self): "Method for thread setup" - ssh = paramiko.Transport((self.host, 22)) + ssh = paramiko.Transport((self.host, self.port)) key = paramiko.RSAKey.from_private_key_file(self.keyfile) ssh.connect(username=self.username, pkey=key) self.channel = ssh.open_session() @@ -536,7 +575,7 @@ def thread_target(self): if not byte: break self.postchars(byte.decode('ascii', 'replace')) - time.sleep(0.1) + time.sleep(0.105) self.channel = None self.postchars("Disconnected. Local mode.\r\n") @@ -586,13 +625,16 @@ def thread_target(self): data = data.replace(b'\n', b'\r\n') self.postchars(data.decode('ascii', 'replace')) else: - byte = os.read(self.read_fd, 1) + try: + byte = os.read(self.read_fd, 1) + except OSError: + break if not byte: break if self.crmod: byte = byte.replace(b'\n', b'\r\n') self.postchars(byte.decode('ascii', 'replace')) - time.sleep(0.1) + time.sleep(0.105) self.teardown() self.postchars("Disconnected. Local mode.\r\n") @@ -671,7 +713,12 @@ def main(frontend, backend): backend_thread.start() frontend.mainloop(my_term) -main(TkinterFrontend(), PtyBackend('sh')) +main(PygameFrontend(), PtyBackend('sh')) + +#main(PygameFrontend(), ParamikoBackend("172.23.97.23", "user", port=2222, keyfile="C:\\Users\\user\\.ssh\\id_rsa")) +#main(TkinterFrontend(), PtyBackend('sh')) +#main(TkinterFrontend(), LoopbackBackend('sh')) + #main(PygameFrontend(), LoopbackBackend()) #main(TkinterFrontend(), ConptyBackend('ubuntu')) #main(PygameFrontend(), PipeBackend('py -3 -i -c ""', crmod=True, lecho=True))