lyrics-transcriber 0.19.2__py3-none-any.whl → 0.30.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. lyrics_transcriber/__init__.py +2 -5
  2. lyrics_transcriber/cli/main.py +194 -0
  3. lyrics_transcriber/core/__init__.py +0 -0
  4. lyrics_transcriber/core/controller.py +283 -0
  5. lyrics_transcriber/core/corrector.py +56 -0
  6. lyrics_transcriber/core/fetcher.py +143 -0
  7. lyrics_transcriber/output/__init__.py +0 -0
  8. lyrics_transcriber/output/generator.py +210 -0
  9. lyrics_transcriber/storage/__init__.py +0 -0
  10. lyrics_transcriber/storage/dropbox.py +249 -0
  11. lyrics_transcriber/storage/tokens.py +116 -0
  12. lyrics_transcriber/{audioshake_transcriber.py → transcribers/audioshake.py} +44 -15
  13. lyrics_transcriber/transcribers/base.py +31 -0
  14. lyrics_transcriber/transcribers/whisper.py +186 -0
  15. {lyrics_transcriber-0.19.2.dist-info → lyrics_transcriber-0.30.0.dist-info}/METADATA +6 -17
  16. lyrics_transcriber-0.30.0.dist-info/RECORD +22 -0
  17. lyrics_transcriber-0.30.0.dist-info/entry_points.txt +3 -0
  18. lyrics_transcriber/llm_prompts/README.md +0 -10
  19. lyrics_transcriber/llm_prompts/llm_prompt_lyrics_correction_andrew_handwritten_20231118.txt +0 -55
  20. lyrics_transcriber/llm_prompts/llm_prompt_lyrics_correction_gpt_optimised_20231119.txt +0 -36
  21. lyrics_transcriber/llm_prompts/llm_prompt_lyrics_matching_andrew_handwritten_20231118.txt +0 -19
  22. lyrics_transcriber/llm_prompts/promptfooconfig.yaml +0 -61
  23. lyrics_transcriber/llm_prompts/test_data/ABBA-UnderAttack-Genius.txt +0 -48
  24. lyrics_transcriber/transcriber.py +0 -1128
  25. lyrics_transcriber/utils/cli.py +0 -179
  26. lyrics_transcriber-0.19.2.dist-info/RECORD +0 -18
  27. lyrics_transcriber-0.19.2.dist-info/entry_points.txt +0 -3
  28. /lyrics_transcriber/{utils → cli}/__init__.py +0 -0
  29. /lyrics_transcriber/{utils → output}/ass.py +0 -0
  30. /lyrics_transcriber/{utils → output}/subtitles.py +0 -0
  31. {lyrics_transcriber-0.19.2.dist-info → lyrics_transcriber-0.30.0.dist-info}/LICENSE +0 -0
  32. {lyrics_transcriber-0.19.2.dist-info → lyrics_transcriber-0.30.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,210 @@
1
+ import os
2
+ import logging
3
+ from typing import Dict, Any, Optional
4
+ import subprocess
5
+ from datetime import timedelta
6
+ from .subtitles import create_styled_subtitles, LyricsScreen, LyricsLine, LyricSegment
7
+
8
+
9
+ class OutputGenerator:
10
+ """Handles generation of various lyrics output formats."""
11
+
12
+ def __init__(
13
+ self,
14
+ logger: Optional[logging.Logger] = None,
15
+ output_dir: Optional[str] = None,
16
+ cache_dir: str = "/tmp/lyrics-transcriber-cache/",
17
+ video_resolution: str = "360p",
18
+ video_background_image: Optional[str] = None,
19
+ video_background_color: str = "black",
20
+ ):
21
+ self.logger = logger or logging.getLogger(__name__)
22
+ self.output_dir = output_dir
23
+ self.cache_dir = cache_dir
24
+
25
+ # Video settings
26
+ self.video_resolution = video_resolution
27
+ self.video_background_image = video_background_image
28
+ self.video_background_color = video_background_color
29
+
30
+ # Set video resolution parameters
31
+ self.video_resolution_num, self.font_size, self.line_height = self._get_video_params(video_resolution)
32
+
33
+ # Validate video background if provided
34
+ if self.video_background_image and not os.path.isfile(self.video_background_image):
35
+ raise FileNotFoundError(f"Video background image not found: {self.video_background_image}")
36
+
37
+ def generate_outputs(
38
+ self, transcription_data: Dict[str, Any], output_prefix: str, audio_filepath: str, render_video: bool = False
39
+ ) -> Dict[str, str]:
40
+ """
41
+ Generate all requested output formats.
42
+
43
+ Args:
44
+ transcription_data: Dictionary containing transcription segments with timing
45
+ output_prefix: Prefix for output filenames
46
+ audio_filepath: Path to the source audio file
47
+ render_video: Whether to generate video output
48
+
49
+ Returns:
50
+ Dictionary of output paths for each format
51
+ """
52
+ outputs = {}
53
+
54
+ try:
55
+ # Generate LRC
56
+ lrc_path = self.generate_lrc(transcription_data, output_prefix)
57
+ outputs["lrc"] = lrc_path
58
+
59
+ # Generate ASS
60
+ ass_path = self.generate_ass(transcription_data, output_prefix)
61
+ outputs["ass"] = ass_path
62
+
63
+ # Generate video if requested
64
+ if render_video:
65
+ video_path = self.generate_video(ass_path, audio_filepath, output_prefix)
66
+ outputs["video"] = video_path
67
+
68
+ except Exception as e:
69
+ self.logger.error(f"Error generating outputs: {str(e)}")
70
+ raise
71
+
72
+ return outputs
73
+
74
+ def generate_lrc(self, transcription_data: Dict[str, Any], output_prefix: str) -> str:
75
+ """Generate LRC format lyrics file."""
76
+ self.logger.info("Generating LRC format lyrics")
77
+
78
+ output_path = os.path.join(self.output_dir or self.cache_dir, f"{output_prefix}.lrc")
79
+
80
+ try:
81
+ with open(output_path, "w", encoding="utf-8") as f:
82
+ for segment in transcription_data["segments"]:
83
+ start_time = self._format_lrc_timestamp(segment["start"])
84
+ line = f"[{start_time}]{segment['text']}\n"
85
+ f.write(line)
86
+
87
+ self.logger.info(f"LRC file generated: {output_path}")
88
+ return output_path
89
+
90
+ except Exception as e:
91
+ self.logger.error(f"Failed to generate LRC file: {str(e)}")
92
+ raise
93
+
94
+ def generate_ass(self, transcription_data: Dict[str, Any], output_prefix: str) -> str:
95
+ """Generate ASS format subtitles file."""
96
+ self.logger.info("Generating ASS format subtitles")
97
+
98
+ output_path = os.path.join(self.output_dir or self.cache_dir, f"{output_prefix}.ass")
99
+
100
+ try:
101
+ with open(output_path, "w", encoding="utf-8") as f:
102
+ # Write ASS header
103
+ f.write(self._get_ass_header())
104
+
105
+ # Write events
106
+ for segment in transcription_data["segments"]:
107
+ start_time = self._format_ass_timestamp(segment["start"])
108
+ end_time = self._format_ass_timestamp(segment["end"])
109
+ line = f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{segment['text']}\n"
110
+ f.write(line)
111
+
112
+ self.logger.info(f"ASS file generated: {output_path}")
113
+ return output_path
114
+
115
+ except Exception as e:
116
+ self.logger.error(f"Failed to generate ASS file: {str(e)}")
117
+ raise
118
+
119
+ def generate_video(self, ass_path: str, audio_path: str, output_prefix: str) -> str:
120
+ """Generate MP4 video with lyrics overlay."""
121
+ self.logger.info("Generating video with lyrics overlay")
122
+
123
+ output_path = os.path.join(self.output_dir or self.cache_dir, f"{output_prefix}.mp4")
124
+ width, height = self.video_resolution_num
125
+
126
+ try:
127
+ # Prepare FFmpeg command
128
+ cmd = [
129
+ "ffmpeg",
130
+ "-y",
131
+ "-f",
132
+ "lavfi",
133
+ "-i",
134
+ f"color=c={self.video_background_color}:s={width}x{height}",
135
+ "-i",
136
+ audio_path,
137
+ "-vf",
138
+ f"ass={ass_path}",
139
+ "-c:v",
140
+ "libx264",
141
+ "-c:a",
142
+ "aac",
143
+ "-shortest",
144
+ output_path,
145
+ ]
146
+
147
+ # If background image provided, use it instead of solid color
148
+ if self.video_background_image:
149
+ cmd[3:6] = ["-i", self.video_background_image]
150
+
151
+ self.logger.debug(f"Running FFmpeg command: {' '.join(cmd)}")
152
+ subprocess.run(cmd, check=True)
153
+
154
+ self.logger.info(f"Video generated: {output_path}")
155
+ return output_path
156
+
157
+ except subprocess.CalledProcessError as e:
158
+ self.logger.error(f"FFmpeg error: {str(e)}")
159
+ raise
160
+ except Exception as e:
161
+ self.logger.error(f"Failed to generate video: {str(e)}")
162
+ raise
163
+
164
+ def _get_video_params(self, resolution: str) -> tuple:
165
+ """Get video parameters based on resolution setting."""
166
+ match resolution:
167
+ case "4k":
168
+ return (3840, 2160), 250, 250
169
+ case "1080p":
170
+ return (1920, 1080), 120, 120
171
+ case "720p":
172
+ return (1280, 720), 100, 100
173
+ case "360p":
174
+ return (640, 360), 50, 50
175
+ case _:
176
+ raise ValueError("Invalid video_resolution value. Must be one of: 4k, 1080p, 720p, 360p")
177
+
178
+ def _format_lrc_timestamp(self, seconds: float) -> str:
179
+ """Format timestamp for LRC format."""
180
+ time = timedelta(seconds=seconds)
181
+ minutes = int(time.total_seconds() / 60)
182
+ seconds = time.total_seconds() % 60
183
+ return f"{minutes:02d}:{seconds:05.2f}"
184
+
185
+ def _format_ass_timestamp(self, seconds: float) -> str:
186
+ """Format timestamp for ASS format."""
187
+ time = timedelta(seconds=seconds)
188
+ hours = int(time.total_seconds() / 3600)
189
+ minutes = int((time.total_seconds() % 3600) / 60)
190
+ seconds = time.total_seconds() % 60
191
+ centiseconds = int((seconds % 1) * 100)
192
+ seconds = int(seconds)
193
+ return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
194
+
195
+ def _get_ass_header(self) -> str:
196
+ """Get ASS format header with style definitions."""
197
+ width, height = self.video_resolution_num
198
+ return f"""[Script Info]
199
+ ScriptType: v4.00+
200
+ PlayResX: {width}
201
+ PlayResY: {height}
202
+ WrapStyle: 0
203
+
204
+ [V4+ Styles]
205
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
206
+ Style: Default,Arial,{self.font_size},&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
207
+
208
+ [Events]
209
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
210
+ """
File without changes
@@ -0,0 +1,249 @@
1
+ import dropbox
2
+ from dropbox import Dropbox
3
+ from dropbox.files import WriteMode
4
+ import os
5
+ import time
6
+ import logging
7
+ import requests
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class DropboxHandler:
13
+ def __init__(self, app_key=None, app_secret=None, refresh_token=None, access_token=None):
14
+ self.app_key = app_key or os.environ.get("WHISPER_DROPBOX_APP_KEY")
15
+ self.app_secret = app_secret or os.environ.get("WHISPER_DROPBOX_APP_SECRET")
16
+ self.refresh_token = refresh_token or os.environ.get("WHISPER_DROPBOX_REFRESH_TOKEN")
17
+ self.access_token = access_token or os.environ.get("WHISPER_DROPBOX_ACCESS_TOKEN")
18
+ self.dbx = Dropbox(self.access_token)
19
+
20
+ def _refresh_access_token(self):
21
+ """Refresh the access token using the refresh token."""
22
+ try:
23
+ logger.debug("Attempting to refresh access token")
24
+ # Prepare the token refresh request
25
+ data = {"grant_type": "refresh_token", "refresh_token": self.refresh_token}
26
+ auth = (self.app_key, self.app_secret)
27
+
28
+ logger.debug(f"Making refresh token request to Dropbox API")
29
+ response = requests.post("https://api.dropbox.com/oauth2/token", data=data, auth=auth)
30
+
31
+ logger.debug(f"Received response from Dropbox API. Status code: {response.status_code}")
32
+ if response.status_code == 200:
33
+ result = response.json()
34
+ self.access_token = result["access_token"]
35
+ self.dbx = Dropbox(self.access_token)
36
+ logger.info("Successfully refreshed access token")
37
+ else:
38
+ logger.error(f"Failed to refresh token. Status code: {response.status_code}, Response: {response.text}")
39
+
40
+ except Exception as e:
41
+ logger.error(f"Error refreshing access token: {str(e)}", exc_info=True)
42
+ raise
43
+
44
+ @staticmethod
45
+ def _handle_auth_error(func):
46
+ """Decorator to handle authentication errors and retry with refreshed token."""
47
+
48
+ def wrapper(self, *args, **kwargs):
49
+ try:
50
+ logger.debug(f"Executing {func.__name__} with args: {args}, kwargs: {kwargs}")
51
+ return func(self, *args, **kwargs)
52
+ except (dropbox.exceptions.AuthError, dropbox.exceptions.ApiError) as e:
53
+ logger.debug(f"Caught error in {func.__name__}: {str(e)}")
54
+ if "expired_access_token" in str(e):
55
+ logger.info(f"Access token expired in {func.__name__}, attempting refresh")
56
+ self._refresh_access_token()
57
+ logger.debug(f"Retrying {func.__name__} after token refresh")
58
+ return func(self, *args, **kwargs)
59
+ logger.error(f"Unhandled Dropbox error in {func.__name__}: {str(e)}")
60
+ raise
61
+
62
+ return wrapper
63
+
64
+ @_handle_auth_error
65
+ def upload_with_retry(self, file, path, max_retries=3):
66
+ """Upload a file to Dropbox with retries."""
67
+ for attempt in range(max_retries):
68
+ try:
69
+ logger.debug(f"Attempting file upload to {path} (attempt {attempt + 1}/{max_retries})")
70
+ file.seek(0)
71
+ self.dbx.files_upload(file.read(), path, mode=WriteMode.overwrite)
72
+ logger.debug(f"Successfully uploaded file to {path}")
73
+ return
74
+ except dropbox.exceptions.ApiError as e:
75
+ logger.warning(f"Upload attempt {attempt + 1} failed: {str(e)}")
76
+ if attempt == max_retries - 1:
77
+ logger.error(f"All upload attempts failed for {path}: {str(e)}")
78
+ raise
79
+ sleep_time = 1 * (attempt + 1)
80
+ logger.debug(f"Waiting {sleep_time} seconds before retry")
81
+ time.sleep(sleep_time)
82
+
83
+ @_handle_auth_error
84
+ def upload_string_with_retry(self, content, path, max_retries=3):
85
+ """Upload a string content to Dropbox with retries."""
86
+ for attempt in range(max_retries):
87
+ try:
88
+ logger.debug(f"Attempting string upload to {path} (attempt {attempt + 1}/{max_retries})")
89
+ self.dbx.files_upload(content.encode(), path, mode=WriteMode.overwrite)
90
+ logger.debug(f"Successfully uploaded string content to {path}")
91
+ return
92
+ except dropbox.exceptions.ApiError as e:
93
+ logger.warning(f"Upload attempt {attempt + 1} failed: {str(e)}")
94
+ if attempt == max_retries - 1:
95
+ logger.error(f"All upload attempts failed for {path}: {str(e)}")
96
+ raise
97
+ sleep_time = 1 * (attempt + 1)
98
+ logger.debug(f"Waiting {sleep_time} seconds before retry")
99
+ time.sleep(sleep_time)
100
+
101
+ @_handle_auth_error
102
+ def list_folder_recursive(self, path=""):
103
+ """List all files in a folder recursively."""
104
+ try:
105
+ logger.debug(f"Listing files recursively from {path}")
106
+ entries = []
107
+ result = self.dbx.files_list_folder(path, recursive=True)
108
+
109
+ while True:
110
+ entries.extend(result.entries)
111
+ if not result.has_more:
112
+ break
113
+ logger.debug("Fetching more results from Dropbox")
114
+ result = self.dbx.files_list_folder_continue(result.cursor)
115
+
116
+ return entries
117
+
118
+ except (dropbox.exceptions.AuthError, dropbox.exceptions.ApiError):
119
+ # Let the decorator handle these
120
+ raise
121
+ except Exception as e:
122
+ logger.error(f"Error listing files from Dropbox: {str(e)}", exc_info=True)
123
+ raise
124
+
125
+ @_handle_auth_error
126
+ def download_file_content(self, path):
127
+ """Download and return the content of a file."""
128
+ try:
129
+ logger.debug(f"Downloading file content from {path}")
130
+ return self.dbx.files_download(path)[1].content
131
+ except Exception as e:
132
+ logger.error(f"Error downloading file from {path}: {str(e)}", exc_info=True)
133
+ raise
134
+
135
+ @_handle_auth_error
136
+ def download_folder(self, dropbox_path, local_path):
137
+ """Download all files from a Dropbox folder to a local path."""
138
+ try:
139
+ logger.debug(f"Downloading folder {dropbox_path} to {local_path}")
140
+
141
+ # List all files in the folder
142
+ result = self.dbx.files_list_folder(dropbox_path, recursive=True)
143
+ entries = result.entries
144
+
145
+ # Continue fetching if there are more files
146
+ while result.has_more:
147
+ result = self.dbx.files_list_folder_continue(result.cursor)
148
+ entries.extend(result.entries)
149
+
150
+ # Download each file
151
+ for entry in entries:
152
+ if isinstance(entry, dropbox.files.FileMetadata):
153
+ # Calculate relative path from the root folder
154
+ rel_path = entry.path_display[len(dropbox_path) :].lstrip("/")
155
+ local_file_path = os.path.join(local_path, rel_path)
156
+
157
+ # Create directories if they don't exist
158
+ os.makedirs(os.path.dirname(local_file_path), exist_ok=True)
159
+
160
+ # Download the file
161
+ logger.debug(f"Downloading {entry.path_display} to {local_file_path}")
162
+ self.dbx.files_download_to_file(local_file_path, entry.path_display)
163
+
164
+ logger.debug(f"Successfully downloaded folder {dropbox_path} to {local_path}")
165
+
166
+ except Exception as e:
167
+ logger.error(f"Error downloading folder {dropbox_path}: {str(e)}", exc_info=True)
168
+ raise
169
+
170
+ @_handle_auth_error
171
+ def upload_folder(self, local_path, dropbox_path):
172
+ """Upload all files from a local folder to a Dropbox path."""
173
+ try:
174
+ logger.debug(f"Uploading folder {local_path} to {dropbox_path}")
175
+
176
+ # Walk through all files in the local folder
177
+ for root, dirs, files in os.walk(local_path):
178
+ for filename in files:
179
+ local_file_path = os.path.join(root, filename)
180
+ # Calculate relative path from local_path
181
+ rel_path = os.path.relpath(local_file_path, local_path)
182
+ target_path = f"{dropbox_path}/{rel_path}"
183
+
184
+ logger.debug(f"Uploading {rel_path} to {target_path}")
185
+ with open(local_file_path, "rb") as f:
186
+ self.dbx.files_upload(f.read(), target_path, mode=WriteMode.overwrite)
187
+
188
+ logger.debug(f"Successfully uploaded folder {local_path} to {dropbox_path}")
189
+
190
+ except Exception as e:
191
+ logger.error(f"Error uploading folder {local_path}: {str(e)}", exc_info=True)
192
+ raise
193
+
194
+ @_handle_auth_error
195
+ def create_shared_link(self, path):
196
+ """Create a shared link for a file that's accessible without login."""
197
+ try:
198
+ logger.debug(f"Creating shared link for {path}")
199
+ shared_link = self.dbx.sharing_create_shared_link_with_settings(
200
+ path, settings=dropbox.sharing.SharedLinkSettings(requested_visibility=dropbox.sharing.RequestedVisibility.public)
201
+ )
202
+ # Convert dropbox shared link to direct download link
203
+ return shared_link.url.replace("www.dropbox.com", "dl.dropboxusercontent.com")
204
+ except Exception as e:
205
+ logger.error(f"Error creating shared link: {str(e)}", exc_info=True)
206
+ raise
207
+
208
+ @_handle_auth_error
209
+ def get_existing_shared_link(self, path):
210
+ """Get existing shared link for a file if it exists."""
211
+ try:
212
+ logger.debug(f"Getting existing shared link for {path}")
213
+ shared_links = self.dbx.sharing_list_shared_links(path=path).links
214
+ if shared_links:
215
+ # Convert to direct download link
216
+ return shared_links[0].url.replace("www.dropbox.com", "dl.dropboxusercontent.com")
217
+ return None
218
+ except Exception as e:
219
+ logger.error(f"Error getting existing shared link: {str(e)}", exc_info=True)
220
+ return None
221
+
222
+ @_handle_auth_error
223
+ def create_or_get_shared_link(self, path):
224
+ """Create a shared link or get existing one."""
225
+ try:
226
+ # First try to get existing link
227
+ existing_link = self.get_existing_shared_link(path)
228
+ if existing_link:
229
+ logger.debug(f"Found existing shared link for {path}")
230
+ return existing_link
231
+
232
+ # If no existing link, create new one
233
+ logger.debug(f"Creating new shared link for {path}")
234
+ shared_link = self.dbx.sharing_create_shared_link_with_settings(
235
+ path, settings=dropbox.sharing.SharedLinkSettings(requested_visibility=dropbox.sharing.RequestedVisibility.public)
236
+ )
237
+ return shared_link.url.replace("www.dropbox.com", "dl.dropboxusercontent.com")
238
+ except Exception as e:
239
+ logger.error(f"Error creating or getting shared link: {str(e)}", exc_info=True)
240
+ raise
241
+
242
+ @_handle_auth_error
243
+ def file_exists(self, path):
244
+ """Check if a file exists in Dropbox."""
245
+ try:
246
+ self.dbx.files_get_metadata(path)
247
+ return True
248
+ except:
249
+ return False
@@ -0,0 +1,116 @@
1
+ #! /usr/bin/env python3
2
+ import http.server
3
+ import socketserver
4
+ import webbrowser
5
+ import urllib.parse
6
+ import requests
7
+ from dotenv import load_dotenv
8
+ import base64
9
+ from threading import Thread
10
+ import argparse
11
+
12
+ # Load environment variables
13
+ load_dotenv()
14
+
15
+ REDIRECT_URL = "http://localhost:53682/"
16
+
17
+ # Store the authorization code when received
18
+ auth_code = None
19
+
20
+
21
+ class OAuthHandler(http.server.SimpleHTTPRequestHandler):
22
+ def do_GET(self):
23
+ global auth_code
24
+ # Parse the query parameters
25
+ query = urllib.parse.urlparse(self.path).query
26
+ params = urllib.parse.parse_qs(query)
27
+
28
+ if "code" in params:
29
+ auth_code = params["code"][0]
30
+ # Send success response
31
+ self.send_response(200)
32
+ self.send_header("Content-type", "text/html")
33
+ self.end_headers()
34
+ self.wfile.write(b"Authorization successful! You can close this window.")
35
+
36
+ # Shutdown the server
37
+ Thread(target=self.server.shutdown).start()
38
+ else:
39
+ # Handle error or other cases
40
+ self.send_response(400)
41
+ self.send_header("Content-type", "text/html")
42
+ self.end_headers()
43
+ self.wfile.write(b"Authorization failed!")
44
+
45
+
46
+ def get_tokens(app_key, app_secret):
47
+ # Construct the authorization URL
48
+ auth_url = (
49
+ "https://www.dropbox.com/oauth2/authorize"
50
+ f"?client_id={app_key}"
51
+ f"&redirect_uri={REDIRECT_URL}"
52
+ "&response_type=code"
53
+ "&token_access_type=offline"
54
+ )
55
+
56
+ # Start local server
57
+ port = int(REDIRECT_URL.split(":")[-1].strip("/"))
58
+ httpd = socketserver.TCPServer(("", port), OAuthHandler)
59
+
60
+ print(f"Opening browser for Dropbox authorization...")
61
+ webbrowser.open(auth_url)
62
+
63
+ print(f"Waiting for authorization...")
64
+ httpd.serve_forever()
65
+
66
+ if auth_code:
67
+ print("Authorization code received, exchanging for tokens...")
68
+
69
+ # Exchange authorization code for tokens
70
+ auth = base64.b64encode(f"{app_key}:{app_secret}".encode()).decode()
71
+ response = requests.post(
72
+ "https://api.dropbox.com/oauth2/token",
73
+ data={"code": auth_code, "grant_type": "authorization_code", "redirect_uri": REDIRECT_URL},
74
+ headers={"Authorization": f"Basic {auth}"},
75
+ )
76
+
77
+ if response.status_code == 200:
78
+ tokens = response.json()
79
+ print("\nTokens received successfully!")
80
+ print("\nAdd these lines to your .env file:")
81
+ print(f"WHISPER_DROPBOX_APP_KEY={app_key}")
82
+ print(f"WHISPER_DROPBOX_APP_SECRET={app_secret}")
83
+ print(f"WHISPER_DROPBOX_ACCESS_TOKEN={tokens['access_token']}")
84
+ print(f"WHISPER_DROPBOX_REFRESH_TOKEN={tokens['refresh_token']}")
85
+
86
+ # Optionally update .env file directly
87
+ update = input("\nWould you like to update your .env file automatically? (y/n): ")
88
+ if update.lower() == "y":
89
+ with open(".env", "r") as f:
90
+ lines = f.readlines()
91
+
92
+ with open(".env", "w") as f:
93
+ for line in lines:
94
+ if line.startswith("WHISPER_DROPBOX_APP_KEY="):
95
+ f.write(f"WHISPER_DROPBOX_APP_KEY={app_key}\n")
96
+ elif line.startswith("WHISPER_DROPBOX_APP_SECRET="):
97
+ f.write(f"WHISPER_DROPBOX_APP_SECRET={app_secret}\n")
98
+ elif line.startswith("WHISPER_DROPBOX_ACCESS_TOKEN="):
99
+ f.write(f"WHISPER_DROPBOX_ACCESS_TOKEN={tokens['access_token']}\n")
100
+ elif line.startswith("WHISPER_DROPBOX_REFRESH_TOKEN="):
101
+ f.write(f"WHISPER_DROPBOX_REFRESH_TOKEN={tokens['refresh_token']}\n")
102
+ else:
103
+ f.write(line)
104
+ print("Updated .env file successfully!")
105
+ else:
106
+ print("Error exchanging authorization code for tokens:")
107
+ print(response.text)
108
+
109
+
110
+ if __name__ == "__main__":
111
+ parser = argparse.ArgumentParser(description="Get Dropbox OAuth tokens.")
112
+ parser.add_argument("--app-key", required=True, help="Dropbox App Key")
113
+ parser.add_argument("--app-secret", required=True, help="Dropbox App Secret")
114
+
115
+ args = parser.parse_args()
116
+ get_tokens(args.app_key, args.app_secret)
@@ -2,19 +2,53 @@ import requests
2
2
  import time
3
3
  import os
4
4
  import json
5
+ from .base import BaseTranscriber
5
6
 
6
7
 
7
- class AudioShakeTranscriber:
8
- def __init__(self, api_token, logger, output_prefix):
9
- self.api_token = api_token
8
+ class AudioShakeTranscriber(BaseTranscriber):
9
+ """Transcription service using AudioShake's API."""
10
+
11
+ def __init__(self, api_token=None, logger=None, output_prefix=None):
12
+ super().__init__(logger)
13
+ self.api_token = api_token or os.getenv("AUDIOSHAKE_API_TOKEN")
10
14
  self.base_url = "https://groovy.audioshake.ai"
11
- self.logger = logger
12
15
  self.output_prefix = output_prefix
13
16
 
14
- def start_transcription(self, audio_filepath):
15
- """Starts the transcription job and returns the job ID without waiting for completion"""
17
+ if not self.api_token:
18
+ raise ValueError("AudioShake API token must be provided either directly or via AUDIOSHAKE_API_TOKEN env var")
19
+
20
+ def get_name(self) -> str:
21
+ return "AudioShake"
22
+
23
+ def transcribe(self, audio_filepath: str) -> dict:
24
+ """
25
+ Transcribe an audio file using AudioShake API.
26
+
27
+ Args:
28
+ audio_filepath: Path to the audio file to transcribe
29
+
30
+ Returns:
31
+ Dict containing:
32
+ - segments: List of segments with start/end times and word-level data
33
+ - text: Full text transcription
34
+ - metadata: Dict of additional info
35
+ """
16
36
  self.logger.info(f"Starting transcription for {audio_filepath} using AudioShake API")
17
37
 
38
+ # Start job and get results
39
+ job_id = self.start_transcription(audio_filepath)
40
+ result = self.get_transcription_result(job_id)
41
+
42
+ # Add metadata to the result
43
+ result["metadata"] = {
44
+ "service": self.get_name(),
45
+ "language": "en", # AudioShake currently only supports English
46
+ }
47
+
48
+ return result
49
+
50
+ def start_transcription(self, audio_filepath: str) -> str:
51
+ """Starts the transcription job and returns the job ID."""
18
52
  # Step 1: Upload the audio file
19
53
  asset_id = self._upload_file(audio_filepath)
20
54
  self.logger.info(f"File uploaded successfully. Asset ID: {asset_id}")
@@ -25,22 +59,17 @@ class AudioShakeTranscriber:
25
59
 
26
60
  return job_id
27
61
 
28
- def get_transcription_result(self, job_id):
29
- """Gets the results for a previously started job"""
62
+ def get_transcription_result(self, job_id: str) -> dict:
63
+ """Gets the results for a previously started job."""
30
64
  self.logger.info(f"Getting results for job ID: {job_id}")
31
65
 
32
- # Step 3: Wait for the job to complete and get the results
66
+ # Wait for job completion and get results
33
67
  result = self._get_job_result(job_id)
34
68
  self.logger.info(f"Job completed. Processing results...")
35
69
 
36
- # Step 4: Process the result and return in the required format
70
+ # Process and return in standard format
37
71
  return self._process_result(result)
38
72
 
39
- def transcribe(self, audio_filepath):
40
- """Original method now just combines the two steps"""
41
- job_id = self.start_transcription(audio_filepath)
42
- return self.get_transcription_result(job_id)
43
-
44
73
  def _upload_file(self, filepath):
45
74
  self.logger.info(f"Uploading {filepath} to AudioShake")
46
75
  url = f"{self.base_url}/upload"