|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +AudioNoise TUI - Simple Terminal UI for turning virtual "pots" and picking effects. |
| 4 | +
|
| 5 | +A minimal curses-based TUI as requested by Linus Torvalds: |
| 6 | +"Some UI (whether T or G) for actually turning the virtual 'pots' and |
| 7 | +picking the effect would probably be interesting. Right now I just do |
| 8 | +it all manually. But only something simple." |
| 9 | +
|
| 10 | +Dependencies: Python 3.x with curses (standard library - no external deps) |
| 11 | +Requirements: ffmpeg, ffplay, and the AudioNoise 'convert' binary |
| 12 | +
|
| 13 | +Usage: |
| 14 | + cd AudioNoise |
| 15 | + python3 tui.py |
| 16 | +
|
| 17 | +License: GPL-2.0 (same as AudioNoise) |
| 18 | +""" |
| 19 | + |
| 20 | +import curses |
| 21 | +import subprocess |
| 22 | +import os |
| 23 | +import sys |
| 24 | +from pathlib import Path |
| 25 | + |
| 26 | +SAMPLE_RATE = 48000 |
| 27 | +SAMPLE_FORMAT = 's32le' |
| 28 | +CHANNELS = 'mono' |
| 29 | + |
| 30 | +EFFECTS = { |
| 31 | + 'flanger': { |
| 32 | + 'defaults': [0.6, 0.6, 0.6, 0.6], |
| 33 | + 'pots': ['Depth', 'Rate', 'Feedback', 'Mix'], |
| 34 | + 'desc': 'Modulated delay - jet-plane swoosh' |
| 35 | + }, |
| 36 | + 'echo': { |
| 37 | + 'defaults': [0.3, 0.3, 0.3, 0.3], |
| 38 | + 'pots': ['Delay', 'Feedback', 'Mix', 'Tone'], |
| 39 | + 'desc': 'Delay loop up to 1.25 seconds' |
| 40 | + }, |
| 41 | + 'fm': { |
| 42 | + 'defaults': [0.25, 0.25, 0.5, 0.5], |
| 43 | + 'pots': ['Mod Depth', 'Mod Rate', 'Carrier', 'Mix'], |
| 44 | + 'desc': 'Frequency modulation synthesis' |
| 45 | + }, |
| 46 | + 'am': { |
| 47 | + 'defaults': [0.5, 0.5, 0.5, 0.5], |
| 48 | + 'pots': ['Depth', 'Rate', 'Shape', 'Mix'], |
| 49 | + 'desc': 'Amplitude modulation' |
| 50 | + }, |
| 51 | + 'phaser': { |
| 52 | + 'defaults': [0.3, 0.3, 0.5, 0.5], |
| 53 | + 'pots': ['Depth', 'Rate', 'Stages', 'Feedback'], |
| 54 | + 'desc': 'All-pass filter sweep' |
| 55 | + }, |
| 56 | + 'discont': { |
| 57 | + 'defaults': [0.8, 0.1, 0.2, 0.2], |
| 58 | + 'pots': ['Pitch', 'Rate', 'Blend', 'Mix'], |
| 59 | + 'desc': 'Pitch shift via crossfade' |
| 60 | + }, |
| 61 | +} |
| 62 | + |
| 63 | +EFFECT_NAMES = list(EFFECTS.keys()) |
| 64 | + |
| 65 | + |
| 66 | +class AudioNoiseTUI: |
| 67 | + def __init__(self, stdscr): |
| 68 | + self.stdscr = stdscr |
| 69 | + self.effect_idx = 0 |
| 70 | + self.pot_idx = 0 |
| 71 | + self.pot_values = {name: list(e['defaults']) for name, e in EFFECTS.items()} |
| 72 | + self.status = '' |
| 73 | + self.status_ok = True |
| 74 | + |
| 75 | + curses.curs_set(0) |
| 76 | + curses.start_color() |
| 77 | + curses.use_default_colors() |
| 78 | + curses.init_pair(1, curses.COLOR_CYAN, -1) |
| 79 | + curses.init_pair(2, curses.COLOR_GREEN, -1) |
| 80 | + curses.init_pair(3, curses.COLOR_YELLOW, -1) |
| 81 | + curses.init_pair(4, curses.COLOR_RED, -1) |
| 82 | + |
| 83 | + self._check_environment() |
| 84 | + |
| 85 | + def _check_environment(self): |
| 86 | + if not Path('convert').exists() and not Path('./convert').exists(): |
| 87 | + self.status = "Warning: 'convert' not found. Run 'make convert' first." |
| 88 | + self.status_ok = False |
| 89 | + elif not Path('input.raw').exists(): |
| 90 | + mp3_files = list(Path('.').glob('*.mp3')) |
| 91 | + if mp3_files: |
| 92 | + self.status = f"Will convert {mp3_files[0].name} to input.raw" |
| 93 | + else: |
| 94 | + self.status = "No input.raw or .mp3 found" |
| 95 | + self.status_ok = False |
| 96 | + else: |
| 97 | + self.status = "Ready - press 'p' to process, 'q' to quit" |
| 98 | + |
| 99 | + @property |
| 100 | + def effect(self): |
| 101 | + return EFFECT_NAMES[self.effect_idx] |
| 102 | + |
| 103 | + @property |
| 104 | + def effect_data(self): |
| 105 | + return EFFECTS[self.effect] |
| 106 | + |
| 107 | + @property |
| 108 | + def pots(self): |
| 109 | + return self.pot_values[self.effect] |
| 110 | + |
| 111 | + def draw(self): |
| 112 | + self.stdscr.clear() |
| 113 | + h, w = self.stdscr.getmaxyx() |
| 114 | + |
| 115 | + if h < 18 or w < 50: |
| 116 | + self.stdscr.addstr(0, 0, "Terminal too small (need 50x18)") |
| 117 | + self.stdscr.refresh() |
| 118 | + return |
| 119 | + |
| 120 | + title = "═══ AUDIONOISE TUI ═══" |
| 121 | + self.stdscr.addstr(0, (w - len(title)) // 2, title, |
| 122 | + curses.color_pair(1) | curses.A_BOLD) |
| 123 | + |
| 124 | + self.stdscr.addstr(2, 2, "EFFECT:", curses.A_BOLD) |
| 125 | + for i, name in enumerate(EFFECT_NAMES): |
| 126 | + y = 3 + i |
| 127 | + selected = i == self.effect_idx |
| 128 | + |
| 129 | + if selected: |
| 130 | + marker = ">" |
| 131 | + attr = curses.color_pair(2) | curses.A_BOLD |
| 132 | + else: |
| 133 | + marker = " " |
| 134 | + attr = curses.A_DIM |
| 135 | + |
| 136 | + self.stdscr.addstr(y, 2, f" {marker} {name.upper()}", attr) |
| 137 | + |
| 138 | + self.stdscr.addstr(3, 20, self.effect_data['desc'], curses.A_DIM) |
| 139 | + |
| 140 | + self.stdscr.addstr(10, 2, "POTS:", curses.A_BOLD) |
| 141 | + |
| 142 | + pot_names = self.effect_data['pots'] |
| 143 | + for i in range(4): |
| 144 | + y = 11 + i |
| 145 | + selected = i == self.pot_idx |
| 146 | + value = self.pots[i] |
| 147 | + name = pot_names[i] |
| 148 | + |
| 149 | + if selected: |
| 150 | + attr = curses.color_pair(3) | curses.A_BOLD |
| 151 | + else: |
| 152 | + attr = curses.A_NORMAL |
| 153 | + |
| 154 | + self.stdscr.addstr(y, 2, f" {name:12}", attr) |
| 155 | + |
| 156 | + bar_width = min(20, w - 30) |
| 157 | + filled = int(value * bar_width) |
| 158 | + bar = "#" * filled + "-" * (bar_width - filled) |
| 159 | + |
| 160 | + bar_attr = curses.color_pair(2) if selected else curses.A_DIM |
| 161 | + self.stdscr.addstr(y, 16, f"[{bar}]", bar_attr) |
| 162 | + self.stdscr.addstr(y, 18 + bar_width, f" {value:.2f}", attr) |
| 163 | + |
| 164 | + self.stdscr.addstr(16, 2, "Keys:", curses.color_pair(3)) |
| 165 | + self.stdscr.addstr(16, 8, "Up/Down=effect Tab=pot Left/Right=value p=play r=reset q=quit", curses.A_DIM) |
| 166 | + |
| 167 | + status_attr = curses.color_pair(2) if self.status_ok else curses.color_pair(4) |
| 168 | + self.stdscr.addstr(h - 1, 0, self.status[:w-1], status_attr) |
| 169 | + |
| 170 | + self.stdscr.refresh() |
| 171 | + |
| 172 | + def process_and_play(self): |
| 173 | + effect = self.effect |
| 174 | + pots = self.pots |
| 175 | + |
| 176 | + self.status = f"Processing {effect}..." |
| 177 | + self.status_ok = True |
| 178 | + self.draw() |
| 179 | + |
| 180 | + if not Path('input.raw').exists(): |
| 181 | + mp3_files = list(Path('.').glob('*.mp3')) |
| 182 | + if not mp3_files: |
| 183 | + self.status = "Error: No input file found" |
| 184 | + self.status_ok = False |
| 185 | + return |
| 186 | + |
| 187 | + try: |
| 188 | + subprocess.run([ |
| 189 | + 'ffmpeg', '-y', '-v', 'fatal', |
| 190 | + '-i', str(mp3_files[0]), |
| 191 | + '-f', SAMPLE_FORMAT, '-ar', str(SAMPLE_RATE), '-ac', '1', |
| 192 | + 'input.raw' |
| 193 | + ], check=True) |
| 194 | + except (subprocess.CalledProcessError, FileNotFoundError) as e: |
| 195 | + self.status = f"Error converting input: {e}" |
| 196 | + self.status_ok = False |
| 197 | + return |
| 198 | + |
| 199 | + convert_path = './convert' if Path('./convert').exists() else 'convert' |
| 200 | + if not Path(convert_path).exists(): |
| 201 | + self.status = "Error: 'convert' not found - run 'make convert'" |
| 202 | + self.status_ok = False |
| 203 | + return |
| 204 | + |
| 205 | + try: |
| 206 | + with open('input.raw', 'rb') as infile, open('output.raw', 'wb') as outfile: |
| 207 | + result = subprocess.run( |
| 208 | + [convert_path, effect] + [f'{p:.2f}' for p in pots], |
| 209 | + stdin=infile, |
| 210 | + stdout=outfile, |
| 211 | + stderr=subprocess.PIPE, |
| 212 | + check=True |
| 213 | + ) |
| 214 | + except subprocess.CalledProcessError as e: |
| 215 | + self.status = f"Processing failed: {e.stderr.decode() if e.stderr else e}" |
| 216 | + self.status_ok = False |
| 217 | + return |
| 218 | + except FileNotFoundError: |
| 219 | + self.status = "Error: convert binary not found" |
| 220 | + self.status_ok = False |
| 221 | + return |
| 222 | + |
| 223 | + try: |
| 224 | + subprocess.Popen([ |
| 225 | + 'ffplay', '-v', 'fatal', '-nodisp', '-autoexit', |
| 226 | + '-f', SAMPLE_FORMAT, '-ar', str(SAMPLE_RATE), |
| 227 | + '-ch_layout', CHANNELS, '-i', 'output.raw' |
| 228 | + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
| 229 | + |
| 230 | + self.status = f"Playing: {effect} [{', '.join(f'{p:.2f}' for p in pots)}]" |
| 231 | + self.status_ok = True |
| 232 | + except FileNotFoundError: |
| 233 | + self.status = "Processed output.raw but ffplay not found for playback" |
| 234 | + self.status_ok = False |
| 235 | + |
| 236 | + def reset_pots(self): |
| 237 | + self.pot_values[self.effect] = list(self.effect_data['defaults']) |
| 238 | + self.status = f"Reset {self.effect} to defaults" |
| 239 | + self.status_ok = True |
| 240 | + |
| 241 | + def run(self): |
| 242 | + while True: |
| 243 | + self.draw() |
| 244 | + |
| 245 | + key = self.stdscr.getch() |
| 246 | + |
| 247 | + if key == ord('q') or key == ord('Q'): |
| 248 | + break |
| 249 | + elif key == curses.KEY_UP: |
| 250 | + self.effect_idx = (self.effect_idx - 1) % len(EFFECT_NAMES) |
| 251 | + elif key == curses.KEY_DOWN: |
| 252 | + self.effect_idx = (self.effect_idx + 1) % len(EFFECT_NAMES) |
| 253 | + elif key == ord('\t'): |
| 254 | + self.pot_idx = (self.pot_idx + 1) % 4 |
| 255 | + elif key == curses.KEY_LEFT: |
| 256 | + self.pots[self.pot_idx] = max(0.0, self.pots[self.pot_idx] - 0.05) |
| 257 | + elif key == curses.KEY_RIGHT: |
| 258 | + self.pots[self.pot_idx] = min(1.0, self.pots[self.pot_idx] + 0.05) |
| 259 | + elif key == ord('p') or key == ord('P'): |
| 260 | + self.process_and_play() |
| 261 | + elif key == ord('r') or key == ord('R'): |
| 262 | + self.reset_pots() |
| 263 | + |
| 264 | + |
| 265 | +def main(): |
| 266 | + print("AudioNoise TUI - press 'q' to quit") |
| 267 | + try: |
| 268 | + curses.wrapper(lambda stdscr: AudioNoiseTUI(stdscr).run()) |
| 269 | + except KeyboardInterrupt: |
| 270 | + pass |
| 271 | + print("Goodbye!") |
| 272 | + |
| 273 | + |
| 274 | +if __name__ == '__main__': |
| 275 | + main() |
0 commit comments