Skip to content

Commit 4b6f134

Browse files
Controls added
1 parent 2466a59 commit 4b6f134

1 file changed

Lines changed: 149 additions & 16 deletions

File tree

ascii_play/player.py

Lines changed: 149 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
"""
22
ascii_play.player
3-
~~~~~~~~~~~~~~~~~
4-
Video decode + render loop with audio sync.
53
6-
Audio: extracted to wav, played via sounddevice (works on Windows natively).
7-
Sync: audio clock is master, video sleeps to match frame timing.
4+
5+
Video decode + render loop with audio sync and keyboard controls.
6+
7+
Controls:
8+
space pause / resume
9+
right arrow seek forward 5 seconds
10+
left arrow seek backward 5 seconds
11+
q quit
812
"""
913

1014
import os
@@ -24,6 +28,74 @@
2428
from .renderers import MODES, render_half
2529

2630

31+
# ── keyboard input ────────────────────────────────────────────────────────────
32+
33+
def _make_kb():
34+
"""
35+
Returns a non-blocking keyboard reader.
36+
On Windows uses msvcrt, on Linux uses termios raw mode.
37+
Returns (read_fn, cleanup_fn).
38+
read_fn() → None | "pause" | "seek_fwd" | "seek_back" | "quit"
39+
"""
40+
if sys.platform == "win32":
41+
import msvcrt
42+
43+
KEY_MAP = {
44+
b" ": "pause",
45+
b"q": "quit",
46+
b"Q": "quit",
47+
}
48+
EXTENDED = {
49+
b"M": "seek_fwd", # right arrow
50+
b"K": "seek_back", # left arrow
51+
}
52+
53+
def read_key():
54+
if not msvcrt.kbhit():
55+
return None
56+
ch = msvcrt.getch()
57+
if ch in (b"\x00", b"\xe0"):
58+
ch2 = msvcrt.getch()
59+
return EXTENDED.get(ch2)
60+
return KEY_MAP.get(ch)
61+
62+
return read_key, lambda: None
63+
64+
else:
65+
import tty, termios, select
66+
67+
fd = sys.stdin.fileno()
68+
old = termios.tcgetattr(fd)
69+
tty.setraw(fd)
70+
71+
KEY_MAP = {
72+
" ": "pause",
73+
"q": "quit",
74+
"Q": "quit",
75+
}
76+
ESC_MAP = {
77+
"\x1b[C": "seek_fwd",
78+
"\x1b[D": "seek_back",
79+
}
80+
81+
def read_key():
82+
r, _, _ = select.select([sys.stdin], [], [], 0)
83+
if not r:
84+
return None
85+
ch = sys.stdin.read(1)
86+
if ch == "\x1b":
87+
r2, _, _ = select.select([sys.stdin], [], [], 0.05)
88+
if r2:
89+
ch += sys.stdin.read(2)
90+
return ESC_MAP.get(ch)
91+
return KEY_MAP.get(ch)
92+
93+
def cleanup():
94+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
95+
96+
return read_key, cleanup
97+
98+
2799
# ── audio ─────────────────────────────────────────────────────────────────────
28100

29101
def _has_audio_deps():
@@ -56,9 +128,13 @@ def __init__(self, wav_path):
56128
self._started = threading.Event()
57129
self._done = threading.Event()
58130
self._sd = sd
131+
self._paused = False
59132

60133
def _callback(self, outdata, frames, time_info, status):
61134
with self._lock:
135+
if self._paused:
136+
outdata[:] = 0
137+
return
62138
chunk = self._data[self._pos : self._pos + frames]
63139
if len(chunk) < frames:
64140
outdata[:len(chunk)] = chunk
@@ -86,6 +162,19 @@ def time(self):
86162
with self._lock:
87163
return self._pos / self._sr
88164

165+
def seek(self, seconds):
166+
with self._lock:
167+
new_pos = int((self._pos / self._sr + seconds) * self._sr)
168+
self._pos = max(0, min(new_pos, len(self._data) - 1))
169+
170+
def pause(self):
171+
with self._lock:
172+
self._paused = True
173+
174+
def resume(self):
175+
with self._lock:
176+
self._paused = False
177+
89178
def is_done(self):
90179
return self._done.is_set()
91180

@@ -119,17 +208,24 @@ def _on_signal(sig, _frame):
119208
sys.stdout.write(clear_screen())
120209
sys.stdout.flush()
121210

211+
read_key, kb_cleanup = _make_kb()
212+
122213
try:
123-
_loop(filename, renderer, mode, scale, loop, info, quality, audio, interrupted)
214+
_loop(filename, renderer, mode, scale, loop, info, quality,
215+
audio, interrupted, read_key)
124216
finally:
217+
kb_cleanup()
125218
sys.stdout.write(reset())
126219
sys.stdout.write(normal_screen())
127220
sys.stdout.write(cursor_show())
128221
sys.stdout.flush()
129222

