-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathconversion.py
More file actions
426 lines (350 loc) · 13.7 KB
/
conversion.py
File metadata and controls
426 lines (350 loc) · 13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
"""Audio conversion engine for SAMPSON.
Uses pydub with ffmpeg backend for format conversion.
Supports WAV, AIFF output with configurable sample rate, bit depth.
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional, NamedTuple
import state
# Track whether static_ffmpeg paths have been added to PATH
_static_ffmpeg_initialized = False
def _get_bundle_dir() -> Optional[Path]:
"""Get the bundle directory when running in a PyInstaller app."""
if getattr(sys, 'frozen', False):
# Running in a bundle
bundle_dir = Path(sys._MEIPASS) if hasattr(sys, '_MEIPASS') else Path(sys.executable).parent
return bundle_dir
return None
def _init_static_ffmpeg() -> bool:
"""Add static_ffmpeg binaries (ffmpeg + ffprobe) to PATH. Returns True on success."""
global _static_ffmpeg_initialized
if _static_ffmpeg_initialized:
return True
# Try PyInstaller bundle paths first
bundle_dir = _get_bundle_dir()
if bundle_dir:
# Check common locations in the bundle
possible_paths = [
bundle_dir / "static_ffmpeg" / "bin" / "darwin_arm64",
bundle_dir / "static_ffmpeg" / "bin" / "darwin_x86_64",
bundle_dir / "static_ffmpeg" / "bin",
bundle_dir / ".." / "Resources" / "static_ffmpeg" / "bin" / "darwin_arm64",
bundle_dir / ".." / "Resources" / "static_ffmpeg" / "bin" / "darwin_x86_64",
bundle_dir / ".." / "Frameworks" / "static_ffmpeg" / "bin" / "darwin_arm64",
bundle_dir / ".." / "Frameworks" / "static_ffmpeg" / "bin" / "darwin_x86_64",
]
for bin_path in possible_paths:
ffmpeg_exe = bin_path / "ffmpeg"
if ffmpeg_exe.exists():
# Add to PATH
bin_str = str(bin_path.resolve())
current_path = os.environ.get('PATH', '')
if bin_str not in current_path:
os.environ['PATH'] = bin_str + os.pathsep + current_path
_static_ffmpeg_initialized = True
return True
# Fall back to standard static_ffmpeg for non-bundled runs
try:
import static_ffmpeg
static_ffmpeg.add_paths()
_static_ffmpeg_initialized = True
return True
except Exception:
pass
return False
def _find_ffmpeg_path() -> Optional[str]:
"""Find ffmpeg executable path.
Priority:
1. static-ffmpeg bundled binaries (includes both ffmpeg + ffprobe)
2. System PATH (user override)
3. Common install locations
"""
# 1. Try static-ffmpeg bundled binaries first (bundled with app)
try:
if _init_static_ffmpeg():
ffmpeg_exe = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg"
bundled = shutil.which(ffmpeg_exe)
if bundled and os.path.isfile(bundled):
return bundled
except Exception:
pass
# 2. Check system PATH (allows user override of bundled version)
ffmpeg_exe = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg"
path_result = shutil.which(ffmpeg_exe)
if path_result:
return path_result
# 3. Try common install locations (fallback for development)
if sys.platform == "win32":
program_files = os.environ.get("ProgramFiles", "C:\\Program Files")
program_files_x86 = os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")
local_appdata = os.environ.get("LOCALAPPDATA", "")
# Winget installs ffmpeg with version in path
winget_base = os.path.join(local_appdata, "Microsoft", "WinGet", "Packages",
"Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe")
if os.path.isdir(winget_base):
for item in os.listdir(winget_base):
if item.startswith("ffmpeg-") and "full_build" in item:
candidate = os.path.join(winget_base, item, "bin", "ffmpeg.exe")
if os.path.isfile(candidate):
return candidate
common_paths = [
os.path.join(program_files, "ffmpeg", "bin", "ffmpeg.exe"),
os.path.join(program_files_x86, "ffmpeg", "bin", "ffmpeg.exe"),
r"C:\ffmpeg\bin\ffmpeg.exe",
]
for path in common_paths:
if os.path.isfile(path):
return path
return None
def _find_ffprobe_path(ffmpeg_path: str) -> Optional[str]:
"""Find ffprobe executable path based on ffmpeg location.
Args:
ffmpeg_path: Path to ffmpeg executable
Returns:
Path to ffprobe executable, or None if not found
"""
if not ffmpeg_path:
return None
ffmpeg_dir = os.path.dirname(ffmpeg_path)
# Check same directory as ffmpeg first (imageio-ffmpeg layout)
ffprobe_exe = "ffprobe.exe" if sys.platform == "win32" else "ffprobe"
same_dir = os.path.join(ffmpeg_dir, ffprobe_exe)
if os.path.isfile(same_dir):
return same_dir
# Try replacing ffmpeg with ffprobe in the filename (various naming conventions)
base_name = os.path.basename(ffmpeg_path)
# Handle different ffmpeg naming patterns
replacements = [
("ffmpeg-win-x86_64-", "ffprobe-win-x86_64-"),
("ffmpeg-win-", "ffprobe-win-"),
("ffmpeg", "ffprobe"),
]
for old, new in replacements:
if old in base_name:
candidate = os.path.join(ffmpeg_dir, base_name.replace(old, new))
if os.path.isfile(candidate):
return candidate
# Try PATH lookup as last resort
return shutil.which(ffprobe_exe)
def check_ffmpeg() -> bool:
"""Verify ffmpeg is available on the system."""
return _find_ffmpeg_path() is not None
def get_ffmpeg_version() -> Optional[str]:
"""Get ffmpeg version string if available."""
ffmpeg_path = _find_ffmpeg_path()
if not ffmpeg_path:
return None
try:
# On Windows, hide console window to prevent flashing in PyInstaller builds
kwargs = {
"capture_output": True,
"text": True,
"timeout": 5
}
if sys.platform == "win32":
kwargs["creationflags"] = 0x08000000 # CREATE_NO_WINDOW
result = subprocess.run([ffmpeg_path, "-version"], **kwargs)
if result.returncode == 0:
# First line: "ffmpeg version 6.0 Copyright ..."
first_line = result.stdout.split('\n')[0]
return first_line.split()[2] if len(first_line.split()) >= 3 else "unknown"
except Exception:
pass
return None
def get_audio_info(path: Path) -> Optional[NamedTuple]:
"""Extract metadata from an audio file.
Returns None if file cannot be read.
"""
try:
AudioSegment = _get_pydub()
audio = AudioSegment.from_file(str(path))
# Determine format from extension
fmt = path.suffix.lower().lstrip('.')
if fmt in ('aif', 'aiff'):
fmt = 'aiff'
# pydub samples are 16-bit internally, so bit_depth is estimated
return AudioInfo(
path=path,
format=fmt,
sample_rate=audio.frame_rate,
bit_depth=16, # pydub uses 16-bit internally
channels=audio.channels,
duration_seconds=len(audio) / 1000.0
)
except Exception:
return None
# Define AudioInfo NamedTuple after imports
class AudioInfo(NamedTuple):
"""Metadata about an audio file."""
path: Path
format: str
sample_rate: int
bit_depth: Optional[int]
channels: int
duration_seconds: float
# Lazy import pydub to avoid startup overhead
_pydub = None
def _get_pydub():
"""Lazy load pydub module and configure ffmpeg path."""
global _pydub
if _pydub is None:
from pydub import AudioSegment
_pydub = AudioSegment
# Note: subprocess.Popen is patched globally in main.py for Windows
return _pydub
def convert_file(
src: Path,
dst: Path,
output_format: str = "wav",
sample_rate: Optional[int] = None,
bit_depth: Optional[int] = None,
channels: Optional[int] = None,
normalize: bool = False,
) -> bool:
"""Convert an audio file to target specifications.
Args:
src: Source file path
dst: Destination file path
output_format: "wav" or "aiff"
sample_rate: Target sample rate (None = keep original)
bit_depth: Target bit depth 16, 24, or 32 (None = keep original)
channels: Target channels 1 or 2 (None = keep original)
normalize: Apply normalization
Returns:
True if conversion successful, False otherwise
"""
try:
# Verify source file exists
if not src.exists():
raise FileNotFoundError(f"Source file not found: {src}")
# Find ffmpeg
ffmpeg_path = _find_ffmpeg_path()
if not ffmpeg_path:
raise RuntimeError("ffmpeg not found - cannot convert audio")
# Ensure PATH includes ffmpeg directory for subprocess calls
ffmpeg_dir = os.path.dirname(ffmpeg_path)
current_path = os.environ.get('PATH', '')
if ffmpeg_dir not in current_path:
os.environ['PATH'] = ffmpeg_dir + os.pathsep + current_path
# Explicitly set pydub's ffmpeg path
import pydub
pydub.AudioSegment.converter = ffmpeg_path
# Now load pydub and process the audio
# Pass format explicitly so pydub uses -f flag with ffmpeg directly,
# making ffprobe unnecessary in the bundle.
AudioSegment = _get_pydub()
_ext = src.suffix.lower().lstrip('.')
_fmt = 'aiff' if _ext in ('aif', 'aiff') else _ext # ffmpeg uses 'aiff' not 'aif'
audio = AudioSegment.from_file(str(src), format=_fmt)
# Apply conversions in order
if sample_rate and audio.frame_rate != sample_rate:
audio = audio.set_frame_rate(sample_rate)
if channels and audio.channels != channels:
if channels == 1:
audio = audio.set_channels(1) # Mono
elif channels == 2:
audio = audio.set_channels(2) # Stereo
if normalize:
# Normalize to -1 dBFS to prevent clipping
audio = audio.normalize()
audio = audio.apply_gain(-1.0)
# Export with specified parameters
export_format = output_format.upper()
# Handle bit depth for WAV export via ffmpeg parameters
parameters = []
if output_format.lower() == "wav" and bit_depth:
if bit_depth == 16:
parameters = ["-acodec", "pcm_s16le"]
elif bit_depth == 24:
parameters = ["-acodec", "pcm_s24le"]
elif bit_depth == 32:
parameters = ["-acodec", "pcm_s32le"]
elif output_format.lower() in ("aiff", "aif") and bit_depth:
if bit_depth == 16:
parameters = ["-acodec", "pcm_s16be"]
elif bit_depth == 24:
parameters = ["-acodec", "pcm_s24be"]
elif bit_depth == 32:
parameters = ["-acodec", "pcm_s32be"]
dst.parent.mkdir(parents=True, exist_ok=True)
audio.export(
str(dst),
format=export_format,
parameters=parameters if parameters else None
)
return True
except Exception as e:
# Store error info in state for retrieval
import traceback
state._last_conversion_error = f"{str(e)}\n{traceback.format_exc()}"
return False
def get_target_extension(output_format: str) -> str:
"""Get file extension for output format."""
fmt = output_format.lower()
if fmt in ("aiff", "aif"):
return ".aif"
return ".wav"
def parse_sample_rate(value: str) -> Optional[int]:
"""Parse sample rate dropdown value to integer.
Args:
value: String like "keep", "44.1k", "48k", "96k", "44100"
Returns:
Sample rate as int, or None to keep original
"""
if not value or "keep" in value.lower():
return None
# Handle format: 44.1k, 48k, 96k
value = value.lower().strip()
if value.endswith('k'):
try:
val = float(value[:-1]) # Remove 'k' and parse
return int(val * 1000)
except ValueError:
pass
# Fallback: extract any number
import re
match = re.search(r'(\d+)', value)
if match:
return int(match.group(1))
return None
def parse_bit_depth(value: str) -> Optional[int]:
"""Parse bit depth dropdown value to integer.
Args:
value: String like "keep", "16bit", "24bit", "32bit"
Returns:
Bit depth as int (16, 24, 32), or None to keep original
"""
if not value or value.lower() == "keep":
return None
value = value.lower().strip()
# Handle format: 16bit, 24bit, 32bit
if value.startswith('16'):
return 16
elif value.startswith('24'):
return 24
elif value.startswith('32'):
return 32
# Fallback: extract any number
import re
match = re.search(r'(\d+)', value)
if match:
return int(match.group(1))
return None
def parse_channels(value: str) -> Optional[int]:
"""Parse channels dropdown value to integer.
Args:
value: String like "keep", "mono", "stereo"
Returns:
1 for mono, 2 for stereo, or None to keep original
"""
if not value or value.lower() == "keep":
return None
val = value.lower().strip()
if val == "mono" or val == "1":
return 1
elif val == "stereo" or val == "2":
return 2
return None