Skip to content

Commit ca9d734

Browse files
committed
Add simple TUI for effect and pot control
1 parent 60f199a commit ca9d734

1 file changed

Lines changed: 275 additions & 0 deletions

File tree

tui.py

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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

Comments
 (0)