├── .gitignore ├── README.md └── wishes_code.py /.gitignore: -------------------------------------------------------------------------------- 1 | # OS 2 | .DS_Store 3 | 4 | # Python 5 | __pycache__/ 6 | *.py[cod] 7 | *.so 8 | *.dylib 9 | *.pyd 10 | *.egg* 11 | pip-wheel-metadata/ 12 | build/ 13 | dist/ 14 | .venv/ 15 | .env 16 | .env.* 17 | 18 | # Tools & caches 19 | .pytest_cache/ 20 | .mypy_cache/ 21 | coverage.* 22 | *.coverage 23 | *.cover 24 | htmlcov/ 25 | .cache/ 26 | *.log 27 | 28 | # IDE 29 | .vscode/ 30 | .idea/ 31 | 32 | # Project artifacts (media & downloads) 33 | song.* 34 | *.mp3 35 | *.wav 36 | *.webm 37 | *.m4a 38 | *.part 39 | *.temp 40 | *.tmp 41 | *.ytdl 42 | *.ytdl.temp 43 | *.info.json 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Wishes — Lyrics Typewriter Player 2 | 3 | A small Python script that downloads a YouTube track locally (audio only), starts playback at a chosen offset, and displays synced lyrics with a typewriter effect. Playback and lyrics stay in sync even when you pause/resume, and you can nudge lyric timing live. 4 | 5 | ## Features 6 | - Downloads audio via `yt-dlp` and converts to MP3 using a portable ffmpeg (`imageio-ffmpeg`). 7 | - Starts playback at a specific timestamp (default 115s). 8 | - Typewriter lyrics with real-time sync to the audio clock. 9 | - Interactive controls in the terminal: 10 | - `p` pause 11 | - `r` resume 12 | - `s`/`q` stop 13 | - `[` make lyrics earlier by 0.25s 14 | - `]` make lyrics later by 0.25s 15 | - `o` print current lyric offset 16 | 17 | ## Requirements 18 | - Python 3.11+ 19 | - A virtual environment is recommended 20 | 21 | ## Setup 22 | ```bash 23 | python3 -m venv .venv 24 | . .venv/bin/activate 25 | pip install -U pip yt-dlp imageio-ffmpeg pygame 26 | ``` 27 | 28 | ## Run 29 | ```bash 30 | .venv/bin/python wishes_code.py 31 | ``` 32 | 33 | The script downloads the song (ignored by git) to the project folder as `song.mp3` and then plays it. Lyrics appear in a typewriter style, synced to the music. Use `[` and `]` to fine‑tune lyric timing. 34 | 35 | ## Notes 36 | - Media files like `song.mp3` are excluded via `.gitignore` so the repo stays lightweight. 37 | - You can change the YouTube URL or the start time in `wishes_code.py`. 38 | - If you want to include the audio in the repo, remove the relevant patterns from `.gitignore`. 39 | -------------------------------------------------------------------------------- /wishes_code.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import threading 5 | import pygame 6 | import yt_dlp 7 | import imageio_ffmpeg 8 | 9 | """Download the song locally as song.mp3 and then play it.""" 10 | 11 | # ------------ DOWNLOAD SONG (yt-dlp + ffmpeg) ------------ 12 | url = "https://www.youtube.com/watch?v=Gz38Yj09k3A" 13 | target_mp3 = os.path.join(os.getcwd(), "song.mp3") 14 | 15 | if os.path.exists(target_mp3): 16 | print("Audio already present:", target_mp3) 17 | else: 18 | print("Downloading audio with yt-dlp...") 19 | ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() 20 | ydl_opts = { 21 | "format": "bestaudio/best", 22 | "outtmpl": os.path.join(os.getcwd(), "song.%(ext)s"), 23 | "prefer_ffmpeg": True, 24 | "ffmpeg_location": ffmpeg_exe, 25 | "postprocessors": [ 26 | { 27 | "key": "FFmpegExtractAudio", 28 | "preferredcodec": "mp3", 29 | "preferredquality": "192", 30 | } 31 | ], 32 | # ensure stable sample rate for pygame 33 | "postprocessor_args": ["-ar", "44100"], 34 | "quiet": False, 35 | "noprogress": False, 36 | } 37 | 38 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 39 | ydl.download([url]) 40 | 41 | # After postprocessing, song.mp3 should exist 42 | if not os.path.exists(target_mp3): 43 | # Fallback: find any song.* and rename if needed 44 | for fname in os.listdir(os.getcwd()): 45 | if fname.startswith("song.") and fname.endswith(".mp3"): 46 | target_mp3 = os.path.join(os.getcwd(), fname) 47 | break 48 | print("Downloaded:", target_mp3) 49 | 50 | # ------------ SETUP PYGAME ------------ 51 | pygame.mixer.init(frequency=44100) 52 | pygame.mixer.music.load(target_mp3) 53 | 54 | # Start playing from 1:55 → 115 seconds 55 | start_time = 115 56 | pygame.mixer.music.play(start=start_time) 57 | 58 | # ------------ CONTROLS (pause/resume/stop) ------------ 59 | pause_flag = threading.Event() 60 | stop_flag = threading.Event() 61 | 62 | def input_controls(): 63 | global lyric_offset 64 | #print("Controls: p=pause, r=resume, s/q=stop, [ / ] nudge lyrics, o=offset") 65 | while not stop_flag.is_set(): 66 | try: 67 | cmd = input().strip().lower() 68 | except EOFError: 69 | break 70 | if cmd == "p": 71 | if not pause_flag.is_set(): 72 | pygame.mixer.music.pause() 73 | pause_flag.set() 74 | print("Paused") 75 | elif cmd == "r": 76 | if pause_flag.is_set(): 77 | pygame.mixer.music.unpause() 78 | pause_flag.clear() 79 | print("Resumed") 80 | elif cmd == "[": 81 | lyric_offset -= 0.25 82 | print(f"Lyric offset: {lyric_offset:+.2f}s (earlier)") 83 | elif cmd == "]": 84 | lyric_offset += 0.25 85 | print(f"Lyric offset: {lyric_offset:+.2f}s (later)") 86 | elif cmd == "o": 87 | print(f"Lyric offset: {lyric_offset:+.2f}s") 88 | elif cmd in ("s", "q"): 89 | stop_flag.set() 90 | pygame.mixer.music.stop() 91 | print("Stopped") 92 | break 93 | 94 | ctrl_thread = threading.Thread(target=input_controls, daemon=True) 95 | ctrl_thread.start() 96 | 97 | # ------------ LYRICS WITH TIMING (absolute seconds from playback start) ------------ 98 | # The first element of each tuple is an ABSOLUTE timestamp (in seconds) 99 | # measured from when playback starts, not a relative delay. 100 | lyrics = [ 101 | (0, "Hoke tetho duur mein"), 102 | (2, "Khoya apne aap nu......"), 103 | (5, "Adda hissa mere dil da"), 104 | (7, "Hoeya tere khilaaf kyun..."), 105 | (10, "Adda dil chaunda ae tenu"), 106 | (12, "Karni chaunda baat kyun..."), 107 | (15, "Tareyaan di roshni de wangu"), 108 | (17, "Gal nal laaja raat nu"), 109 | (19, "Dil mere nu samjhaja"), 110 | (21, "Hathan wich hath tu paaja"), 111 | (24, "Pehlan jo dekhi nahi mein"), 112 | (27, "Aaisi duniya tu dikhaa ja") 113 | ] 114 | 115 | lyric_offset = 0.0 # allow fine-tuning in real time 116 | 117 | def show_lyrics(): 118 | # Typewriter effect synced to the audio clock 119 | idx = 0 120 | total = len(lyrics) 121 | line_started = False 122 | typed_idx = 0 123 | char_delay = 0.01 # default; recomputed per line based on available window 124 | 125 | while idx < total and not stop_flag.is_set(): 126 | if pause_flag.is_set(): 127 | time.sleep(0.02) 128 | continue 129 | 130 | pos_ms = pygame.mixer.music.get_pos() 131 | if pos_ms < 0: 132 | time.sleep(0.02) 133 | continue 134 | 135 | pos = pos_ms / 1000.0 # seconds since play() (doesn't advance while paused) 136 | cue_time, text = lyrics[idx] 137 | cue_time = float(cue_time) 138 | 139 | # Wait for cue 140 | if not line_started: 141 | if pos + lyric_offset >= cue_time: 142 | # Type so the last character lands exactly at the next cue. 143 | if idx + 1 < total: 144 | char_delay = 0.12 145 | else: 146 | # Last line: use a reasonable default 147 | char_delay = 0.12 148 | 149 | line_started = True 150 | typed_idx = 0 151 | # Immediately render first character at cue 152 | # (the loop below will compute target based on elapsed) 153 | else: 154 | time.sleep(0.01) 155 | continue 156 | 157 | # How many characters should be visible by now? 158 | elapsed = (pos + lyric_offset) - cue_time 159 | target = int(elapsed / char_delay) + 1 # start typing at cue 160 | target = max(0, min(target, len(text))) 161 | 162 | # Print any missing characters 163 | while typed_idx < target and not stop_flag.is_set(): 164 | sys.stdout.write(text[typed_idx]) 165 | sys.stdout.flush() 166 | typed_idx += 1 167 | 168 | # If the line is complete, newline and advance 169 | if typed_idx >= len(text): 170 | sys.stdout.write("\n") 171 | sys.stdout.flush() 172 | idx += 1 173 | line_started = False 174 | else: 175 | time.sleep(0.01) 176 | 177 | lyrics_thread = threading.Thread(target=show_lyrics, daemon=True) 178 | lyrics_thread.start() 179 | 180 | # Keep script running until song ends 181 | try: 182 | while pygame.mixer.music.get_busy() and not stop_flag.is_set(): 183 | time.sleep(0.2) 184 | finally: 185 | stop_flag.set() 186 | --------------------------------------------------------------------------------