130223

131-
def _loop(filename, renderer, mode, scale, loop, info, quality, audio, interrupted):
224+
def _loop(filename, renderer, mode, scale, loop, info, quality,
225+
audio, interrupted, read_key):
226+
132227
use_audio = audio and _has_audio_deps()
228+
SEEK_SECS = 5
133229

134230
while True:
135231
# ── extract + start audio ──────────────────────────────────────────
@@ -150,41 +246,75 @@ def _loop(filename, renderer, mode, scale, loop, info, quality, audio, interrupt
150246
# ── video decode ───────────────────────────────────────────────────
151247
video = imageio_ffmpeg.read_frames(filename)
152248
meta = next(video)
153-
fps = meta.get("fps", 24) or 24
154-
fps = min(max(float(fps), 1), 120) # clamp to sane range
249+
fps = min(max(float(meta.get("fps", 24) or 24), 1), 120)
155250
vw, vh = meta["size"]
156251
frame_size = (vh, vw, 3)
157252
spf = 1.0 / fps
158253

159254
frame_count = 0
160255
t_start = time.perf_counter()
256+
paused = False
257+
pause_start = 0.0
258+
total_paused = 0.0
161259

162260
try:
163261
for raw in video:
164262
if interrupted.is_set():
165263
return
166264

265+
# ── keyboard ───────────────────────────────────────────────
266+
key = read_key()
267+
if key == "quit":
268+
interrupted.set()
269+
return
270+
elif key == "pause":
271+
paused = not paused
272+
if paused:
273+
pause_start = time.perf_counter()
274+
if clock: clock.pause()
275+
else:
276+
total_paused += time.perf_counter() - pause_start
277+
if clock: clock.resume()
278+
elif key == "seek_fwd":
279+
frame_count = min(
280+
frame_count + int(SEEK_SECS * fps),
281+
int(meta.get("duration", 0) * fps) - 1
282+
)
283+
t_start -= SEEK_SECS
284+
if clock: clock.seek(SEEK_SECS)
285+
elif key == "seek_back":
286+
frame_count = max(frame_count - int(SEEK_SECS * fps), 0)
287+
t_start += SEEK_SECS
288+
if clock: clock.seek(-SEEK_SECS)
289+
290+
# ── pause loop ─────────────────────────────────────────────
291+
while paused and not interrupted.is_set():
292+
key2 = read_key()
293+
if key2 == "pause":
294+
paused = False
295+
total_paused += time.perf_counter() - pause_start
296+
if clock: clock.resume()
297+
elif key2 == "quit":
298+
interrupted.set()
299+
return
300+
time.sleep(0.05)
301+
167302
frame = np.frombuffer(raw, dtype=np.uint8).reshape(frame_size)
168303

169304
# ── timing ─────────────────────────────────────────────────
170305
if clock is not None:
171-
# audio is master clock
172306
audio_time = clock.time
173307
expected_frame = int(audio_time * fps)
174-
175308
if expected_frame > frame_count + 2:
176-
# more than 2 frames behind audio — skip to catch up
177309
frame_count = expected_frame
178310
continue
179-
180-
# sleep until this frame is due per audio clock
181311
target = t_start + (audio_time + spf)
182312
slack = target - time.perf_counter()
183313
if slack > 0:
184314
time.sleep(slack)
185315
else:
186-
# no audio — wall clock timing
187-
target = t_start + frame_count * spf
316+
effective_start = t_start + total_paused
317+
target = effective_start + frame_count * spf
188318
slack = target - time.perf_counter()
189319
if slack > 0:
190320
time.sleep(slack)
@@ -198,9 +328,10 @@ def _loop(filename, renderer, mode, scale, loop, info, quality, audio, interrupt
198328
out = renderer(frame, cols, render_rows, quality)
199329

200330
if info:
201-
elapsed = time.perf_counter() - t_start
331+
elapsed = time.perf_counter() - t_start - total_paused
202332
actual_fps = frame_count / elapsed if elapsed > 0 else 0
203333
audio_tag = "audio" if clock else "no audio"
334+
pause_tag = " PAUSED" if paused else ""
204335
out += (
205336
move_to(rows)
206337
+ "\033[48;2;18;18;18m\033[38;2;170;170;170m"
@@ -210,6 +341,8 @@ def _loop(filename, renderer, mode, scale, loop, info, quality, audio, interrupt
210341
+ f" │ {cols}×{render_rows}"
211342
+ f" │ {actual_fps:.1f}/{fps:.0f} fps"
212343
+ f" │ {audio_tag}"
344+
+ f" │ [space] pause [←→] seek 5s [q] quit"
345+
+ pause_tag
213346
+ "\033[K"
214347
+ reset()
215348
)

0 commit comments

Comments
 (0)