11"""
22ascii_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
1014import os
2428from .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
29101def _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