Initial commit: ap_ds 音频播放库 (Audio Player By DVS AFS)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
test_all_apis.py - 测试 ap_ds 文档中所有 API 是否存在
|
||||
包括 AudioLibrary 类的所有方法
|
||||
"""
|
||||
|
||||
import sys
|
||||
import inspect
|
||||
|
||||
print("=" * 60)
|
||||
print("🧪 Testing All ap_ds API Exports")
|
||||
print("=" * 60)
|
||||
|
||||
# ============================================================
|
||||
# 1. 测试顶级函数导入
|
||||
# ============================================================
|
||||
|
||||
TOP_LEVEL_APIS = [
|
||||
"AudioLibrary",
|
||||
"batch_get_metadata",
|
||||
"batch_get_duration",
|
||||
"batch_get_metadata_by_type",
|
||||
"get_audio_duration",
|
||||
"get_audio_metadata",
|
||||
"auto_check_runtime",
|
||||
"check_runtime_mode",
|
||||
"show_tech_manual",
|
||||
]
|
||||
|
||||
print("\n📦 Testing top-level imports:")
|
||||
print("-" * 40)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
missing = []
|
||||
|
||||
for name in TOP_LEVEL_APIS:
|
||||
try:
|
||||
exec(f"from ap_ds import {name}")
|
||||
print(f" ✅ {name}")
|
||||
passed += 1
|
||||
except ImportError as e:
|
||||
print(f" ❌ {name}: {e}")
|
||||
failed += 1
|
||||
missing.append(name)
|
||||
|
||||
# ============================================================
|
||||
# 2. 测试 AudioLibrary 类的所有方法
|
||||
# ============================================================
|
||||
|
||||
AUDIOLIBRARY_METHODS = [
|
||||
# 初始化
|
||||
"__init__",
|
||||
|
||||
# 播放方法
|
||||
"play_from_file",
|
||||
"play_from_memory",
|
||||
"new_aid",
|
||||
|
||||
# 控制方法
|
||||
"play_audio",
|
||||
"pause_audio",
|
||||
"stop_audio",
|
||||
"seek_audio",
|
||||
|
||||
# 音量方法
|
||||
"set_volume",
|
||||
"get_volume",
|
||||
|
||||
# 淡入淡出与过渡方法
|
||||
"fadein_music",
|
||||
"fadein_music_pos",
|
||||
"fadeout_music",
|
||||
"is_music_playing",
|
||||
"is_music_paused",
|
||||
"get_music_fading",
|
||||
|
||||
# 元数据方法
|
||||
"get_audio_duration",
|
||||
"get_audio_metadata",
|
||||
"get_audio_metadata_by_path",
|
||||
"get_audio_metadata_by_aid",
|
||||
|
||||
# 批量解析方法
|
||||
"batch_get_metadata",
|
||||
"batch_get_duration",
|
||||
"batch_get_metadata_by_type",
|
||||
|
||||
# DAP 系统方法
|
||||
"save_dap_to_json",
|
||||
"get_dap_recordings",
|
||||
"clear_dap_recordings",
|
||||
|
||||
# 资源管理
|
||||
"clear_memory_cache",
|
||||
"cleanup_function",
|
||||
|
||||
# 内部辅助方法 (文档中列出但通常是私有的)
|
||||
"_find_channel_by_aid",
|
||||
"_get_file_path_by_aid",
|
||||
"_is_music_file",
|
||||
"_seek_audio",
|
||||
"_get_duration_by_filepath",
|
||||
"_get_file_duration",
|
||||
]
|
||||
|
||||
print("\n" + "-" * 40)
|
||||
print("🎯 Testing AudioLibrary methods:")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
from ap_ds import AudioLibrary
|
||||
|
||||
# 获取 AudioLibrary 类的所有方法
|
||||
lib_methods = [m for m in dir(AudioLibrary) if not m.startswith('__') or m == '__init__']
|
||||
|
||||
for method_name in AUDIOLIBRARY_METHODS:
|
||||
if hasattr(AudioLibrary, method_name):
|
||||
print(f" ✅ AudioLibrary.{method_name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ AudioLibrary.{method_name} (NOT FOUND)")
|
||||
failed += 1
|
||||
missing.append(f"AudioLibrary.{method_name}")
|
||||
|
||||
except ImportError as e:
|
||||
print(f" ❌ Cannot import AudioLibrary: {e}")
|
||||
failed += 1
|
||||
|
||||
# ============================================================
|
||||
# 3. 检查文档中可能遗漏的额外 API
|
||||
# ============================================================
|
||||
|
||||
EXTRA_APIS = [
|
||||
"is_full_performance",
|
||||
"get_runtime_info",
|
||||
]
|
||||
|
||||
print("\n" + "-" * 40)
|
||||
print("🔍 Checking extra APIs (mentioned in docs but maybe not exported):")
|
||||
print("-" * 40)
|
||||
|
||||
for name in EXTRA_APIS:
|
||||
try:
|
||||
exec(f"from ap_ds import {name}")
|
||||
print(f" ✅ {name} (exists!)")
|
||||
passed += 1
|
||||
except ImportError:
|
||||
print(f" ❌ {name} (NOT FOUND - remove from docs or add to __init__.py)")
|
||||
failed += 1
|
||||
missing.append(name)
|
||||
|
||||
# ============================================================
|
||||
# 4. 汇总
|
||||
# ============================================================
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("📊 FINAL SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
if failed == 0:
|
||||
print("🎉 ALL APIs EXIST! Documentation is accurate.")
|
||||
else:
|
||||
print(f"⚠️ {failed} API(s) missing:")
|
||||
for name in missing:
|
||||
print(f" - {name}")
|
||||
print("\n💡 Fix:")
|
||||
print(" Either remove these from documentation, or add them to __init__.py")
|
||||
|
||||
print("=" * 60)
|
||||
print(f"✅ Passed: {passed}")
|
||||
print(f"❌ Failed: {failed}")
|
||||
@@ -0,0 +1,893 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
============================================================================
|
||||
AP_DS 0.0.1-AFS - Opus Specialized Test Suite
|
||||
Audio Library By DVS
|
||||
============================================================================
|
||||
|
||||
Automated + interactive test coverage for the Opus playback support
|
||||
(opusplayer.py + _opusdll.py) integrated into AudioLibrary.
|
||||
|
||||
Sections:
|
||||
[OP-1] Opus Module Imports / Constants / Error Codes
|
||||
[OP-2] _opusdll.py DLL Loading & Auto-Download
|
||||
[OP-3] Opus Metadata Parsing (basic + extended)
|
||||
[OP-4] Opus Playback (play_from_file / new_aid / play_from_memory)
|
||||
[OP-5] Opus Playback Control (pause / resume / stop)
|
||||
[OP-6] Opus Volume Control
|
||||
[OP-7] Opus Seek
|
||||
[OP-8] Opus Fade In / Out
|
||||
[OP-9] Opus vs Original Distinction
|
||||
[OP-10] Opus Batch Parsing
|
||||
[OP-11] Opus Error Codes (normal / boundary / error)
|
||||
[OP-12] Opus AID Mapping (1:1)
|
||||
[OP-13] Opus Resource Management
|
||||
[OP-L] Listening Tests (interactive, requires ears)
|
||||
|
||||
Usage:
|
||||
python OPUS_TEST.py --auto Run automated tests only
|
||||
python OPUS_TEST.py --listen Run interactive listening tests only
|
||||
python OPUS_TEST.py --full Run everything (default)
|
||||
|
||||
The suite verifies Opus-specific error codes (2001-2010), normal operation,
|
||||
boundary values, and error paths.
|
||||
============================================================================
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import io
|
||||
import time
|
||||
import struct
|
||||
import hashlib
|
||||
import contextlib
|
||||
|
||||
# ============================================================================
|
||||
# Environment
|
||||
# ============================================================================
|
||||
os.environ.setdefault('AP_DS_SKIP_AUTO_CHECK', '1')
|
||||
os.environ.setdefault('AP_DS_SUPPRESS_WARNINGS', '1')
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if os.path.basename(_HERE) == 'ap_ds':
|
||||
sys.path.insert(0, os.path.dirname(_HERE))
|
||||
else:
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
import ap_ds
|
||||
from ap_ds import AudioLibrary
|
||||
import ap_ds.player as player
|
||||
import ap_ds.opusplayer as opusplayer
|
||||
import ap_ds._opusdll as opusdll
|
||||
|
||||
# ============================================================================
|
||||
# Test resources
|
||||
# ============================================================================
|
||||
TMP_DIR = os.path.join(_HERE, 'opus_tmp')
|
||||
os.makedirs(TMP_DIR, exist_ok=True)
|
||||
|
||||
# Opus test file (use the real test.opus if available, else search package)
|
||||
OPUS_FILE = r"D:\DVS开发目录\ap_ds opus支持 开发目录\test.opus"
|
||||
if not os.path.exists(OPUS_FILE):
|
||||
pkg_dir = os.path.dirname(os.path.abspath(opusplayer.__file__))
|
||||
for f in os.listdir(pkg_dir):
|
||||
if f.endswith('.opus'):
|
||||
OPUS_FILE = os.path.join(pkg_dir, f)
|
||||
break
|
||||
|
||||
def _prompt_opus():
|
||||
"""Prompt the user for an Opus file path. Returns default if empty/EOF."""
|
||||
print("\n" + "=" * 60)
|
||||
print(" Opus Test Suite - Test Audio Selection")
|
||||
print("=" * 60)
|
||||
default = OPUS_FILE
|
||||
try:
|
||||
answer = input(
|
||||
"Enter path to an Opus file for tests\n"
|
||||
f"(or press Enter to use default: {default})\n> "
|
||||
).strip().strip('"').strip("'")
|
||||
if answer and os.path.exists(answer):
|
||||
return answer
|
||||
if answer and not os.path.exists(answer):
|
||||
print(f" ! File not found: {answer}, using default")
|
||||
return default
|
||||
except EOFError:
|
||||
# Non-interactive execution -> use default
|
||||
return default
|
||||
|
||||
|
||||
# Original (non-Opus) test file - a WAV
|
||||
WAV_FILE = os.path.join(TMP_DIR, 'orig.wav')
|
||||
def _make_wav(path, seconds=2, sr=22050, ch=1):
|
||||
import wave
|
||||
n = sr * seconds * ch
|
||||
with wave.open(path, 'w') as w:
|
||||
w.setnchannels(ch)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(sr)
|
||||
w.writeframes(b''.join(struct.pack('<h', 0) for _ in range(n)))
|
||||
return path
|
||||
_make_wav(WAV_FILE)
|
||||
|
||||
# Corrupted Opus file
|
||||
BAD_OPUS = os.path.join(TMP_DIR, 'bad.opus')
|
||||
with open(BAD_OPUS, 'wb') as f:
|
||||
f.write(b'\x00\x01\x02\x03' * 100) # not a valid Opus stream
|
||||
|
||||
# ============================================================================
|
||||
# Test framework
|
||||
# ============================================================================
|
||||
class OpusTest:
|
||||
def __init__(self):
|
||||
self.passed = 0
|
||||
self.failed = 0
|
||||
self.skipped = 0
|
||||
self.failures = []
|
||||
|
||||
def log(self, msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
def check(self, name, cond, detail=""):
|
||||
if cond:
|
||||
self.passed += 1
|
||||
self.log(f" [PASS] {name}" + (f" | {detail}" if detail else ""))
|
||||
else:
|
||||
self.failed += 1
|
||||
self.failures.append((name, detail))
|
||||
self.log(f" [FAIL] {name}" + (f" | {detail}" if detail else ""))
|
||||
|
||||
def skip(self, name, reason):
|
||||
self.skipped += 1
|
||||
self.log(f" [SKIP] {name} | {reason}")
|
||||
|
||||
def section(self, title):
|
||||
self.log("\n" + "=" * 66)
|
||||
self.log(f" {title}")
|
||||
self.log("=" * 66)
|
||||
|
||||
def summary(self):
|
||||
self.log("\n" + "=" * 66)
|
||||
self.log(" Opus Test Summary")
|
||||
self.log("=" * 66)
|
||||
self.log(f" Passed : {self.passed}")
|
||||
self.log(f" Failed : {self.failed}")
|
||||
self.log(f" Skipped: {self.skipped}")
|
||||
if self.failures:
|
||||
self.log(" --- Failed details ---")
|
||||
for name, detail in self.failures:
|
||||
self.log(f" [FAIL] {name}: {detail}")
|
||||
self.log("=" * 66)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-1] Opus Module Imports / Constants / Error Codes
|
||||
# ============================================================================
|
||||
def test_opus_imports(t):
|
||||
t.section("[OP-1] Opus Module Imports / Constants / Error Codes")
|
||||
# Module imports
|
||||
t.check("opusplayer importable", callable(opusplayer.OpusAudio))
|
||||
t.check("_opusdll importable", opusdll is not None)
|
||||
t.check("OpusAudio class exists", hasattr(opusplayer, 'OpusAudio'))
|
||||
t.check("check_opus_dll callable", callable(opusplayer.check_opus_dll))
|
||||
t.check("import_opus callable", callable(opusdll.import_opus))
|
||||
t.check("download_opus_libraries callable", callable(opusdll.download_opus_libraries))
|
||||
# Constants
|
||||
t.check("MUS_NO_FADING=0", opusplayer.MUS_NO_FADING == 0)
|
||||
t.check("MUS_FADING_IN=1", opusplayer.MUS_FADING_IN == 1)
|
||||
t.check("MUS_FADING_OUT=2", opusplayer.MUS_FADING_OUT == 2)
|
||||
t.check("WAVE_FORMAT_PCM=1", opusplayer.WAVE_FORMAT_PCM == 1)
|
||||
t.check("CALLBACK_EVENT set", opusplayer.CALLBACK_EVENT == 0x00050000)
|
||||
# Structures
|
||||
t.check("OpusHead structure", hasattr(opusdll, 'OpusHead'))
|
||||
t.check("OpusTags structure", hasattr(opusdll, 'OpusTags'))
|
||||
t.check("WAVEFORMATEX structure", hasattr(opusdll, 'WAVEFORMATEX'))
|
||||
t.check("WAVEHDR structure", hasattr(opusdll, 'WAVEHDR'))
|
||||
# DLL file presence
|
||||
pkg = os.path.dirname(os.path.abspath(opusdll.__file__))
|
||||
for dll in ('libopusfile-0.dll', 'libopus-0.dll', 'libogg-0.dll', 'libopusurl-0.dll'):
|
||||
t.check(f"{dll} exists", os.path.exists(os.path.join(pkg, dll)))
|
||||
# DLL hash table
|
||||
t.check("OPUS_DLL_HASHES defined", hasattr(opusdll, 'OPUS_DLL_HASHES'))
|
||||
if hasattr(opusdll, 'OPUS_DLL_HASHES'):
|
||||
for dll in ('libopusfile-0.dll', 'libopus-0.dll', 'libogg-0.dll', 'libopusurl-0.dll'):
|
||||
t.check(f"hash for {dll}", dll in opusdll.OPUS_DLL_HASHES)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-2] _opusdll.py DLL Loading & Auto-Download
|
||||
# ============================================================================
|
||||
def test_opus_dll(t):
|
||||
t.section("[OP-2] _opusdll.py DLL Loading & Auto-Download")
|
||||
ok, msg = opusplayer.check_opus_dll()
|
||||
t.check("check_opus_dll returns (bool,str)", isinstance(ok, bool) and isinstance(msg, str), f"got={ok},{msg}")
|
||||
t.check("check_opus_dll ok", ok is True, f"msg={msg}")
|
||||
r = opusdll.import_opus()
|
||||
t.check("import_opus returns bool", isinstance(r, bool), f"got={r}")
|
||||
t.check("import_opus ok", r is True)
|
||||
t.check("opusfile handle loaded", opusdll.opusfile is not None)
|
||||
t.check("OPUS_DLL_FILES has 4", len(opusdll.OPUS_DLL_FILES) == 4, f"got={len(opusdll.OPUS_DLL_FILES)}")
|
||||
if hasattr(opusdll, 'OPUS_DLL_FILES'):
|
||||
for dll in opusdll.OPUS_DLL_FILES:
|
||||
t.check(f"DLL config {dll['filename']}", 'url' in dll and 'filename' in dll)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-3] Opus Metadata Parsing
|
||||
# ============================================================================
|
||||
def test_opus_metadata(t):
|
||||
t.section("[OP-3] Opus Metadata Parsing (basic + extended)")
|
||||
if not os.path.exists(OPUS_FILE):
|
||||
t.skip("Opus metadata", "no Opus file available")
|
||||
return
|
||||
lib = opusplayer.OpusAudio()
|
||||
meta = lib.get_audio_metadata_by_path(OPUS_FILE)
|
||||
t.check("get_audio_metadata_by_path is dict", isinstance(meta, dict), f"got={type(meta).__name__}")
|
||||
if isinstance(meta, dict):
|
||||
t.check("format=opus", meta.get('format') == 'opus', f"got={meta.get('format')}")
|
||||
t.check("duration>0", meta.get('duration', 0) > 0, f"got={meta.get('duration')}")
|
||||
t.check("sample_rate=48000", meta.get('sample_rate') == 48000, f"got={meta.get('sample_rate')}")
|
||||
t.check("channels>0", meta.get('channels', 0) > 0, f"got={meta.get('channels')}")
|
||||
t.check("bitrate>0", meta.get('bitrate', 0) > 0, f"got={meta.get('bitrate')}")
|
||||
t.check("fields complete", all(k in meta for k in ('path', 'format', 'duration', 'length', 'sample_rate', 'channels', 'bitrate')))
|
||||
dur = lib.get_audio_duration(OPUS_FILE, is_file=True)
|
||||
t.check("get_audio_duration>0", isinstance(dur, int) and dur > 0, f"got={dur}")
|
||||
sr = lib._get_sample_rate(OPUS_FILE)
|
||||
t.check("_get_sample_rate=48000", sr == 48000, f"got={sr}")
|
||||
ch = lib._get_channels(OPUS_FILE)
|
||||
t.check("_get_channels>0", ch > 0, f"got={ch}")
|
||||
ext = lib.get_audio_extended_metadata(OPUS_FILE)
|
||||
t.check("extended metadata is dict", isinstance(ext, dict), f"got={type(ext).__name__}")
|
||||
if isinstance(ext, dict):
|
||||
t.check("extended has title/album/artist", 'title' in ext or 'album' in ext or 'artist' in ext, f"keys={list(ext.keys())[:5]}")
|
||||
t.check("extended has vendor", 'vendor' in ext, f"vendor={ext.get('vendor')}")
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-4] Opus Playback
|
||||
# ============================================================================
|
||||
def test_opus_play(t):
|
||||
t.section("[OP-4] Opus Playback")
|
||||
if not os.path.exists(OPUS_FILE):
|
||||
t.skip("Opus playback", "no Opus file available")
|
||||
return
|
||||
lib = opusplayer.OpusAudio()
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
t.check("play_from_file returns int AID", isinstance(aid, int), f"got={aid}")
|
||||
if isinstance(aid, int):
|
||||
time.sleep(0.3)
|
||||
t.check("is_music_playing", lib.is_music_playing() is True)
|
||||
lib.stop_audio(aid)
|
||||
aid2 = lib.new_aid(OPUS_FILE)
|
||||
t.check("new_aid returns int AID", isinstance(aid2, int), f"got={aid2}")
|
||||
r = lib.play_from_memory(OPUS_FILE)
|
||||
t.check("play_from_memory returns int", isinstance(r, int), f"got={r}")
|
||||
if isinstance(r, int):
|
||||
lib.stop_audio(r)
|
||||
r = lib.play_from_file(os.path.join(TMP_DIR, 'missing.opus'))
|
||||
t.check("play_from_file(missing) -> 1001", isinstance(r, tuple) and r[0] == 1001, f"got={r}")
|
||||
r = lib.play_from_file(None)
|
||||
t.check("play_from_file(None) -> 1001", isinstance(r, tuple) and r[0] == 1001, f"got={r}")
|
||||
r = lib.play_from_file(BAD_OPUS)
|
||||
t.check("play_from_file(corrupted) -> 2003", isinstance(r, tuple) and r[0] == 2003, f"got={r}")
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-5] Opus Playback Control
|
||||
# ============================================================================
|
||||
def test_opus_control(t):
|
||||
t.section("[OP-5] Opus Playback Control")
|
||||
if not os.path.exists(OPUS_FILE):
|
||||
t.skip("Opus control", "no Opus file available")
|
||||
return
|
||||
lib = opusplayer.OpusAudio()
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
if not isinstance(aid, int):
|
||||
t.check("Play opus ok", False, f"got={aid}")
|
||||
lib.cleanup_function()
|
||||
return
|
||||
time.sleep(0.3)
|
||||
r = lib.pause_audio(aid)
|
||||
t.check("pause_audio ok", r[0] == 0, f"got={r}")
|
||||
time.sleep(0.2)
|
||||
t.check("is_music_paused=True", lib.is_music_paused() is True)
|
||||
r = lib.play_audio(aid)
|
||||
t.check("play_audio resume ok", r[0] == 0, f"got={r}")
|
||||
time.sleep(0.2)
|
||||
t.check("is_music_playing=True after resume", lib.is_music_playing() is True)
|
||||
r = lib.stop_audio(aid)
|
||||
t.check("stop_audio returns float", isinstance(r, float), f"got={r}")
|
||||
# Error paths
|
||||
r = lib.pause_audio(99999)
|
||||
t.check("pause_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
|
||||
r = lib.play_audio(99999)
|
||||
t.check("play_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
|
||||
r = lib.stop_audio(99999)
|
||||
t.check("stop_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-6] Opus Volume Control
|
||||
# ============================================================================
|
||||
def test_opus_volume(t):
|
||||
t.section("[OP-6] Opus Volume Control")
|
||||
if not os.path.exists(OPUS_FILE):
|
||||
t.skip("Opus volume", "no Opus file available")
|
||||
return
|
||||
lib = opusplayer.OpusAudio()
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
if isinstance(aid, int):
|
||||
# Valid volumes
|
||||
for vol in (0, 1, 64, 100, 128):
|
||||
r = lib.set_volume(aid, vol)
|
||||
t.check(f"set_volume({vol}) ok", r[0] == 0, f"got={r}")
|
||||
g = lib.get_volume(aid)
|
||||
t.check("get_volume is int", isinstance(g, int), f"got={g}")
|
||||
t.check("get_volume in range 0-128", 0 <= g <= 128, f"got={g}")
|
||||
# Invalid volumes
|
||||
for vol in (-1, 129, 200):
|
||||
r = lib.set_volume(aid, vol)
|
||||
t.check(f"set_volume({vol}) -> 1015", r[0] == 1015, f"got={r}")
|
||||
# Invalid types
|
||||
for vol in (None, '50', 1.5):
|
||||
r = lib.set_volume(aid, vol)
|
||||
t.check(f"set_volume({vol!r}) -> 1015", isinstance(r, tuple) and r[0] == 1015, f"got={r}")
|
||||
lib.stop_audio(aid)
|
||||
# Invalid AID
|
||||
r = lib.set_volume(99999, 50)
|
||||
t.check("set_volume(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
|
||||
r = lib.get_volume(99999)
|
||||
t.check("get_volume(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-7] Opus Seek
|
||||
# ============================================================================
|
||||
def test_opus_seek(t):
|
||||
t.section("[OP-7] Opus Seek")
|
||||
if not os.path.exists(OPUS_FILE):
|
||||
t.skip("Opus seek", "no Opus file available")
|
||||
return
|
||||
lib = opusplayer.OpusAudio()
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
if isinstance(aid, int):
|
||||
time.sleep(0.2)
|
||||
# Valid seeks
|
||||
for pos in (0.0, 1.0, 30.0, 100.5):
|
||||
r = lib.seek_audio(aid, pos)
|
||||
t.check(f"seek_audio({pos}) ok", r[0] == 0, f"got={r}")
|
||||
t.check("still playing after seek", lib.is_music_playing() is True)
|
||||
# Invalid types
|
||||
for pos in (None, '5', [], {}):
|
||||
r = lib.seek_audio(aid, pos)
|
||||
t.check(f"seek_audio({pos!r}) -> tuple", isinstance(r, tuple), f"got={r}")
|
||||
lib.stop_audio(aid)
|
||||
r = lib.seek_audio(99999, 1.0)
|
||||
t.check("seek_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-8] Opus Fade In / Out
|
||||
# ============================================================================
|
||||
def test_opus_fade(t):
|
||||
t.section("[OP-8] Opus Fade In / Out")
|
||||
if not os.path.exists(OPUS_FILE):
|
||||
t.skip("Opus fade", "no Opus file available")
|
||||
return
|
||||
lib = opusplayer.OpusAudio()
|
||||
# Fade-in play (via new_aid)
|
||||
aid = lib.new_aid(OPUS_FILE)
|
||||
r = lib.fadein_music(aid, ms=1000)
|
||||
t.check("fadein_music ok", r[0] == 0, f"got={r}")
|
||||
if r[0] == 0:
|
||||
time.sleep(0.3)
|
||||
fading = lib.get_music_fading()
|
||||
t.check("get_music_fading=1 (fading in)", fading == 1, f"got={fading}")
|
||||
t.check("playing during fadein", lib.is_music_playing() is True)
|
||||
time.sleep(1.5) # wait for fade-in to complete
|
||||
t.check("fading=0 after fadein", lib.get_music_fading() == 0, f"got={lib.get_music_fading()}")
|
||||
# Fade-out
|
||||
r = lib.fadeout_music(ms=1000)
|
||||
t.check("fadeout_music ok", r[0] == 0, f"got={r}")
|
||||
time.sleep(3)
|
||||
t.check("stopped after fadeout", not lib.is_music_playing())
|
||||
# Fade-in from position
|
||||
aid2 = lib.new_aid(OPUS_FILE)
|
||||
r = lib.fadein_music_pos(aid2, ms=500, position=30.0)
|
||||
t.check("fadein_music_pos ok", r[0] == 0, f"got={r}")
|
||||
if r[0] == 0:
|
||||
time.sleep(2)
|
||||
lib.stop_audio(aid2)
|
||||
# Error paths
|
||||
r = lib.fadein_music(99999)
|
||||
t.check("fadein_music(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
|
||||
r = lib.fadein_music_pos(99999, ms=500)
|
||||
t.check("fadein_music_pos(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-9] Opus vs Original Distinction
|
||||
# ============================================================================
|
||||
def test_opus_distinction(t):
|
||||
t.section("[OP-9] Opus vs Original Distinction")
|
||||
# _is_opus_file detection
|
||||
t.check("_is_opus_file(opus)=True", player._is_opus_file(OPUS_FILE) is True)
|
||||
t.check("_is_opus_file(wav)=False", player._is_opus_file(WAV_FILE) is False)
|
||||
t.check("_is_opus_file(.mp3)=False", player._is_opus_file('x.mp3') is False)
|
||||
t.check("_is_opus_file(.ogg)=False", player._is_opus_file('x.ogg') is False)
|
||||
# AudioLibrary routes opus to OpusAudio
|
||||
lib = AudioLibrary()
|
||||
opus_aid = lib.play_from_file(OPUS_FILE)
|
||||
t.check("AudioLibrary play opus returns int", isinstance(opus_aid, int), f"got={opus_aid}")
|
||||
if isinstance(opus_aid, int):
|
||||
t.check("opus AID mapped", lib._is_opus_aid(opus_aid), f"mapping={lib._aid_to_opus_aid}")
|
||||
lib.stop_audio(opus_aid)
|
||||
# AudioLibrary routes wav to SDL2
|
||||
wav_aid = lib.play_from_file(WAV_FILE)
|
||||
t.check("AudioLibrary play wav returns int", isinstance(wav_aid, int), f"got={wav_aid}")
|
||||
if isinstance(wav_aid, int):
|
||||
t.check("wav NOT mapped to opus", not lib._is_opus_aid(wav_aid), "wav uses SDL2")
|
||||
lib.stop_audio(wav_aid)
|
||||
# Metadata distinction
|
||||
meta_opus = lib.get_audio_metadata_by_path(OPUS_FILE)
|
||||
meta_wav = lib.get_audio_metadata_by_path(WAV_FILE)
|
||||
t.check("opus metadata format=opus", isinstance(meta_opus, dict) and meta_opus.get('format') == 'opus', f"got={meta_opus.get('format') if isinstance(meta_opus,dict) else meta_opus}")
|
||||
t.check("wav metadata format=wav", isinstance(meta_wav, dict) and meta_wav.get('format') == 'wav', f"got={meta_wav.get('format') if isinstance(meta_wav,dict) else meta_wav}")
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-10] Opus Batch Parsing
|
||||
# ============================================================================
|
||||
def test_opus_batch(t):
|
||||
t.section("[OP-10] Opus Batch Parsing")
|
||||
if not os.path.exists(OPUS_FILE):
|
||||
t.skip("Opus batch", "no Opus file available")
|
||||
return
|
||||
lib = opusplayer.OpusAudio()
|
||||
files = [OPUS_FILE]
|
||||
bl = lib.batch_get_metadata(files)
|
||||
t.check("batch_get_metadata returns 1", len(bl) == 1, f"got={len(bl)}")
|
||||
if bl:
|
||||
t.check("batch metadata format=opus", bl[0].get('format') == 'opus', f"got={bl[0].get('format')}")
|
||||
bd = lib.batch_get_duration(files)
|
||||
t.check("batch_get_duration returns dict", isinstance(bd, dict), f"got={type(bd).__name__}")
|
||||
t.check("batch_get_duration has opus", OPUS_FILE in bd, f"keys={list(bd.keys())}")
|
||||
bt = lib.batch_get_metadata_by_type(files, 'opus')
|
||||
t.check("batch_by_type opus -> 1", len(bt) == 1, f"got={len(bt)}")
|
||||
bt2 = lib.batch_get_metadata_by_type(files, 'wav')
|
||||
t.check("batch_by_type wav -> 0", len(bt2) == 0, f"got={len(bt2)}")
|
||||
# Mixed batch via AudioLibrary (test opus and wav separately to avoid
|
||||
# multiprocessing spawn limitations in test environments)
|
||||
lib2 = AudioLibrary()
|
||||
# Opus files in batch use opusplayer
|
||||
opus_batch = lib2.batch_get_metadata([OPUS_FILE])
|
||||
t.check("AudioLibrary batch opus -> 1", len(opus_batch) == 1, f"got={len(opus_batch)}")
|
||||
if opus_batch:
|
||||
t.check("AudioLibrary batch opus format", opus_batch[0].get('format') == 'opus', f"got={opus_batch[0].get('format')}")
|
||||
# WAV files in batch use audio_parser
|
||||
wav_batch = lib2.batch_get_metadata([WAV_FILE])
|
||||
t.check("AudioLibrary batch wav -> 1", len(wav_batch) == 1, f"got={len(wav_batch)}")
|
||||
if wav_batch:
|
||||
t.check("AudioLibrary batch wav format", wav_batch[0].get('format') == 'wav', f"got={wav_batch[0].get('format')}")
|
||||
lib2.cleanup_function()
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-11] Opus Error Codes (normal / boundary / error)
|
||||
# ============================================================================
|
||||
def test_opus_errors(t):
|
||||
t.section("[OP-11] Opus Error Codes (normal / boundary / error)")
|
||||
# Error code constants
|
||||
err_codes = {
|
||||
'AP_DS_ERR_OPUS_LIB_LOAD_FAILED': 2001,
|
||||
'AP_DS_ERR_OPUS_DLL_DEPENDENCY': 2002,
|
||||
'AP_DS_ERR_OPUS_OPEN_FAILED': 2003,
|
||||
'AP_DS_ERR_OPUS_HEADER_CORRUPT': 2004,
|
||||
'AP_DS_ERR_OPUS_TAGS_PARSE_FAILED': 2005,
|
||||
'AP_DS_ERR_OPUS_DECODE_FAILED': 2006,
|
||||
'AP_DS_ERR_OPUS_SEEK_FAILED': 2007,
|
||||
'AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE': 2008,
|
||||
'AP_DS_ERR_OPUS_NOT_SEEKABLE': 2009,
|
||||
'AP_DS_ERR_OPUS_CHANNEL_INVALID': 2010,
|
||||
}
|
||||
for name, code in err_codes.items():
|
||||
t.check(f"{name}={code}", getattr(opusplayer, name, None) == code, f"got={getattr(opusplayer, name, None)}")
|
||||
# OPUS_ERR_INFO table
|
||||
t.check("OPUS_ERR_INFO defined", hasattr(opusplayer, 'OPUS_ERR_INFO'))
|
||||
if hasattr(opusplayer, 'OPUS_ERR_INFO'):
|
||||
for code in err_codes.values():
|
||||
t.check(f"OPUS_ERR_INFO[{code}]", code in opusplayer.OPUS_ERR_INFO)
|
||||
# Normal error paths (corrupted file -> 2003)
|
||||
lib = opusplayer.OpusAudio()
|
||||
r = lib.get_audio_metadata_by_path(BAD_OPUS)
|
||||
t.check("corrupted metadata -> 2003", isinstance(r, tuple) and r[0] == 2003, f"got={r}")
|
||||
# Invalid source type
|
||||
r = lib.get_audio_metadata(1.5)
|
||||
t.check("invalid source type -> 1014", isinstance(r, tuple) and r[0] == 1014, f"got={r}")
|
||||
# Missing file
|
||||
r = lib.get_audio_metadata_by_path(os.path.join(TMP_DIR, 'missing.opus'))
|
||||
t.check("missing file -> 1001", isinstance(r, tuple) and r[0] == 1001, f"got={r}")
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-12] Opus AID Mapping (1:1)
|
||||
# ============================================================================
|
||||
def test_opus_aid_mapping(t):
|
||||
t.section("[OP-12] Opus AID Mapping (1:1)")
|
||||
if not os.path.exists(OPUS_FILE):
|
||||
t.skip("Opus AID mapping", "no Opus file available")
|
||||
return
|
||||
lib = AudioLibrary()
|
||||
main_aid = lib.play_from_file(OPUS_FILE)
|
||||
t.check("main AID is int", isinstance(main_aid, int), f"got={main_aid}")
|
||||
if isinstance(main_aid, int):
|
||||
t.check("AID mapped to opus", lib._is_opus_aid(main_aid), f"mapping={lib._aid_to_opus_aid}")
|
||||
opus_aid = lib._get_opus_aid(main_aid)
|
||||
t.check("opus AID is int", isinstance(opus_aid, int), f"got={opus_aid}")
|
||||
t.check("mapping is 1:1", isinstance(main_aid, int) and isinstance(opus_aid, int))
|
||||
r = lib.pause_audio(main_aid)
|
||||
t.check("pause via main AID -> opus", r[0] == 0, f"got={r}")
|
||||
r = lib.play_audio(main_aid)
|
||||
t.check("resume via main AID -> opus", r[0] == 0, f"got={r}")
|
||||
r = lib.set_volume(main_aid, 80)
|
||||
t.check("set_volume via main AID -> opus", r[0] == 0, f"got={r}")
|
||||
g = lib.get_volume(main_aid)
|
||||
t.check("get_volume via main AID -> opus", isinstance(g, int), f"got={g}")
|
||||
r = lib.seek_audio(main_aid, 30.0)
|
||||
t.check("seek via main AID -> opus", r[0] == 0, f"got={r}")
|
||||
r = lib.stop_audio(main_aid)
|
||||
t.check("stop via main AID -> opus", isinstance(r, float), f"got={r}")
|
||||
t.check("mapping removed after stop", not lib._is_opus_aid(main_aid), f"mapping={lib._aid_to_opus_aid}")
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-13] Opus Resource Management
|
||||
# ============================================================================
|
||||
def test_opus_resources(t):
|
||||
t.section("[OP-13] Opus Resource Management")
|
||||
if not os.path.exists(OPUS_FILE):
|
||||
t.skip("Opus resources", "no Opus file available")
|
||||
return
|
||||
lib = opusplayer.OpusAudio()
|
||||
lib.play_from_file(OPUS_FILE)
|
||||
t.check("playing before cleanup", lib.is_music_playing() is True)
|
||||
lib.cleanup_function()
|
||||
t.check("not playing after cleanup", not lib.is_music_playing())
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
t.check("reusable after cleanup", isinstance(aid, int), f"got={aid}")
|
||||
if isinstance(aid, int):
|
||||
lib.stop_audio(aid)
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-14] Opus Boundary & Error Tests (reference CI/CD test_edge_cases)
|
||||
# ============================================================================
|
||||
def test_opus_boundary(t):
|
||||
t.section("[OP-14] Opus Boundary & Error Tests")
|
||||
lib = opusplayer.OpusAudio()
|
||||
|
||||
# --- B1: invalid argument types -> error tuples, never crash ---
|
||||
t.log(" --- B1 invalid argument types ---")
|
||||
for bad in (None, 1.5, [], {}, ('a',)):
|
||||
r = lib.play_from_file(bad)
|
||||
ok = isinstance(r, tuple) and len(r) == 3 and r[0] == 1001
|
||||
t.check(f"play_from_file({type(bad).__name__}) -> tuple(1001)", ok, f"got={r!r}")
|
||||
for bad in (None, 1.5, []):
|
||||
r = lib.new_aid(bad)
|
||||
ok = isinstance(r, tuple) and len(r) == 3 and r[0] == 1001
|
||||
t.check(f"new_aid({type(bad).__name__}) -> tuple(1001)", ok, f"got={r!r}")
|
||||
|
||||
# --- B2: seek / volume / fade boundary values ---
|
||||
t.log(" --- B2 boundary values ---")
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
if isinstance(aid, int):
|
||||
# Seek boundary
|
||||
for pos in (None, '5', [], {}, -1.0, 0.0, 999999.0):
|
||||
r = lib.seek_audio(aid, pos)
|
||||
ok = isinstance(r, tuple)
|
||||
t.check(f"seek_audio({pos!r}) -> tuple", ok, f"got={r!r}")
|
||||
r = lib.seek_audio(aid, 0.0)
|
||||
t.check("seek_audio(0.0) -> success", isinstance(r, tuple) and r[0] == 0, f"got={r!r}")
|
||||
# Volume boundary
|
||||
for v in (None, '50', 1.5, [], {}):
|
||||
r = lib.set_volume(aid, v)
|
||||
t.check(f"set_volume({v!r}) -> 1015", isinstance(r, tuple) and r[0] == 1015, f"got={r!r}")
|
||||
for v in (0, 1, 64, 100, 128):
|
||||
r = lib.set_volume(aid, v)
|
||||
t.check(f"set_volume({v}) boundary -> success", r[0] == 0, f"got={r}")
|
||||
for v in (-1, 129, 200, 1000):
|
||||
r = lib.set_volume(aid, v)
|
||||
t.check(f"set_volume({v}) out of range -> 1015", r[0] == 1015, f"got={r}")
|
||||
# Fade boundary
|
||||
r = lib.fadein_music(aid, ms=None)
|
||||
t.check("fadein_music(ms=None) -> tuple", isinstance(r, tuple), f"got={r!r}")
|
||||
r = lib.fadein_music_pos(aid, ms=100, position=None)
|
||||
t.check("fadein_music_pos(position=None) -> tuple", isinstance(r, tuple), f"got={r!r}")
|
||||
lib.stop_audio(aid)
|
||||
|
||||
# --- B3: exact error codes ---
|
||||
t.log(" --- B3 exact error codes ---")
|
||||
cases = {
|
||||
'1001 missing file': (lambda: lib.play_from_file(os.path.join(TMP_DIR, 'no_such.opus')), 1001),
|
||||
'1002 invalid AID': (lambda: lib.pause_audio(99999), 1002),
|
||||
'1014 invalid source type': (lambda: lib.get_audio_metadata(1.5), 1014),
|
||||
'2003 corrupted opus': (lambda: lib.get_audio_metadata_by_path(BAD_OPUS), 2003),
|
||||
}
|
||||
for name, (fn, expect_code) in cases.items():
|
||||
r = fn()
|
||||
ok = isinstance(r, tuple) and r[0] == expect_code
|
||||
t.check(f"{name} -> {expect_code}", ok, f"got={r!r}")
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-15] Opus DLL Specialized Tests (reference CI/CD test_sdl2_bindings)
|
||||
# ============================================================================
|
||||
def test_opus_dll_deep(t):
|
||||
t.section("[OP-15] Opus DLL Specialized Tests")
|
||||
# DLL file presence and sizes
|
||||
pkg = os.path.dirname(os.path.abspath(opusdll.__file__))
|
||||
dll_info = {
|
||||
'libopusfile-0.dll': 55884,
|
||||
'libopus-0.dll': 500112,
|
||||
'libogg-0.dll': 40580,
|
||||
'libopusurl-0.dll': 76772,
|
||||
}
|
||||
for dll, size in dll_info.items():
|
||||
path = os.path.join(pkg, dll)
|
||||
t.check(f"{dll} exists", os.path.exists(path))
|
||||
if os.path.exists(path):
|
||||
actual = os.path.getsize(path)
|
||||
t.check(f"{dll} size={size}", actual == size, f"got={actual}")
|
||||
# DLL hash verification
|
||||
for dll in dll_info:
|
||||
path = os.path.join(pkg, dll)
|
||||
if os.path.exists(path):
|
||||
with open(path, 'rb') as f:
|
||||
content = f.read()
|
||||
h = hashlib.sha256(content).hexdigest()
|
||||
expected = opusdll.OPUS_DLL_HASHES.get(dll)
|
||||
t.check(f"{dll} hash matches", h == expected, f"got={h[:16]}...")
|
||||
# import_opus idempotent
|
||||
r1 = opusdll.import_opus()
|
||||
r2 = opusdll.import_opus()
|
||||
t.check("import_opus idempotent", r1 == r2 == True, f"r1={r1} r2={r2}")
|
||||
# opusfile handle
|
||||
t.check("opusfile handle valid", opusdll.opusfile is not None)
|
||||
# OPUS_DLL_FILES structure
|
||||
t.check("OPUS_DLL_FILES has 4", len(opusdll.OPUS_DLL_FILES) == 4)
|
||||
for dll in opusdll.OPUS_DLL_FILES:
|
||||
t.check(f"DLL {dll['filename']} has url", 'url' in dll and dll['url'].startswith('http'))
|
||||
t.check(f"DLL {dll['filename']} has size", 'size' in dll and dll['size'] > 0)
|
||||
# check_opus_dll
|
||||
ok, msg = opusplayer.check_opus_dll()
|
||||
t.check("check_opus_dll ok", ok is True, f"msg={msg}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-16] Opus Error Code Trigger Tests (all 2001-2010)
|
||||
# ============================================================================
|
||||
def test_opus_error_triggers(t):
|
||||
t.section("[OP-16] Opus Error Code Trigger Tests")
|
||||
lib = opusplayer.OpusAudio()
|
||||
|
||||
# 2003: corrupted file open failure
|
||||
r = lib.play_from_file(BAD_OPUS)
|
||||
t.check("2003 OPEN_FAILED trigger", isinstance(r, tuple) and r[0] == 2003, f"got={r}")
|
||||
|
||||
# 2004: HEADER_CORRUPT (monkey-patch op_head to return empty)
|
||||
import ctypes
|
||||
orig_head = opusdll.opusfile.op_head
|
||||
opusdll.opusfile.op_head = lambda of, li: ctypes.POINTER(opusdll.OpusHead)()
|
||||
r = opusplayer._get_opus_metadata(OPUS_FILE)
|
||||
t.check("2004 HEADER_CORRUPT trigger", isinstance(r, tuple) and r[0] == 2004, f"got={r}")
|
||||
opusdll.opusfile.op_head = orig_head
|
||||
|
||||
# 2008: BITRATE_UNAVAILABLE (monkey-patch op_bitrate to return 0)
|
||||
orig_bitrate = opusdll.opusfile.op_bitrate
|
||||
opusdll.opusfile.op_bitrate = lambda of, li: 0
|
||||
r = opusplayer._get_opus_metadata(OPUS_FILE)
|
||||
t.check("2008 BITRATE_UNAVAILABLE trigger", isinstance(r, tuple) and r[0] == 2008, f"got={r}")
|
||||
opusdll.opusfile.op_bitrate = orig_bitrate
|
||||
|
||||
# 2010: CHANNEL_INVALID (monkey-patch op_channel_count to return 0)
|
||||
orig_channels = opusdll.opusfile.op_channel_count
|
||||
opusdll.opusfile.op_channel_count = lambda of, li: 0
|
||||
r = opusplayer._get_opus_metadata(OPUS_FILE)
|
||||
t.check("2010 CHANNEL_INVALID trigger", isinstance(r, tuple) and r[0] == 2010, f"got={r}")
|
||||
opusdll.opusfile.op_channel_count = orig_channels
|
||||
|
||||
# 2009: NOT_SEEKABLE (monkey-patch op_seekable to return 0)
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
if isinstance(aid, int):
|
||||
orig_seekable = opusdll.opusfile.op_seekable
|
||||
opusdll.opusfile.op_seekable = lambda of: 0
|
||||
r = lib.seek_audio(aid, 10.0)
|
||||
t.check("2009 NOT_SEEKABLE trigger", isinstance(r, tuple) and r[0] == 2009, f"got={r}")
|
||||
opusdll.opusfile.op_seekable = orig_seekable
|
||||
lib.stop_audio(aid)
|
||||
|
||||
# 2007: SEEK_FAILED (monkey-patch op_pcm_seek to return -1)
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
if isinstance(aid, int):
|
||||
orig_seek = opusdll.opusfile.op_pcm_seek
|
||||
opusdll.opusfile.op_pcm_seek = lambda of, pos: -1
|
||||
r = lib.seek_audio(aid, 10.0)
|
||||
t.check("2007 SEEK_FAILED trigger", isinstance(r, tuple) and r[0] == 2007, f"got={r}")
|
||||
opusdll.opusfile.op_pcm_seek = orig_seek
|
||||
lib.stop_audio(aid)
|
||||
|
||||
# 2005: TAGS_PARSE_FAILED (monkey-patch op_tags to raise)
|
||||
class BadTags:
|
||||
@property
|
||||
def contents(self):
|
||||
raise ValueError("corrupt tags")
|
||||
orig_tags = opusdll.opusfile.op_tags
|
||||
opusdll.opusfile.op_tags = lambda of, li: BadTags()
|
||||
r = opusplayer._get_opus_extended_metadata(OPUS_FILE)
|
||||
t.check("2005 TAGS_PARSE_FAILED trigger", isinstance(r, tuple) and r[0] == 2005, f"got={r}")
|
||||
opusdll.opusfile.op_tags = orig_tags
|
||||
|
||||
# 2006: DECODE_FAILED (monkey-patch op_read_stereo to return -1)
|
||||
orig_read = opusdll.opusfile.op_read_stereo
|
||||
opusdll.opusfile.op_read_stereo = lambda of, pcm, size: -1
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
time.sleep(0.5)
|
||||
r = lib._decode_error
|
||||
t.check("2006 DECODE_FAILED trigger", r is not None and r[0] == 2006, f"got={r}")
|
||||
opusdll.opusfile.op_read_stereo = orig_read
|
||||
lib.stop_audio(aid)
|
||||
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [OP-L] Listening Tests (interactive, requires ears)
|
||||
# ============================================================================
|
||||
def test_opus_listen(t):
|
||||
t.section("[OP-L] Opus Listening Tests (interactive)")
|
||||
if not os.path.exists(OPUS_FILE):
|
||||
t.skip("Opus listening", "no Opus file available")
|
||||
return
|
||||
try:
|
||||
ans = input("\nRun Opus listening tests? (y/n): ").strip().lower()
|
||||
if ans != 'y':
|
||||
t.skip("Opus listening", "user skipped")
|
||||
return
|
||||
except EOFError:
|
||||
t.skip("Opus listening", "non-interactive")
|
||||
return
|
||||
|
||||
lib = opusplayer.OpusAudio()
|
||||
t.log("\n--- Listening 1: Normal playback ---")
|
||||
t.log(" Playing Opus at normal volume...")
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
if isinstance(aid, int):
|
||||
time.sleep(3)
|
||||
try:
|
||||
ans = input(" Did you hear audio? (y/n): ").strip().lower()
|
||||
t.check("normal playback audible", ans == 'y')
|
||||
except EOFError:
|
||||
t.skip("normal playback audible", "no input")
|
||||
lib.stop_audio(aid)
|
||||
|
||||
t.log("\n--- Listening 2: Fade-in ---")
|
||||
t.log(" Fading in over 3 seconds...")
|
||||
aid = lib.new_aid(OPUS_FILE)
|
||||
r = lib.fadein_music(aid, ms=3000)
|
||||
if r[0] == 0:
|
||||
time.sleep(3)
|
||||
try:
|
||||
ans = input(" Did volume gradually increase? (y/n): ").strip().lower()
|
||||
t.check("fade-in audible", ans == 'y')
|
||||
except EOFError:
|
||||
t.skip("fade-in audible", "no input")
|
||||
time.sleep(1)
|
||||
lib.stop_audio(aid)
|
||||
|
||||
t.log("\n--- Listening 3: Fade-out ---")
|
||||
t.log(" Playing at normal volume for 3 seconds, then fading out...")
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
time.sleep(3) # Play at normal volume first
|
||||
r = lib.fadeout_music(ms=3000)
|
||||
if r[0] == 0:
|
||||
time.sleep(3)
|
||||
try:
|
||||
ans = input(" Did volume gradually decrease then stop? (y/n): ").strip().lower()
|
||||
t.check("fade-out audible", ans == 'y')
|
||||
except EOFError:
|
||||
t.skip("fade-out audible", "no input")
|
||||
time.sleep(1)
|
||||
|
||||
t.log("\n--- Listening 4: Volume change ---")
|
||||
t.log(" Playing at normal volume for 2s, then volume 128 -> 0 -> 128...")
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
time.sleep(2) # Play at normal volume first
|
||||
lib.set_volume(aid, 128)
|
||||
time.sleep(1)
|
||||
lib.set_volume(aid, 0)
|
||||
time.sleep(1)
|
||||
try:
|
||||
ans = input(" Did sound become silent at volume 0? (y/n): ").strip().lower()
|
||||
t.check("volume 0 silent", ans == 'y')
|
||||
except EOFError:
|
||||
t.skip("volume 0 silent", "no input")
|
||||
lib.set_volume(aid, 100)
|
||||
time.sleep(1)
|
||||
lib.stop_audio(aid)
|
||||
|
||||
t.log("\n--- Listening 5: Seek position ---")
|
||||
t.log(" Playing for 3s, then seeking to 30s, 60s, 120s...")
|
||||
aid = lib.play_from_file(OPUS_FILE)
|
||||
time.sleep(3) # Play at normal position first
|
||||
for pos in (30, 60, 120):
|
||||
lib.seek_audio(aid, pos)
|
||||
time.sleep(1)
|
||||
try:
|
||||
ans = input(f" After seek to {pos}s, did position change? (y/n): ").strip().lower()
|
||||
t.check(f"seek to {pos}s audible", ans == 'y')
|
||||
except EOFError:
|
||||
t.skip(f"seek to {pos}s audible", "no input")
|
||||
lib.stop_audio(aid)
|
||||
|
||||
lib.cleanup_function()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# main()
|
||||
# ============================================================================
|
||||
def main():
|
||||
global OPUS_FILE
|
||||
mode = sys.argv[1].lower().lstrip('-') if len(sys.argv) > 1 else 'full'
|
||||
if mode not in ('auto', 'listen', 'full'):
|
||||
print(f"Unknown mode: {mode}, using full")
|
||||
mode = 'full'
|
||||
|
||||
# Prompt for Opus file path (use default if empty)
|
||||
OPUS_FILE = _prompt_opus()
|
||||
|
||||
print(f"\n Python: {sys.version.split()[0]} | Platform: {sys.platform} | Mode: {mode}")
|
||||
print(f" ap_ds version: {ap_ds.__version__} | Path: {os.path.dirname(ap_ds.__file__)}")
|
||||
print(f" Opus test file: {OPUS_FILE} (exists={os.path.exists(OPUS_FILE)})")
|
||||
|
||||
t = OpusTest()
|
||||
|
||||
if mode in ('auto', 'full'):
|
||||
test_opus_imports(t)
|
||||
test_opus_dll(t)
|
||||
test_opus_metadata(t)
|
||||
test_opus_play(t)
|
||||
test_opus_control(t)
|
||||
test_opus_volume(t)
|
||||
test_opus_seek(t)
|
||||
test_opus_fade(t)
|
||||
test_opus_distinction(t)
|
||||
test_opus_batch(t)
|
||||
test_opus_errors(t)
|
||||
test_opus_aid_mapping(t)
|
||||
test_opus_resources(t)
|
||||
test_opus_boundary(t)
|
||||
test_opus_dll_deep(t)
|
||||
test_opus_error_triggers(t)
|
||||
|
||||
if mode in ('listen', 'full'):
|
||||
test_opus_listen(t)
|
||||
|
||||
t.summary()
|
||||
return 0 if t.failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
# CI/CD.
|
||||
@@ -0,0 +1,772 @@
|
||||
# __init__.py - Package entry point
|
||||
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
try:
|
||||
from ._version import __version__
|
||||
except ImportError:
|
||||
try:
|
||||
from _version import __version__
|
||||
except ImportError:
|
||||
__version__ = "unknown"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Export top-level functions from audio_parser
|
||||
# ============================================================
|
||||
|
||||
try:
|
||||
from .audio_parser import (
|
||||
batch_get_metadata,
|
||||
batch_get_duration,
|
||||
batch_get_metadata_by_type,
|
||||
get_audio_duration,
|
||||
get_audio_metadata,
|
||||
)
|
||||
except ImportError:
|
||||
try:
|
||||
from audio_parser import (
|
||||
batch_get_metadata,
|
||||
batch_get_duration,
|
||||
batch_get_metadata_by_type,
|
||||
get_audio_duration,
|
||||
get_audio_metadata,
|
||||
)
|
||||
except ImportError:
|
||||
# Define as None if audio_parser not available
|
||||
batch_get_metadata = None
|
||||
batch_get_duration = None
|
||||
batch_get_metadata_by_type = None
|
||||
get_audio_duration = None
|
||||
get_audio_metadata = None
|
||||
|
||||
def is_full_performance() -> bool:
|
||||
"""Check if running in full performance mode."""
|
||||
info = _auto_check_runtime()
|
||||
return info.get('is_full_performance', False) if info else False
|
||||
|
||||
def get_runtime_info() -> dict:
|
||||
"""Get runtime information dictionary."""
|
||||
info = _auto_check_runtime()
|
||||
return info.copy() if info else {}
|
||||
# ============================================================
|
||||
# Banner
|
||||
# ============================================================
|
||||
|
||||
if os.environ.get('AP_DS_HIDE_SUPPORT_PROMPT') != '1':
|
||||
print(f"AP_DS © - Audio Library By DVS v{__version__} | https://apds.top")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Runtime Environment Detection
|
||||
# ============================================================
|
||||
|
||||
SUPPRESS_WARNINGS = os.environ.get('AP_DS_SUPPRESS_WARNINGS', '').lower() in ('1', 'true', 'yes', 'on')
|
||||
SHOW_CONGRATS = os.environ.get('AP_DS_SHOW_CONGRATS', '').lower() not in ('0', 'false', 'no', 'off')
|
||||
_AUTO_CHECK_SKIP = os.environ.get('AP_DS_SKIP_AUTO_CHECK', '1').lower() in ('1', 'true', 'yes', 'on')
|
||||
# ============================================================
|
||||
# Show Technical Manual (User-Initiated)
|
||||
# ============================================================
|
||||
|
||||
def show_tech_manual() -> None:
|
||||
"""
|
||||
Display the complete AP_DS 0.0.1a3 Technical Manual.
|
||||
|
||||
This function prints a comprehensive technical reference including:
|
||||
- Library architecture
|
||||
- Supported audio formats
|
||||
- Core components description
|
||||
- API reference
|
||||
- Performance tuning
|
||||
- Environment variables
|
||||
- Cross-platform notes
|
||||
- Troubleshooting guide
|
||||
|
||||
User must call this function explicitly. It will NOT be called automatically.
|
||||
|
||||
Examples:
|
||||
>>> from ap_ds import show_tech_manual
|
||||
>>> show_tech_manual()
|
||||
"""
|
||||
manual = r"""
|
||||
╔═══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ AP_DS 0.0.1a3 TECHNICAL MANUAL ║
|
||||
║ Audio Library By DVS - https://apds.top ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1. OVERVIEW │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
AP_DS (Audio Playback & Data Service) is a cross-platform, high-performance
|
||||
audio library for Python applications. Built on SDL2 and SDL2_mixer, it provides:
|
||||
|
||||
• Low-latency audio playback
|
||||
• Accurate metadata parsing (pure Python, no external dependencies)
|
||||
• Smart WAV handling with automatic mode switching
|
||||
• DAP (Dvs Audio Playlist) recording with O(1) deduplication
|
||||
• Batch metadata extraction with multi-core parallelism
|
||||
• Fade in/out controls with position seeking
|
||||
• Memory-efficient caching with automatic cleanup
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2. SUPPORTED AUDIO FORMATS │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────┬─────────────┬─────────────────────────────────────────┐
|
||||
│ Format │ Extension │ Notes │
|
||||
├──────────────┼─────────────┼─────────────────────────────────────────┤
|
||||
│ MP3 │ .mp3 │ Frame-by-frame scanning, >98% accuracy │
|
||||
│ WAV │ .wav │ RIFF chunk parsing, 100% accuracy │
|
||||
│ FLAC │ .flac │ STREAMINFO block, 100% accuracy │
|
||||
│ OGG Vorbis │ .ogg │ Granule position, 99.99% accuracy │
|
||||
│ AAC (ADTS) │ .aac │ ADTS frame parsing, >99% accuracy │
|
||||
│ OPUS │ .opus │ libopusfile decode, waveOut playback │
|
||||
└──────────────┴─────────────┴─────────────────────────────────────────┘
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2.1 OPUS SUPPORT (NEW in 0.0.1a3) │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
AP_DS 0.0.1a3 adds native Opus playback support via libopusfile + winmm waveOut.
|
||||
This is a separate playback path from SDL2, automatically selected when the
|
||||
audio file is an Opus (.opus) file.
|
||||
|
||||
Key Features:
|
||||
• Automatic detection: Opus files are routed to OpusAudio sub-player
|
||||
• AID mapping: Main library AID <-> Opus sub-library AID (1:1)
|
||||
• Auto-download: Opus DLLs downloaded from https://dvsyun.top
|
||||
• Hash verification: SHA256 verified after download
|
||||
• Full control: play / pause / resume / stop / seek / volume / fade
|
||||
|
||||
Modules:
|
||||
• opusplayer.py - OpusAudio class (Opus playback engine)
|
||||
• _opusdll.py - DLL loader + auto-download + hash verification
|
||||
|
||||
OpusAudio class:
|
||||
class OpusAudio(frequency=48000, channels=2, volume_pct=80)
|
||||
Same API as AudioLibrary for Opus files:
|
||||
play_from_file / new_aid / play_from_memory
|
||||
pause_audio / play_audio / stop_audio / seek_audio
|
||||
set_volume / get_volume
|
||||
fadein_music / fadein_music_pos / fadeout_music
|
||||
get_audio_metadata / get_audio_duration / batch_*
|
||||
|
||||
Usage (automatic routing through AudioLibrary):
|
||||
from ap_ds import AudioLibrary
|
||||
lib = AudioLibrary()
|
||||
aid = lib.play_from_file("song.opus") # auto-routed to OpusAudio
|
||||
|
||||
Direct usage:
|
||||
from ap_ds import OpusAudio
|
||||
opus = OpusAudio()
|
||||
aid = opus.play_from_file("song.opus")
|
||||
|
||||
Opus DLLs (auto-downloaded from https://dvsyun.top/ap_ds/download/):
|
||||
• libopusfile-0.dll (Opus file decoding)
|
||||
• libopus-0.dll (Opus codec core)
|
||||
• libogg-0.dll (Ogg container)
|
||||
• libopusurl-0.dll (Opus URL streaming)
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 3. CORE COMPONENTS │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
3.1 AudioLibrary (player.py)
|
||||
────────────────────────────
|
||||
Main class providing all audio playback and management functionality.
|
||||
|
||||
Methods:
|
||||
• play_from_file(file_path, loops=0, start_pos=0.0) -> int
|
||||
Play audio directly from file, returns AID
|
||||
|
||||
• play_from_memory(file_path, loops=0, start_pos=0.0) -> int
|
||||
Play audio from memory cache, returns AID
|
||||
|
||||
• new_aid(file_path) -> int
|
||||
Generate AID without playing (preloads to cache)
|
||||
|
||||
• pause_audio(aid) -> None
|
||||
Pause audio playback
|
||||
|
||||
• stop_audio(aid) -> float
|
||||
Stop playback and return played duration
|
||||
|
||||
• seek_audio(aid, position) -> None
|
||||
Seek to specified position in seconds
|
||||
|
||||
• set_volume(aid, volume) -> bool
|
||||
Set volume (0-128)
|
||||
|
||||
• get_volume(aid) -> int
|
||||
Get current volume
|
||||
|
||||
• fadein_music(aid, loops=-1, ms=0) -> bool
|
||||
Fade in music
|
||||
|
||||
• fadein_music_pos(aid, loops=-1, ms=0, position=0.0) -> bool
|
||||
Fade in music from position
|
||||
|
||||
• fadeout_music(ms=0) -> bool
|
||||
Fade out music
|
||||
|
||||
• clear_memory_cache() -> None
|
||||
Clear all cached audio data
|
||||
|
||||
• save_dap_to_json(save_path) -> bool
|
||||
Save DAP recordings to .ap-ds-dap file
|
||||
|
||||
• get_dap_recordings() -> List[Dict]
|
||||
Get current DAP recordings
|
||||
|
||||
• clear_dap_recordings() -> None
|
||||
Clear all DAP recordings
|
||||
|
||||
|
||||
3.2 Metadata Parsers (audio_parser.py)
|
||||
──────────────────────────────────────
|
||||
Pure-Python parsers for audio metadata extraction.
|
||||
|
||||
Functions:
|
||||
• get_audio_duration(file_path) -> int
|
||||
Get duration in seconds
|
||||
|
||||
• get_audio_metadata(file_path) -> Dict
|
||||
Get complete metadata (duration, sample_rate, channels, bitrate)
|
||||
|
||||
• batch_get_metadata(file_paths, max_workers=None, show_progress=False) -> List[Dict]
|
||||
Parse multiple files in parallel
|
||||
|
||||
• batch_get_duration(file_paths, max_workers=None) -> Dict[str, int]
|
||||
Get durations for multiple files
|
||||
|
||||
• batch_get_metadata_by_type(file_paths, file_type, max_workers=None) -> List[Dict]
|
||||
Filter results by format
|
||||
|
||||
|
||||
3.3 SDL2 Loader (_sdl2.py)
|
||||
──────────────────────────
|
||||
Cross-platform SDL2 library loader with automatic fallback.
|
||||
|
||||
Loading order (Linux):
|
||||
1. Package directory
|
||||
2. User config (~/.config/ap_ds/sdl_paths.conf)
|
||||
3. System libraries
|
||||
4. Auto-install via package manager
|
||||
5. Interactive setup
|
||||
|
||||
Loading order (Windows/macOS):
|
||||
1. Package directory
|
||||
2. System path
|
||||
3. Automatic download from CDN
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 4. DAP (Dvs Audio Playlist) SYSTEM │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
The DAP system automatically records every audio file that is played or loaded
|
||||
through the AudioLibrary. Features:
|
||||
|
||||
• O(1) Deduplication: Uses Python set for fast duplicate checking
|
||||
• Fallback O(n): Linear scan if set deduplication fails
|
||||
• Persistent Storage: Save to .ap-ds-dap JSON files
|
||||
• Memory Efficient: Stores only metadata, not audio data
|
||||
|
||||
Record Structure:
|
||||
{
|
||||
"path": "/path/to/audio.mp3",
|
||||
"duration": 240,
|
||||
"bitrate": 320000,
|
||||
"channels": 2
|
||||
}
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 5. WAV SMART MODE │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
WAV files are automatically handled differently based on duration:
|
||||
|
||||
┌────────────────────┬─────────────────┬────────────────────────────────┐
|
||||
│ Duration │ Mode │ SDL2 API Used │
|
||||
├────────────────────┼─────────────────┼────────────────────────────────┤
|
||||
│ < WAV_THRESHOLD │ Sound Effect │ Mix_PlayChannel (memory) │
|
||||
│ >= WAV_THRESHOLD │ Music │ Mix_PlayMusic (streaming) │
|
||||
└────────────────────┴─────────────────┴────────────────────────────────┘
|
||||
|
||||
Default threshold: 6 seconds
|
||||
Configure via: AP_DS_WAV_THRESHOLD environment variable
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 6. ENVIRONMENT VARIABLES │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
AP_DS_WAV_THRESHOLD
|
||||
─────────────────
|
||||
WAV mode switching threshold in seconds.
|
||||
Default: 6
|
||||
Range: 0-29 (values >=30 reset to 6)
|
||||
Example: AP_DS_WAV_THRESHOLD=10
|
||||
|
||||
AP_DS_SUPPRESS_WARNINGS
|
||||
─────────────────────
|
||||
Suppress GIL warning messages.
|
||||
Default: 0 (warnings enabled)
|
||||
Values: 1, true, yes, on
|
||||
Example: AP_DS_SUPPRESS_WARNINGS=1
|
||||
|
||||
AP_DS_SHOW_CONGRATS
|
||||
─────────────────
|
||||
Show congratulations message when GIL is disabled.
|
||||
Default: 1 (show)
|
||||
Values: 0, false, no, off (to hide)
|
||||
Example: AP_DS_SHOW_CONGRATS=0
|
||||
|
||||
AP_DS_SKIP_AUTO_CHECK
|
||||
───────────────────
|
||||
Skip runtime self-check on import.
|
||||
Default: 1 (skip)
|
||||
Values: 1, true, yes, on (to skip)
|
||||
Example: AP_DS_SKIP_AUTO_CHECK=0 # Show self-check
|
||||
|
||||
AP_DS_HIDE_SUPPORT_PROMPT
|
||||
──────────────────────
|
||||
Hide the support prompt banner.
|
||||
Default: 0 (show banner)
|
||||
Values: 1
|
||||
Example: AP_DS_HIDE_SUPPORT_PROMPT=1
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 7. PERFORMANCE OPTIMIZATION │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
7.1 Free-Threading Support
|
||||
──────────────────────────
|
||||
AP_DS 0.0.1a3 is optimized for Python 3.15t (free-threading mode).
|
||||
When running with GIL disabled, performance improves significantly:
|
||||
|
||||
• Batch metadata parsing uses ProcessPoolExecutor
|
||||
• Multiple audio operations can run in parallel
|
||||
• Lower latency for concurrent playback
|
||||
|
||||
To enable free-threading:
|
||||
Download Python 3.15t from:
|
||||
https://mirrors.huaweicloud.com/python/3.15.0/python-3.15.0b4t-amd64.zip
|
||||
|
||||
|
||||
7.2 Batch Processing
|
||||
────────────────────
|
||||
Use batch APIs for processing multiple files:
|
||||
|
||||
metadata = batch_get_metadata(directory, max_workers=4, show_progress=True)
|
||||
|
||||
Workers default to CPU count. Adjust based on:
|
||||
• I/O bound: Use more workers (CPU count * 2)
|
||||
• CPU bound: Use CPU count (or CPU count - 1 on 4+ cores)
|
||||
|
||||
|
||||
7.3 Memory Management
|
||||
─────────────────────
|
||||
Audio data is cached in memory. To manage memory:
|
||||
|
||||
• Use new_aid() to preload without playing
|
||||
• Call clear_memory_cache() periodically for long-running apps
|
||||
• WAV files under 6 seconds are cached as Mix_Chunk in memory
|
||||
• WAV files over 6 seconds stream via Mix_Music
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 8. CROSS-PLATFORM NOTES │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
8.1 Windows
|
||||
───────────
|
||||
• DLLs automatically downloaded from CDN
|
||||
• SDL2.dll and SDL2_mixer.dll placed in package directory
|
||||
• os.add_dll_directory() used for modern Windows
|
||||
• PATH environment variable updated automatically
|
||||
|
||||
8.2 macOS
|
||||
─────────
|
||||
• Frameworks downloaded as DMG and auto-extracted
|
||||
• SDL2.framework and SDL2_mixer.framework
|
||||
• DYLD_FRAMEWORK_PATH updated automatically
|
||||
• Supports both Intel (x64) and Apple Silicon (ARM)
|
||||
|
||||
8.3 Linux
|
||||
─────────
|
||||
• No automatic download (distribution compatibility)
|
||||
• Uses system package manager when possible
|
||||
• Manual installation instructions provided
|
||||
• Supports: Ubuntu/Debian (apt), Fedora (dnf), Arch (pacman)
|
||||
• LD_LIBRARY_PATH updated when loading from package
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 9. TROUBLESHOOTING │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
9.1 "Failed to load music file"
|
||||
──────────────────────────────
|
||||
• Verify file exists and is readable
|
||||
• Check if SDL2_mixer supports the format
|
||||
• For WAV files > 6s, ensure file is valid PCM
|
||||
|
||||
9.2 "SDL initialization failed"
|
||||
────────────────────────────────
|
||||
• SDL2 library not loaded properly
|
||||
• On Windows, check antivirus isn't blocking DLLs
|
||||
• On Linux, install SDL2 development packages
|
||||
|
||||
9.3 "audio_parser not available"
|
||||
──────────────────────────────────
|
||||
• audio_parser.py missing from package
|
||||
• Reinstall ap_ds: pip install --upgrade ap_ds
|
||||
|
||||
9.4 "GIL is enabled" warning
|
||||
──────────────────────────────
|
||||
• Running on standard Python (non-free-threading)
|
||||
• Upgrade to Python 3.15t for full performance
|
||||
• Or suppress with AP_DS_SUPPRESS_WARNINGS=1
|
||||
|
||||
9.5 DAP recordings not saving
|
||||
──────────────────────────────
|
||||
• Check file extension: must be .ap-ds-dap
|
||||
• Verify write permissions on save location
|
||||
• Ensure at least one file was played/loaded
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 10. API REFERENCE │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
10.1 AudioLibrary Class
|
||||
───────────────────────
|
||||
class AudioLibrary(frequency=44100, format=MIX_DEFAULT_FORMAT,
|
||||
channels=2, chunksize=2048)
|
||||
|
||||
参数:
|
||||
frequency: Audio sample rate (Hz)
|
||||
format: Audio format (MIX_DEFAULT_FORMAT)
|
||||
channels: Number of channels (1=mono, 2=stereo)
|
||||
chunksize: Audio buffer size
|
||||
|
||||
10.2 AID (Audio ID) System
|
||||
──────────────────────────
|
||||
Every audio playback/load returns a unique AID.
|
||||
Use AID to control playback:
|
||||
aid = lib.play_from_file("song.mp3")
|
||||
lib.pause_audio(aid)
|
||||
lib.seek_audio(aid, 30.0)
|
||||
lib.stop_audio(aid)
|
||||
|
||||
10.3 Channel vs Music
|
||||
─────────────────────
|
||||
Sound Effect Mode (Mix_PlayChannel):
|
||||
• Up to 8 simultaneous sounds
|
||||
• Loaded into memory (Mix_Chunk)
|
||||
• Low latency
|
||||
• Best for short sounds (<6s)
|
||||
|
||||
Music Mode (Mix_PlayMusic):
|
||||
• One at a time
|
||||
• Streamed from disk (Mix_Music)
|
||||
• Supports seeking and fading
|
||||
• Best for long tracks (>=6s)
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 10.4 Opus Error Codes (NEW in 0.0.1a3) │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Opus-specific error codes (2000+):
|
||||
┌──────────┬────────────────────────────────────────────┬──────────────────────────────────┐
|
||||
│ Code │ Name │ Description │
|
||||
├──────────┼────────────────────────────────────────────┼──────────────────────────────────┤
|
||||
│ 2001 │ AP_DS_ERR_OPUS_LIB_LOAD_FAILED │ libopusfile-0.dll load failed │
|
||||
│ 2002 │ AP_DS_ERR_OPUS_DLL_DEPENDENCY │ DLL dependency missing │
|
||||
│ 2003 │ AP_DS_ERR_OPUS_OPEN_FAILED │ Opus file open failed │
|
||||
│ 2004 │ AP_DS_ERR_OPUS_HEADER_CORRUPT │ OpusHead header corrupt │
|
||||
│ 2005 │ AP_DS_ERR_OPUS_TAGS_PARSE_FAILED │ OpusTags tag parse failed │
|
||||
│ 2006 │ AP_DS_ERR_OPUS_DECODE_FAILED │ Opus decode failed │
|
||||
│ 2007 │ AP_DS_ERR_OPUS_SEEK_FAILED │ Opus seek failed │
|
||||
│ 2008 │ AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE │ Bitrate unavailable │
|
||||
│ 2009 │ AP_DS_ERR_OPUS_NOT_SEEKABLE │ Stream not seekable │
|
||||
│ 2010 │ AP_DS_ERR_OPUS_CHANNEL_INVALID │ Invalid channel count │
|
||||
└──────────┴────────────────────────────────────────────┴──────────────────────────────────┘
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 11. VERSION HISTORY │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Version 0.0.1a3 (Current)
|
||||
────────────────────────
|
||||
• Opus playback support (libopusfile + waveOut)
|
||||
• OpusAudio class with automatic routing
|
||||
• Opus DLL auto-download + SHA256 verification
|
||||
• Opus-specific error codes (2001-2010)
|
||||
• Opus batch parsing
|
||||
• Opus vs original format distinction
|
||||
|
||||
Version 0.0.1a1
|
||||
─────────────
|
||||
• Python 3.15t free-threading support
|
||||
• Lazy imports for Python 3.15+
|
||||
• DAP O(1) deduplication
|
||||
• Batch metadata extraction
|
||||
• Smart WAV mode switching
|
||||
• Audio metadata parsers (pure Python)
|
||||
• Cross-platform SDL2 loader
|
||||
|
||||
Version 3.x
|
||||
───────────
|
||||
• Initial SDL2 bindings
|
||||
• Audio playback and control
|
||||
• Volume control
|
||||
• Basic metadata support
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 12. CONTRIBUTING & SUPPORT │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Website: https://apds.top
|
||||
Source Code: https://gitcode.com/dvsxt/ap_ds
|
||||
Documentation: https://apds.top/docs
|
||||
Issues: https://gitcode.com/dvsxt/ap_ds/issues
|
||||
License: MIT
|
||||
|
||||
Author: DVS
|
||||
Email: support@apds.top
|
||||
|
||||
╔═══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ END OF MANUAL ║
|
||||
║ AP_DS 0.0.1a3 - August 2026 ║
|
||||
║ ║
|
||||
║ 📖 For detailed Markdown documentation, visit: ║
|
||||
║ https://apds.top ║
|
||||
║ https://gitcode.com/dvsxt/ap_ds ║
|
||||
║ ║
|
||||
║ 📝 View source code: ║
|
||||
║ https://gitcode.com/dvsxt/ap_ds ║
|
||||
║ ║
|
||||
║ 💬 Report issues: ║
|
||||
║ https://gitcode.com/dvsxt/ap_ds/issues ║
|
||||
║ ║
|
||||
║ 💡 Quick start: ║
|
||||
║ from ap_ds import AudioLibrary ║
|
||||
║ lib = AudioLibrary() ║
|
||||
║ aid = lib.play_from_file("music.mp3") ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════════╝
|
||||
"""
|
||||
print(manual)
|
||||
def _check_runtime_mode():
|
||||
"""
|
||||
Check GIL status and notify the user accordingly.
|
||||
|
||||
Returns:
|
||||
bool: True if GIL is enabled, False if disabled (free-threading)
|
||||
"""
|
||||
try:
|
||||
gil_enabled = sys._is_gil_enabled()
|
||||
except AttributeError:
|
||||
gil_enabled = True
|
||||
|
||||
if not gil_enabled:
|
||||
if SHOW_CONGRATS:
|
||||
print("🎉 ap_ds: GIL disabled (free-threading mode)")
|
||||
else:
|
||||
if not SUPPRESS_WARNINGS:
|
||||
warnings.warn(
|
||||
"⚠️ ap_ds: GIL is enabled (multi-core parallelism limited).\n"
|
||||
" For full performance, upgrade to Python 3.15t:\n"
|
||||
" https://mirrors.huaweicloud.com/python/3.15.0/python-3.15.0b4t-amd64.zip\n"
|
||||
" To suppress this warning, set AP_DS_SUPPRESS_WARNINGS=1",
|
||||
RuntimeWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
return gil_enabled
|
||||
def _auto_check_runtime():
|
||||
"""
|
||||
Automatic runtime self-check on library import.
|
||||
|
||||
Prints diagnostic information including:
|
||||
- Python version
|
||||
- GIL status
|
||||
- Profiling availability
|
||||
- Performance mode
|
||||
- CPU cores
|
||||
- Platform
|
||||
- Library info (name, version, install path, website, author)
|
||||
|
||||
Can be disabled by setting environment variable:
|
||||
AP_DS_SKIP_AUTO_CHECK=1
|
||||
|
||||
Returns:
|
||||
dict: Runtime information dictionary
|
||||
"""
|
||||
if _AUTO_CHECK_SKIP:
|
||||
return None
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("🔍 ap_ds Runtime Self-Check")
|
||||
print("=" * 60)
|
||||
|
||||
# Library Info
|
||||
print(f"📚 Library: AP_DS (Audio Library By DVS)")
|
||||
print(f"📌 Version: {__version__}")
|
||||
print(f"📂 Install Path: {os.path.dirname(os.path.abspath(__file__))}")
|
||||
print(f"🌐 Website: https://apds.top")
|
||||
print(f"📦 PyPI: https://pypi.org/project/ap-ds/")
|
||||
print(f"📦 Mirror: https://pypi.tuna.tsinghua.edu.cn/simple/ap-ds/")
|
||||
print()
|
||||
print("📥 Installation:")
|
||||
print(" pip install ap-ds==0.0.1a3")
|
||||
print(" pip install ap-ds==0.0.1a3 -i https://pypi.tuna.tsinghua.edu.cn/simple")
|
||||
print(" pip install /path/to/ap-ds-0.0.1a3-py3-none-any.whl")
|
||||
print(f"👤 Author: DVS")
|
||||
print()
|
||||
print("📖 Description:")
|
||||
print(" AP_DS (Audio Playback & Data Service) is a cross-platform audio")
|
||||
print(" library built on SDL2 and SDL2_mixer, designed for Python applications")
|
||||
print(" requiring high-performance audio playback and metadata management.")
|
||||
print()
|
||||
print(" Core Features:")
|
||||
print(" • Audio Playback: MP3, WAV, FLAC, OGG, AAC, and more")
|
||||
print(" • Smart WAV Handling: Auto-switch between music/sound effect mode")
|
||||
print(" • Metadata Parsing: Duration, sample rate, channels, bitrate")
|
||||
print(" • DAP Recording: O(1) deduplication playlist generation")
|
||||
print(" • Batch Processing: Multi-core parallel metadata extraction")
|
||||
print(" • Fade Control: Fade in/out with position seeking support")
|
||||
print(" • Memory Management: Efficient caching with automatic cleanup")
|
||||
print()
|
||||
print(" Performance:")
|
||||
print(" • Native SDL2 bindings with zero-copy audio processing")
|
||||
print(" • Free-threading support (Python 3.15t) for maximum parallelism")
|
||||
print(" • ProcessPoolExecutor for CPU-bound batch operations")
|
||||
print()
|
||||
print(" Platform Support:")
|
||||
print(" • Windows (x64) • macOS (x64/ARM) • Linux (x64/ARM)")
|
||||
print()
|
||||
print(" Documentation: https://apds.top/docs")
|
||||
print(" Source Code: https://gitcode.com/dvsxt/ap_ds")
|
||||
print(" License: DVS Audio Library (ap_ds) Open Source License Version 2.0")
|
||||
print()
|
||||
print(f"🐍 Python: {sys.version.split()[0]} ({sys.implementation.name})")
|
||||
|
||||
try:
|
||||
gil_enabled = sys._is_gil_enabled()
|
||||
print(f"🔒 GIL: {'Enabled' if gil_enabled else 'Disabled (Free-Threading)! 🎉'}")
|
||||
except AttributeError:
|
||||
gil_enabled = True
|
||||
print(f"🔒 GIL: Unknown (pre-3.14, assumed Enabled)")
|
||||
|
||||
try:
|
||||
import profiling
|
||||
has_profiling = True
|
||||
print(f"📊 Profiling: Available (Python 3.15+)")
|
||||
except ImportError:
|
||||
has_profiling = False
|
||||
print(f"📊 Profiling: Not available (requires Python 3.15+)")
|
||||
|
||||
is_full = has_profiling and not gil_enabled
|
||||
print(f"🚀 Full Performance Mode: {'✅ YES! (3.15t)' if is_full else '❌ No (degraded mode)'}")
|
||||
|
||||
print(f"💻 CPU Cores: {os.cpu_count() or 0}")
|
||||
print(f"🖥️ Platform: {sys.platform}")
|
||||
print("=" * 60)
|
||||
|
||||
if not is_full:
|
||||
print("💡 Tip: Upgrade to Python 3.15t for full performance:")
|
||||
print(" https://mirrors.huaweicloud.com/python/3.15.0/python-3.15.0b4t-amd64.zip")
|
||||
print(" To suppress this auto-check, set AP_DS_SKIP_AUTO_CHECK=1")
|
||||
else:
|
||||
print("🎉 You're running in full performance mode! Enjoy!")
|
||||
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
return {
|
||||
"library_name": "AP_DS",
|
||||
"library_version": __version__,
|
||||
"library_install_path": os.path.dirname(os.path.abspath(__file__)),
|
||||
"library_website": "https://apds.top",
|
||||
"library_author": "DVS",
|
||||
"python_version": sys.version.split()[0],
|
||||
"gil_enabled": gil_enabled,
|
||||
"has_profiling": has_profiling,
|
||||
"is_full_performance": is_full,
|
||||
"cpu_count": os.cpu_count() or 0,
|
||||
"platform": sys.platform,
|
||||
}
|
||||
# ============================================================
|
||||
# Runtime Self-Check on Import
|
||||
# ============================================================
|
||||
|
||||
_RUNTIME_CHECKED = False
|
||||
|
||||
def ensure_runtime_checked():
|
||||
"""Ensure runtime check is performed only once."""
|
||||
global _RUNTIME_CHECKED
|
||||
if not _RUNTIME_CHECKED:
|
||||
_check_runtime_mode()
|
||||
_RUNTIME_CHECKED = True
|
||||
|
||||
ensure_runtime_checked()
|
||||
|
||||
# Execute auto self-check on import (user can call again later)
|
||||
_auto_check_runtime()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Import Player Module (AudioLibrary and all core functions)
|
||||
# ============================================================
|
||||
|
||||
try:
|
||||
from .player import *
|
||||
except ImportError:
|
||||
from player import *
|
||||
|
||||
# ============================================================
|
||||
# Opus Support (opusplayer.py)
|
||||
# ============================================================
|
||||
try:
|
||||
from .opusplayer import OpusAudio
|
||||
except ImportError:
|
||||
try:
|
||||
from opusplayer import OpusAudio
|
||||
except ImportError:
|
||||
OpusAudio = None
|
||||
|
||||
# ============================================================
|
||||
# Export Self-Check Functions (users can call manually)
|
||||
# ============================================================
|
||||
|
||||
try:
|
||||
# Try direct assignment first (functions already defined in this module)
|
||||
auto_check_runtime = _auto_check_runtime
|
||||
check_runtime_mode = _check_runtime_mode
|
||||
except Exception:
|
||||
# Fallback: import from current package
|
||||
try:
|
||||
from . import _auto_check_runtime as auto_check_runtime
|
||||
from . import _check_runtime_mode as check_runtime_mode
|
||||
except Exception:
|
||||
# Final fallback: define as None
|
||||
auto_check_runtime = None
|
||||
check_runtime_mode = None
|
||||
|
||||
# ============================================================
|
||||
# Public API
|
||||
# ============================================================
|
||||
# __init__.py
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"AudioLibrary",
|
||||
"OpusAudio",
|
||||
"get_audio_duration",
|
||||
"get_audio_metadata",
|
||||
"batch_get_metadata",
|
||||
"batch_get_duration",
|
||||
"batch_get_metadata_by_type",
|
||||
"auto_check_runtime",
|
||||
"check_runtime_mode",
|
||||
"show_tech_manual",
|
||||
]
|
||||
@@ -0,0 +1,946 @@
|
||||
# _opusdll.py - Opus DLL constants, structures, bindings and auto-download loader
|
||||
# Reference: ap_ds/_sdl2.py structure
|
||||
|
||||
import os
|
||||
import sys
|
||||
import ssl
|
||||
import hashlib
|
||||
import tempfile
|
||||
import shutil
|
||||
import urllib.request
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
from ctypes import *
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Opus DLL Library Loader
|
||||
# ============================================================
|
||||
|
||||
# Global library handles
|
||||
opusfile = None # libopusfile-0.dll
|
||||
winmm = None # winmm.dll (system)
|
||||
kernel32 = None # kernel32.dll (system)
|
||||
_opus_dll_error = None # Records DLL load failure reason
|
||||
|
||||
# DLL file list (from the download API)
|
||||
OPUS_DLL_FILES = [
|
||||
{
|
||||
"filename": "libopusurl-0.dll",
|
||||
"url": "https://dvsyun.top/ap_ds/download/libopusurl-0.dll",
|
||||
"size": 76772,
|
||||
},
|
||||
{
|
||||
"filename": "libopus-0.dll",
|
||||
"url": "https://dvsyun.top/ap_ds/download/libopus-0.dll",
|
||||
"size": 500112,
|
||||
},
|
||||
{
|
||||
"filename": "libogg-0.dll",
|
||||
"url": "https://dvsyun.top/ap_ds/download/libogg-0.dll",
|
||||
"size": 40580,
|
||||
},
|
||||
{
|
||||
"filename": "libopusfile-0.dll",
|
||||
"url": "https://dvsyun.top/ap_ds/download/libopusfile-0.dll",
|
||||
"size": 55884,
|
||||
},
|
||||
]
|
||||
|
||||
# DLL SHA256 hashes for verification (after download)
|
||||
OPUS_DLL_HASHES = {
|
||||
"libopusfile-0.dll": "fc8ff75c5e0180e73b0528dc78c51ed0fb493741375cdc227f50c2a33cabf727",
|
||||
"libopus-0.dll": "90aa25a0a6525d7da48a7ae8dd3306e45b0c28ce09a73d2a02b56cd95418d5be",
|
||||
"libogg-0.dll": "3038ce8d161324a6349bf7c83b78493857ff6a3501e3adb3d541c6a07bd94a57",
|
||||
"libopusurl-0.dll": "a6cde968a23f2d0067332a13718c52e265653a2c35d65862e8dff4cf2a0346d9",
|
||||
}
|
||||
|
||||
|
||||
def _get_package_dir():
|
||||
"""Get the directory containing this module."""
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _check_opus_libraries_exist(directory):
|
||||
"""Check if all Opus DLLs exist in the given directory."""
|
||||
required = ["libopusfile-0.dll", "libopus-0.dll", "libogg-0.dll"]
|
||||
for dll in required:
|
||||
if not os.path.exists(os.path.join(directory, dll)):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _load_from_directory(directory):
|
||||
"""Load Opus libraries from the specified directory.
|
||||
|
||||
Supports both Windows (.dll) and Linux (.so) library names.
|
||||
|
||||
Returns:
|
||||
bool: True if the Opus library loaded successfully
|
||||
"""
|
||||
global opusfile
|
||||
platform = sys.platform
|
||||
|
||||
# Determine library filename based on platform
|
||||
if platform == "win32":
|
||||
libopusfile_path = os.path.join(directory, "libopusfile-0.dll")
|
||||
elif platform.startswith("linux"):
|
||||
# Try multiple Linux .so naming conventions
|
||||
names_to_try = [
|
||||
"libopusfile.so",
|
||||
"libopusfile.so.0",
|
||||
"libopusfile-0.so",
|
||||
]
|
||||
libopusfile_path = None
|
||||
for name in names_to_try:
|
||||
candidate = os.path.join(directory, name)
|
||||
if os.path.exists(candidate):
|
||||
libopusfile_path = candidate
|
||||
break
|
||||
if libopusfile_path is None:
|
||||
return False
|
||||
else:
|
||||
# macOS (not added yet) or other
|
||||
libopusfile_path = os.path.join(directory, "libopusfile-0.dll")
|
||||
if not os.path.exists(libopusfile_path):
|
||||
return False
|
||||
|
||||
if not os.path.exists(libopusfile_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Add directory to library search path
|
||||
if platform == "win32":
|
||||
if hasattr(os, 'add_dll_directory'):
|
||||
os.add_dll_directory(directory)
|
||||
os.environ['PATH'] = directory + os.pathsep + os.environ.get('PATH', '')
|
||||
elif platform.startswith("linux"):
|
||||
if 'LD_LIBRARY_PATH' not in os.environ:
|
||||
os.environ['LD_LIBRARY_PATH'] = directory
|
||||
else:
|
||||
os.environ['LD_LIBRARY_PATH'] = directory + ':' + os.environ['LD_LIBRARY_PATH']
|
||||
|
||||
opusfile = ctypes.CDLL(libopusfile_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
_opus_dll_error = f"Opus library load error: {e}"
|
||||
return False
|
||||
|
||||
|
||||
def _load_from_system():
|
||||
"""Try loading libopusfile from system paths."""
|
||||
global opusfile
|
||||
try:
|
||||
import ctypes.util
|
||||
found = ctypes.util.find_library("opusfile")
|
||||
if found:
|
||||
opusfile = ctypes.CDLL(found)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _load_user_config():
|
||||
"""Load user-saved Opus library paths from config file."""
|
||||
global opusfile
|
||||
try:
|
||||
config_file = os.path.expanduser('~/.config/ap_ds/opus_paths.conf')
|
||||
if os.path.exists(config_file):
|
||||
with open(config_file, 'r') as f:
|
||||
opusfile_path = None
|
||||
for line in f:
|
||||
if line.startswith('OPUSFILE_PATH='):
|
||||
opusfile_path = line.strip().split('=', 1)[1]
|
||||
if opusfile_path and os.path.exists(opusfile_path):
|
||||
opusfile = ctypes.CDLL(opusfile_path)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _run_sudo_command(cmd, packages):
|
||||
"""Run a sudo command with interactive password input.
|
||||
|
||||
First tries without password (if already root or passwordless sudo).
|
||||
If that fails, prompts for the sudo password interactively.
|
||||
|
||||
Args:
|
||||
cmd: Base command list (e.g. ['apt-get', 'install', '-y'])
|
||||
packages: Package names to install
|
||||
|
||||
Returns:
|
||||
bool: True if command succeeded
|
||||
"""
|
||||
import subprocess
|
||||
import getpass
|
||||
|
||||
full_cmd = cmd + packages
|
||||
|
||||
# Try without password first (if already root / passwordless sudo)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sudo', '-n'] + full_cmd,
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Prompt for sudo password interactively
|
||||
print("🔑 sudo password required for package installation")
|
||||
try:
|
||||
password = getpass.getpass("Enter sudo password: ")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n❌ Password input cancelled")
|
||||
return False
|
||||
|
||||
# Use sudo -S to read password from stdin
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sudo', '-S'] + full_cmd,
|
||||
input=password + '\n',
|
||||
capture_output=True, text=True, timeout=180
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print("✅ Packages installed successfully")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Installation failed: {result.stderr.strip()}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Installation error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _linux_auto_install():
|
||||
"""Try automatic package manager installation on Linux.
|
||||
|
||||
Uses interactive sudo password input to avoid hanging.
|
||||
"""
|
||||
try:
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
if shutil.which('apt-get'):
|
||||
print("📦 Detected apt-based system (Ubuntu/Debian)")
|
||||
if _run_sudo_command(['apt-get', 'install', '-y'],
|
||||
['libopusfile-dev', 'libopus-dev', 'libogg-dev']):
|
||||
if _load_from_system():
|
||||
print("✅ Opus libraries installed and loaded")
|
||||
return True
|
||||
|
||||
elif shutil.which('dnf'):
|
||||
print("📦 Detected dnf-based system (Fedora)")
|
||||
if _run_sudo_command(['dnf', 'install', '-y'],
|
||||
['opusfile-devel', 'opus-devel', 'libogg-devel']):
|
||||
if _load_from_system():
|
||||
print("✅ Opus libraries installed and loaded")
|
||||
return True
|
||||
|
||||
elif shutil.which('pacman'):
|
||||
print("📦 Detected pacman-based system (Arch)")
|
||||
if _run_sudo_command(['pacman', '-S', '--noconfirm'],
|
||||
['opusfile', 'opus', 'libogg']):
|
||||
if _load_from_system():
|
||||
print("✅ Opus libraries installed and loaded")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Automatic installation failed: {e}")
|
||||
return False
|
||||
|
||||
def _linux_interactive_setup():
|
||||
"""Linux interactive setup for Opus libraries."""
|
||||
global opusfile
|
||||
print("\n" + "=" * 70)
|
||||
print("Linux Opus Library Loading")
|
||||
print("=" * 70)
|
||||
print("Options:")
|
||||
print("1. Use system-installed libraries (re-check)")
|
||||
print("2. Specify path to your compiled .so files")
|
||||
print("3. Show installation instructions")
|
||||
print("=" * 70)
|
||||
|
||||
while True:
|
||||
choice = input("\nChoose option (1/2/3): ").strip()
|
||||
|
||||
if choice == "1":
|
||||
if _load_from_system():
|
||||
print("✅ Opus libraries loaded from system")
|
||||
return True
|
||||
print("❌ System libraries not found")
|
||||
continue
|
||||
|
||||
elif choice == "2":
|
||||
opusfile_path = input("Enter full path to libopusfile.so: ").strip()
|
||||
if os.path.exists(opusfile_path):
|
||||
try:
|
||||
opusfile = ctypes.CDLL(opusfile_path)
|
||||
# Save for future
|
||||
try:
|
||||
config_dir = os.path.expanduser('~/.config/ap_ds')
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
with open(os.path.join(config_dir, 'opus_paths.conf'), 'w') as f:
|
||||
f.write(f"OPUSFILE_PATH={opusfile_path}\n")
|
||||
except Exception:
|
||||
pass
|
||||
print("✅ Libraries loaded from user-specified paths")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load: {e}")
|
||||
else:
|
||||
print("❌ File not found")
|
||||
continue
|
||||
|
||||
elif choice == "3":
|
||||
print("\n📚 Installation instructions:")
|
||||
print("=" * 50)
|
||||
print("For Ubuntu/Debian:")
|
||||
print(" sudo apt-get install libopusfile-dev libopus-dev libogg-dev")
|
||||
print("\nFor Fedora:")
|
||||
print(" sudo dnf install opusfile-devel opus-devel libogg-devel")
|
||||
print("\nFor Arch:")
|
||||
print(" sudo pacman -S opusfile opus libogg")
|
||||
print("=" * 50)
|
||||
continue
|
||||
|
||||
else:
|
||||
print("❌ Invalid choice. Please enter 1, 2, or 3.")
|
||||
|
||||
|
||||
def _macos_apology():
|
||||
"""Print a humble apology about missing Opus precompiled packages on macOS."""
|
||||
print("\n" + "=" * 70)
|
||||
print("⚠️ macOS Opus Support Notice")
|
||||
print("=" * 70)
|
||||
print("We sincerely apologize.")
|
||||
print("On macOS, SDL2 libraries can be downloaded automatically, but we")
|
||||
print("could NOT find any precompiled Opus framework packages for macOS.")
|
||||
print("This is a limitation of the Opus ecosystem, not of ap_ds.")
|
||||
print("")
|
||||
print("We recommend using a package manager to install the Opus libraries.")
|
||||
print("ap_ds will first try to auto-install them for you.")
|
||||
print("If that fails, we will guide you through manual installation.")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
|
||||
def _macos_detect_system():
|
||||
"""Detect if Opus libraries are already present on macOS system paths."""
|
||||
global opusfile
|
||||
try:
|
||||
import ctypes.util
|
||||
found = ctypes.util.find_library("opusfile")
|
||||
if found:
|
||||
opusfile = ctypes.CDLL(found)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
# Check common macOS library paths
|
||||
for path in [
|
||||
"/opt/homebrew/lib/libopusfile.dylib", # Apple Silicon Homebrew
|
||||
"/usr/local/lib/libopusfile.dylib", # Intel Homebrew
|
||||
"/opt/local/lib/libopusfile.dylib", # MacPorts
|
||||
]:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
opusfile = ctypes.CDLL(path)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _macos_install_macports():
|
||||
"""Try installing Opus libraries via MacPorts."""
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
if shutil.which('port'):
|
||||
print("📦 Detected MacPorts")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sudo', '-n', 'port', 'install', 'opus', 'opusfile', 'libogg'],
|
||||
capture_output=True, text=True, timeout=180
|
||||
)
|
||||
if result.returncode == 0:
|
||||
if _macos_detect_system():
|
||||
print("✅ Opus libraries installed via MacPorts")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
# If sudo -n failed (needs password), prompt for password
|
||||
import getpass
|
||||
print("🔑 sudo password required for MacPorts installation")
|
||||
try:
|
||||
password = getpass.getpass("Enter sudo password: ")
|
||||
result = subprocess.run(
|
||||
['sudo', '-S', 'port', 'install', 'opus', 'opusfile', 'libogg'],
|
||||
input=password + '\n', capture_output=True, text=True, timeout=300
|
||||
)
|
||||
if result.returncode == 0 and _macos_detect_system():
|
||||
print("✅ Opus libraries installed via MacPorts")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ MacPorts installation failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _macos_install_homebrew():
|
||||
"""Try installing Opus libraries via Homebrew."""
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
if shutil.which('brew'):
|
||||
print("📦 Detected Homebrew")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['brew', 'install', 'opus', 'opusfile', 'libogg'],
|
||||
capture_output=True, text=True, timeout=300
|
||||
)
|
||||
if result.returncode == 0:
|
||||
if _macos_detect_system():
|
||||
print("✅ Opus libraries installed via Homebrew")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Homebrew installation failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _macos_guide_manual_install():
|
||||
"""Guide the user through manual installation of Opus libraries."""
|
||||
print("\n" + "=" * 70)
|
||||
print("📚 Manual Installation Guide (macOS)")
|
||||
print("=" * 70)
|
||||
print("We could not auto-install the Opus libraries. Please install them")
|
||||
print("using one of the following methods:")
|
||||
print("")
|
||||
print("Method 1: Install Homebrew (if not installed)")
|
||||
print(" /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"")
|
||||
print(" Then: brew install opus opusfile libogg")
|
||||
print("")
|
||||
print("Method 2: Install MacPorts")
|
||||
print(" https://www.macports.org/install.php")
|
||||
print(" Then: sudo port install opus opusfile libogg")
|
||||
print("")
|
||||
print("Method 3: Compile from source")
|
||||
print(" Download from https://opus-codec.org/downloads/")
|
||||
print(" opus-1.6.1.tar.gz, opusfile-0.12.tar.gz, libogg-1.3.6.tar.gz")
|
||||
print(" Compile each with: ./configure && make && sudo make install")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
|
||||
def _macos_auto_install():
|
||||
"""Try to auto-install Opus libraries on macOS.
|
||||
|
||||
Order: detect system -> MacPorts -> Homebrew -> manual guide.
|
||||
"""
|
||||
_macos_apology()
|
||||
|
||||
# Step 1: Detect if already present
|
||||
if _macos_detect_system():
|
||||
print("✅ Opus libraries already present on system")
|
||||
return True
|
||||
|
||||
# Step 2: Try MacPorts
|
||||
print("\n📦 Attempting MacPorts installation...")
|
||||
if _macos_install_macports():
|
||||
return True
|
||||
|
||||
# Step 3: Try Homebrew
|
||||
print("\n📦 Attempting Homebrew installation...")
|
||||
if _macos_install_homebrew():
|
||||
return True
|
||||
|
||||
# Step 4: Guide manual installation
|
||||
_macos_guide_manual_install()
|
||||
return False
|
||||
|
||||
|
||||
def _check_opus_libraries_exist_linux(directory):
|
||||
"""Check if Opus .so libraries exist in directory (Linux)."""
|
||||
return os.path.exists(os.path.join(directory, "libopusfile.so"))
|
||||
|
||||
|
||||
def verify_file_hash(file_path, expected_hash):
|
||||
"""Verify the SHA256 hash of a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to verify
|
||||
expected_hash: Expected SHA256 hash (hex string)
|
||||
|
||||
Returns:
|
||||
bool: True if hash matches (or no hash configured)
|
||||
"""
|
||||
if not expected_hash:
|
||||
print(f" ⚠️ No hash configured for {os.path.basename(file_path)}, skipping verification")
|
||||
return True
|
||||
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
content = f.read()
|
||||
file_hash = hashlib.sha256(content).hexdigest()
|
||||
print(f" Existing file SHA256: {file_hash}")
|
||||
if file_hash.lower() == expected_hash.lower():
|
||||
print(f" ✅ Hash verification passed")
|
||||
return True
|
||||
else:
|
||||
print(f" ❌ Hash verification failed! Expected: {expected_hash}, Got: {file_hash}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ❌ Error verifying hash: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def download_opus_libraries():
|
||||
"""Download all Opus DLLs to the package directory with auto-download and hash verification.
|
||||
|
||||
Returns:
|
||||
bool: True if all DLLs downloaded successfully
|
||||
"""
|
||||
current_dir = _get_package_dir()
|
||||
print(f"Package directory: {current_dir}")
|
||||
|
||||
def download_file(url, filename, expected_hash=None):
|
||||
"""Download a single file with SSL fallback and hash verification."""
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False)
|
||||
temp_file.close()
|
||||
try:
|
||||
print(f" Downloading {filename} from {url}...")
|
||||
try:
|
||||
# Try with SSL verification first
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
content = response.read()
|
||||
print(f" ✅ Download successful with SSL verification")
|
||||
except (urllib.error.URLError, ssl.SSLError) as e:
|
||||
print(f" SSL verification failed: {e}")
|
||||
print(f" Retrying without SSL verification...")
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=30, context=context) as response:
|
||||
content = response.read()
|
||||
print(f" ✅ Download successful without SSL verification")
|
||||
|
||||
with open(temp_file.name, 'wb') as f:
|
||||
f.write(content)
|
||||
print(f" Downloaded {len(content)} bytes")
|
||||
|
||||
# Verify hash before moving
|
||||
if expected_hash:
|
||||
file_hash = hashlib.sha256(content).hexdigest()
|
||||
print(f" Downloaded file SHA256: {file_hash}")
|
||||
if file_hash.lower() != expected_hash.lower():
|
||||
print(f" ❌ Hash verification failed! Expected: {expected_hash}, Got: {file_hash}")
|
||||
try:
|
||||
os.unlink(temp_file.name)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
print(f" ✅ Hash verification passed")
|
||||
|
||||
# Move to package directory
|
||||
dest_path = os.path.join(current_dir, filename)
|
||||
shutil.move(temp_file.name, dest_path)
|
||||
print(f" ✅ Saved to {dest_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ❌ Download failed for {filename}: {e}")
|
||||
try:
|
||||
os.unlink(temp_file.name)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
# Download each DLL with hash verification
|
||||
success_count = 0
|
||||
for dll_info in OPUS_DLL_FILES:
|
||||
filename = dll_info["filename"]
|
||||
expected_hash = OPUS_DLL_HASHES.get(filename)
|
||||
file_path = os.path.join(current_dir, filename)
|
||||
if os.path.exists(file_path):
|
||||
print(f"\n📁 {filename} already exists, verifying hash...")
|
||||
if verify_file_hash(file_path, expected_hash):
|
||||
print(f"✅ {filename} is valid, skipping download")
|
||||
success_count += 1
|
||||
continue
|
||||
else:
|
||||
print(f"⚠️ {filename} hash mismatch, re-downloading...")
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"\n📥 Downloading {filename}...")
|
||||
if download_file(dll_info["url"], filename, expected_hash):
|
||||
success_count += 1
|
||||
|
||||
print(f"\n✅ Downloaded {success_count}/{len(OPUS_DLL_FILES)} DLLs")
|
||||
return success_count == len(OPUS_DLL_FILES)
|
||||
|
||||
|
||||
def import_opus():
|
||||
"""Main function: Import Opus libraries with cross-platform support.
|
||||
|
||||
Returns:
|
||||
bool: True if Opus libraries loaded successfully
|
||||
"""
|
||||
global opusfile
|
||||
|
||||
# Already loaded?
|
||||
if opusfile is not None:
|
||||
return True
|
||||
|
||||
current_dir = _get_package_dir()
|
||||
platform = sys.platform
|
||||
|
||||
# Windows: use DLL + auto-download
|
||||
if platform == "win32":
|
||||
# Layer 1: Load from current directory
|
||||
if _load_from_directory(current_dir):
|
||||
return True
|
||||
# Layer 2: Load from system
|
||||
if _load_from_system():
|
||||
return True
|
||||
# Layer 3: Auto-download DLLs
|
||||
print("Opus DLLs not found, downloading...")
|
||||
if download_opus_libraries():
|
||||
if _load_from_directory(current_dir):
|
||||
print("✅ Opus DLLs loaded after download")
|
||||
return True
|
||||
|
||||
# Linux: use system .so libraries (reference _sdl2.py)
|
||||
# NOTE: The package directory contains Windows .dll files only.
|
||||
# Linux Opus libraries (.so) are installed via system package manager.
|
||||
elif platform.startswith("linux"):
|
||||
# Layer 1: User config (custom .so paths)
|
||||
if _load_user_config():
|
||||
print("✅ Opus loaded from user config")
|
||||
return True
|
||||
# Layer 2: System libraries
|
||||
if _load_from_system():
|
||||
print("✅ Opus loaded from system")
|
||||
return True
|
||||
# Layer 3: Auto install via package manager
|
||||
if _linux_auto_install():
|
||||
return True
|
||||
# Layer 4: Interactive setup
|
||||
if _linux_interactive_setup():
|
||||
return True
|
||||
|
||||
# macOS: no precompiled Opus framework, use package manager
|
||||
# Order: detect system -> MacPorts -> Homebrew -> manual guide
|
||||
elif platform == "darwin":
|
||||
if _macos_auto_install():
|
||||
return True
|
||||
|
||||
global _opus_dll_error
|
||||
if not _opus_dll_error:
|
||||
_opus_dll_error = "Failed to load Opus libraries"
|
||||
return False
|
||||
|
||||
|
||||
def check_opus_dll():
|
||||
"""Check whether the Opus DLL can be loaded normally.
|
||||
|
||||
Returns:
|
||||
(bool, str): (whether normal, error message)
|
||||
"""
|
||||
if not import_opus():
|
||||
return (False, _opus_dll_error or "Opus DLL load failed")
|
||||
try:
|
||||
if not hasattr(opusfile, 'op_open_file'):
|
||||
return (False, "libopusfile-0.dll not loaded correctly (missing op_open_file)")
|
||||
return (True, "Opus DLL OK")
|
||||
except Exception as e:
|
||||
return (False, f"Opus DLL check failed: {e}")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Opus structures
|
||||
# ============================================================
|
||||
|
||||
class OpusHead(Structure):
|
||||
_fields_ = [
|
||||
("version", c_int),
|
||||
("channel_count", c_int),
|
||||
("pre_skip", c_uint),
|
||||
("input_sample_rate", c_uint),
|
||||
("output_gain", c_int),
|
||||
("mapping_family", c_int),
|
||||
("stream_count", c_int),
|
||||
("coupled_count", c_int),
|
||||
("mapping", c_ubyte * 255),
|
||||
]
|
||||
|
||||
|
||||
class OpusTags(Structure):
|
||||
_fields_ = [
|
||||
("user_comments", POINTER(c_char_p)),
|
||||
("comment_lengths", POINTER(c_int)),
|
||||
("comments", c_int),
|
||||
("vendor", c_char_p),
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Windows Wave API structures and constants
|
||||
# ============================================================
|
||||
|
||||
WAVE_FORMAT_PCM = 1
|
||||
WAVE_MAPPER = 0xFFFFFFFF
|
||||
CALLBACK_EVENT = 0x00050000
|
||||
WHDR_DONE = 0x1
|
||||
MMSYSERR_NOERROR = 0
|
||||
WAIT_OBJECT_0 = 0
|
||||
|
||||
|
||||
class WAVEFORMATEX(Structure):
|
||||
_fields_ = [
|
||||
("wFormatTag", wt.WORD),
|
||||
("nChannels", wt.WORD),
|
||||
("nSamplesPerSec", wt.DWORD),
|
||||
("nAvgBytesPerSec", wt.DWORD),
|
||||
("nBlockAlign", wt.WORD),
|
||||
("wBitsPerSample", wt.WORD),
|
||||
("cbSize", wt.WORD),
|
||||
]
|
||||
|
||||
|
||||
class WAVEHDR(Structure):
|
||||
pass
|
||||
WAVEHDR._fields_ = [
|
||||
("lpData", wt.LPSTR),
|
||||
("dwBufferLength", wt.DWORD),
|
||||
("dwBytesRecorded", wt.DWORD),
|
||||
("dwUser", c_void_p), # DWORD_PTR (8 bytes)
|
||||
("dwFlags", wt.DWORD),
|
||||
("dwLoops", wt.DWORD),
|
||||
("lpNext", POINTER(WAVEHDR)),
|
||||
("reserved", c_void_p), # DWORD_PTR (8 bytes)
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Opus file function bindings (wrapper functions)
|
||||
# ============================================================
|
||||
|
||||
def op_open_file(path, error):
|
||||
"""Open an Opus file. Returns handle or None."""
|
||||
return opusfile.op_open_file(path, error)
|
||||
|
||||
|
||||
def op_free(of):
|
||||
"""Free an Opus file handle."""
|
||||
opusfile.op_free(of)
|
||||
|
||||
|
||||
def op_head(of, li):
|
||||
"""Get OpusHead for a link."""
|
||||
return opusfile.op_head(of, li)
|
||||
|
||||
|
||||
def op_tags(of, li):
|
||||
"""Get OpusTags for a link."""
|
||||
return opusfile.op_tags(of, li)
|
||||
|
||||
|
||||
def op_channel_count(of, li):
|
||||
"""Get channel count."""
|
||||
return opusfile.op_channel_count(of, li)
|
||||
|
||||
|
||||
def op_pcm_total(of, li):
|
||||
"""Get total PCM samples."""
|
||||
return opusfile.op_pcm_total(of, li)
|
||||
|
||||
|
||||
def op_bitrate(of, li):
|
||||
"""Get average bitrate."""
|
||||
return opusfile.op_bitrate(of, li)
|
||||
|
||||
|
||||
def op_seekable(of):
|
||||
"""Check if stream is seekable."""
|
||||
return opusfile.op_seekable(of)
|
||||
|
||||
|
||||
def op_link_count(of):
|
||||
"""Get number of links."""
|
||||
return opusfile.op_link_count(of)
|
||||
|
||||
|
||||
def op_read_stereo(of, pcm, buf_size):
|
||||
"""Read decoded stereo PCM."""
|
||||
return opusfile.op_read_stereo(of, pcm, buf_size)
|
||||
|
||||
|
||||
def op_pcm_seek(of, pos):
|
||||
"""Seek to PCM sample position."""
|
||||
return opusfile.op_pcm_seek(of, pos)
|
||||
|
||||
|
||||
def op_pcm_tell(of):
|
||||
"""Get current PCM position."""
|
||||
return opusfile.op_pcm_tell(of)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Windows Wave API function bindings
|
||||
# ============================================================
|
||||
|
||||
def waveOutOpen(phwo, device_id, fmt, callback, instance, flags):
|
||||
return winmm.waveOutOpen(phwo, device_id, fmt, callback, instance, flags)
|
||||
|
||||
|
||||
def waveOutPrepareHeader(hwo, pwh, cbwh):
|
||||
return winmm.waveOutPrepareHeader(hwo, pwh, cbwh)
|
||||
|
||||
|
||||
def waveOutWrite(hwo, pwh, cbwh):
|
||||
return winmm.waveOutWrite(hwo, pwh, cbwh)
|
||||
|
||||
|
||||
def waveOutUnprepareHeader(hwo, pwh, cbwh):
|
||||
return winmm.waveOutUnprepareHeader(hwo, pwh, cbwh)
|
||||
|
||||
|
||||
def waveOutClose(hwo):
|
||||
return winmm.waveOutClose(hwo)
|
||||
|
||||
|
||||
def waveOutSetVolume(hwo, volume):
|
||||
return winmm.waveOutSetVolume(hwo, volume)
|
||||
|
||||
|
||||
def waveOutGetVolume(hwo, volume):
|
||||
return winmm.waveOutGetVolume(hwo, volume)
|
||||
|
||||
|
||||
def waveOutPause(hwo):
|
||||
return winmm.waveOutPause(hwo)
|
||||
|
||||
|
||||
def waveOutRestart(hwo):
|
||||
return winmm.waveOutRestart(hwo)
|
||||
|
||||
|
||||
def waveOutReset(hwo):
|
||||
return winmm.waveOutReset(hwo)
|
||||
|
||||
|
||||
def waveOutGetErrorTextW(code, buf, size):
|
||||
return winmm.waveOutGetErrorTextW(code, buf, size)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Kernel32 function bindings
|
||||
# ============================================================
|
||||
|
||||
def CreateEventW(lp_attrs, b_manual, b_initial, name):
|
||||
return kernel32.CreateEventW(lp_attrs, b_manual, b_initial, name)
|
||||
|
||||
|
||||
def WaitForSingleObject(handle, ms):
|
||||
return kernel32.WaitForSingleObject(handle, ms)
|
||||
|
||||
|
||||
def ResetEvent(handle):
|
||||
return kernel32.ResetEvent(handle)
|
||||
|
||||
|
||||
def CloseHandle(handle):
|
||||
return kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Function prototypes (set after libraries are loaded)
|
||||
# ============================================================
|
||||
|
||||
def _setup_prototypes():
|
||||
"""Set up function prototypes for opusfile, winmm, and kernel32."""
|
||||
# --- opusfile prototypes ---
|
||||
opusfile.op_open_file.restype = c_void_p
|
||||
opusfile.op_open_file.argtypes = [c_char_p, POINTER(c_int)]
|
||||
opusfile.op_free.restype = None
|
||||
opusfile.op_free.argtypes = [c_void_p]
|
||||
opusfile.op_head.restype = POINTER(OpusHead)
|
||||
opusfile.op_head.argtypes = [c_void_p, c_int]
|
||||
opusfile.op_tags.restype = POINTER(OpusTags)
|
||||
opusfile.op_tags.argtypes = [c_void_p, c_int]
|
||||
opusfile.op_channel_count.restype = c_int
|
||||
opusfile.op_channel_count.argtypes = [c_void_p, c_int]
|
||||
opusfile.op_pcm_total.restype = c_longlong
|
||||
opusfile.op_pcm_total.argtypes = [c_void_p, c_int]
|
||||
opusfile.op_bitrate.restype = c_int
|
||||
opusfile.op_bitrate.argtypes = [c_void_p, c_int]
|
||||
opusfile.op_seekable.restype = c_int
|
||||
opusfile.op_seekable.argtypes = [c_void_p]
|
||||
opusfile.op_link_count.restype = c_int
|
||||
opusfile.op_link_count.argtypes = [c_void_p]
|
||||
opusfile.op_read_stereo.restype = c_int
|
||||
opusfile.op_read_stereo.argtypes = [c_void_p, POINTER(c_int16), c_int]
|
||||
opusfile.op_pcm_seek.restype = c_int
|
||||
opusfile.op_pcm_seek.argtypes = [c_void_p, c_longlong]
|
||||
opusfile.op_pcm_tell.restype = c_longlong
|
||||
opusfile.op_pcm_tell.argtypes = [c_void_p]
|
||||
|
||||
# --- winmm prototypes (Windows only) ---
|
||||
if winmm is None:
|
||||
return
|
||||
winmm.waveOutOpen.restype = wt.DWORD
|
||||
winmm.waveOutOpen.argtypes = [
|
||||
POINTER(wt.HANDLE), wt.UINT, POINTER(WAVEFORMATEX),
|
||||
wt.DWORD, wt.DWORD, wt.DWORD]
|
||||
winmm.waveOutPrepareHeader.restype = wt.DWORD
|
||||
winmm.waveOutPrepareHeader.argtypes = [wt.HANDLE, POINTER(WAVEHDR), wt.UINT]
|
||||
winmm.waveOutWrite.restype = wt.DWORD
|
||||
winmm.waveOutWrite.argtypes = [wt.HANDLE, POINTER(WAVEHDR), wt.UINT]
|
||||
winmm.waveOutUnprepareHeader.restype = wt.DWORD
|
||||
winmm.waveOutUnprepareHeader.argtypes = [wt.HANDLE, POINTER(WAVEHDR), wt.UINT]
|
||||
winmm.waveOutClose.restype = wt.DWORD
|
||||
winmm.waveOutClose.argtypes = [wt.HANDLE]
|
||||
winmm.waveOutSetVolume.restype = wt.DWORD
|
||||
winmm.waveOutSetVolume.argtypes = [wt.HANDLE, wt.DWORD]
|
||||
winmm.waveOutGetVolume.restype = wt.DWORD
|
||||
winmm.waveOutGetVolume.argtypes = [wt.HANDLE, POINTER(wt.DWORD)]
|
||||
winmm.waveOutPause.restype = wt.DWORD
|
||||
winmm.waveOutPause.argtypes = [wt.HANDLE]
|
||||
winmm.waveOutRestart.restype = wt.DWORD
|
||||
winmm.waveOutRestart.argtypes = [wt.HANDLE]
|
||||
winmm.waveOutReset.restype = wt.DWORD
|
||||
winmm.waveOutReset.argtypes = [wt.HANDLE]
|
||||
winmm.waveOutGetErrorTextW.restype = wt.DWORD
|
||||
winmm.waveOutGetErrorTextW.argtypes = [wt.DWORD, wt.LPWSTR, wt.UINT]
|
||||
|
||||
# --- kernel32 prototypes ---
|
||||
kernel32.CreateEventW.restype = wt.HANDLE
|
||||
kernel32.CreateEventW.argtypes = [c_void_p, wt.BOOL, wt.BOOL, wt.LPCWSTR]
|
||||
kernel32.WaitForSingleObject.restype = wt.DWORD
|
||||
kernel32.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD]
|
||||
kernel32.ResetEvent.restype = wt.BOOL
|
||||
kernel32.ResetEvent.argtypes = [wt.HANDLE]
|
||||
kernel32.CloseHandle.restype = wt.BOOL
|
||||
kernel32.CloseHandle.argtypes = [wt.HANDLE]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Initialize: Load system DLLs and Opus DLLs
|
||||
# ============================================================
|
||||
|
||||
# Windows-specific system DLLs (winmm/kernel32) are only available on Windows.
|
||||
# On Linux/macOS, these are set to None; Opus decoding still works via opusfile,
|
||||
# but waveOut playback is Windows-only.
|
||||
if sys.platform == "win32":
|
||||
winmm = ctypes.WinDLL("winmm")
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
else:
|
||||
winmm = None
|
||||
kernel32 = None
|
||||
|
||||
# Load Opus libraries (with auto-download on Windows, system .so on Linux)
|
||||
if import_opus():
|
||||
_setup_prototypes()
|
||||
+1053
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
__version__ = "0.0.1a3"
|
||||
@@ -0,0 +1,693 @@
|
||||
"""
|
||||
audio_parser.py - Format-Specific Audio Metadata Parsers
|
||||
|
||||
This module provides pure-Python parsers for extracting metadata (duration,
|
||||
sample rate, channels, bitrate) from various audio formats without external
|
||||
dependencies.
|
||||
|
||||
Supported formats:
|
||||
- WAV: 100% accuracy (RIFF chunk parsing)
|
||||
- FLAC: 100% accuracy (STREAMINFO block)
|
||||
- MP3: >98% accuracy (frame-by-frame scanning)
|
||||
- AAC: >99% accuracy (ADTS frame parsing)
|
||||
- OGG Vorbis: 99.99% accuracy (granule position)
|
||||
|
||||
Python 3.14/3.15 optimizations:
|
||||
- Batch parsing uses ProcessPoolExecutor for true parallelism
|
||||
- Runtime mode detection: Warns users when running with GIL enabled
|
||||
|
||||
Environment variables:
|
||||
AP_DS_SUPPRESS_WARNINGS=1 - Suppress GIL warning
|
||||
AP_DS_SHOW_CONGRATS=0 - Hide "GIL disabled" congratulations message
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import struct
|
||||
import io
|
||||
import warnings
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from typing import List, Dict, Optional, Union, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
# Error codes (mirror player.py values; defined locally to avoid circular import)
|
||||
AP_DS_ERR_UNKNOWN = 1999
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Runtime Environment Detection
|
||||
# ============================================================
|
||||
|
||||
SUPPRESS_WARNINGS = os.environ.get('AP_DS_SUPPRESS_WARNINGS', '').lower() in ('1', 'true', 'yes', 'on')
|
||||
SHOW_CONGRATS = os.environ.get('AP_DS_SHOW_CONGRATS', '').lower() not in ('0', 'false', 'no', 'off')
|
||||
|
||||
_RUNTIME_CHECKED = False
|
||||
|
||||
|
||||
def _check_runtime_mode():
|
||||
"""
|
||||
Detect GIL status and notify the user accordingly.
|
||||
"""
|
||||
try:
|
||||
gil_enabled = sys._is_gil_enabled()
|
||||
except AttributeError:
|
||||
gil_enabled = True # Pre-3.14 always has GIL
|
||||
|
||||
if not gil_enabled:
|
||||
if SHOW_CONGRATS:
|
||||
print("🎉 ap_ds: GIL disabled (free-threading mode)")
|
||||
else:
|
||||
if not SUPPRESS_WARNINGS:
|
||||
warnings.warn(
|
||||
"⚠️ ap_ds: GIL is enabled (multi-core parallelism limited).\n"
|
||||
" For full performance, upgrade to Python 3.15t:\n"
|
||||
" https://mirrors.huaweicloud.com/python/3.15.0/python-3.15.0b4t-amd64.zip\n"
|
||||
" To suppress this warning, set AP_DS_SUPPRESS_WARNINGS=1",
|
||||
RuntimeWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
return gil_enabled
|
||||
|
||||
|
||||
def _ensure_runtime_checked():
|
||||
"""Ensure runtime mode check is performed only once per process."""
|
||||
global _RUNTIME_CHECKED
|
||||
if not _RUNTIME_CHECKED:
|
||||
_check_runtime_mode()
|
||||
_RUNTIME_CHECKED = True
|
||||
|
||||
|
||||
_ensure_runtime_checked()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Core Data Structures
|
||||
# ============================================================
|
||||
|
||||
class StreamInfo:
|
||||
"""
|
||||
Container for audio stream metadata.
|
||||
|
||||
Attributes:
|
||||
length (float): Duration in seconds
|
||||
sample_rate (int): Sample rate in Hz
|
||||
channels (int): Number of audio channels (1=mono, 2=stereo)
|
||||
bitrate (int): Bitrate in bits per second
|
||||
"""
|
||||
__slots__ = ("length", "sample_rate", "channels", "bitrate")
|
||||
|
||||
def __init__(self, length, sample_rate, channels, bitrate):
|
||||
self.length = float(length)
|
||||
self.sample_rate = int(sample_rate)
|
||||
self.channels = int(channels)
|
||||
self.bitrate = int(bitrate)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<StreamInfo length={self.length:.6f}s "
|
||||
f"rate={self.sample_rate}Hz "
|
||||
f"channels={self.channels} "
|
||||
f"bitrate={self.bitrate}bps>"
|
||||
)
|
||||
|
||||
|
||||
class FileType:
|
||||
"""
|
||||
Base class for format-specific parsers.
|
||||
|
||||
Each subclass must implement _parse() to return a StreamInfo object.
|
||||
"""
|
||||
__slots__ = ("filename", "info")
|
||||
|
||||
def __init__(self, filename):
|
||||
self.filename = filename
|
||||
self.info = self._parse()
|
||||
|
||||
def _parse(self):
|
||||
raise ValueError("Invalid audio file")
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
return self.info.length
|
||||
|
||||
@property
|
||||
def sample_rate(self):
|
||||
return self.info.sample_rate
|
||||
|
||||
@property
|
||||
def channels(self):
|
||||
return self.info.channels
|
||||
|
||||
@property
|
||||
def bitrate(self):
|
||||
return self.info.bitrate
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Utility Functions
|
||||
# ============================================================
|
||||
|
||||
def open_file(path):
|
||||
"""Open a file in binary read mode."""
|
||||
return open(path, "rb")
|
||||
|
||||
|
||||
def read_u32_be(f):
|
||||
"""Read a big-endian 32-bit unsigned integer from a file."""
|
||||
return struct.unpack(">I", f.read(4))[0]
|
||||
|
||||
|
||||
def read_u32_le(f):
|
||||
"""Read a little-endian 32-bit unsigned integer from a file."""
|
||||
return struct.unpack("<I", f.read(4))[0]
|
||||
|
||||
|
||||
def read_u16_le(f):
|
||||
"""Read a little-endian 16-bit unsigned integer from a file."""
|
||||
return struct.unpack("<H", f.read(2))[0]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# WAV Parser (100% accuracy)
|
||||
# ============================================================
|
||||
|
||||
class WAVFile(FileType):
|
||||
"""
|
||||
WAV audio parser using RIFF chunk structure.
|
||||
|
||||
Extracts format information from the 'fmt ' chunk and data size from
|
||||
the 'data' chunk. Computes duration from total frames and sample rate.
|
||||
Accuracy: 100% (based on file structure, no heuristics).
|
||||
"""
|
||||
def _parse(self):
|
||||
with open_file(self.filename) as f:
|
||||
if f.read(4) != b"RIFF":
|
||||
raise ValueError
|
||||
f.read(4)
|
||||
if f.read(4) != b"WAVE":
|
||||
raise ValueError
|
||||
|
||||
sample_rate = channels = block_align = data_size = None
|
||||
|
||||
while True:
|
||||
chunk = f.read(4)
|
||||
if not chunk:
|
||||
break
|
||||
size = read_u32_le(f)
|
||||
|
||||
if chunk == b"fmt ":
|
||||
fmt = f.read(size)
|
||||
channels = struct.unpack("<H", fmt[2:4])[0]
|
||||
sample_rate = struct.unpack("<I", fmt[4:8])[0]
|
||||
block_align = struct.unpack("<H", fmt[12:14])[0]
|
||||
elif chunk == b"data":
|
||||
data_size = size
|
||||
break
|
||||
else:
|
||||
f.seek(size, io.SEEK_CUR)
|
||||
|
||||
total_frames = data_size // block_align
|
||||
length = total_frames / sample_rate
|
||||
bitrate = sample_rate * block_align * 8 // channels
|
||||
|
||||
return StreamInfo(length, sample_rate, channels, bitrate)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# FLAC Parser (100% accuracy)
|
||||
# ============================================================
|
||||
|
||||
class FLACFile(FileType):
|
||||
"""
|
||||
FLAC audio parser reading the STREAMINFO metadata block.
|
||||
|
||||
The STREAMINFO block is mandatory in all FLAC files and contains
|
||||
sample rate, channel count, and total samples. Accuracy: 100%.
|
||||
"""
|
||||
def _parse(self):
|
||||
with open_file(self.filename) as f:
|
||||
if f.read(4) != b"fLaC":
|
||||
raise ValueError
|
||||
|
||||
while True:
|
||||
header = f.read(4)
|
||||
is_last = header[0] & 0x80
|
||||
block_type = header[0] & 0x7F
|
||||
size = struct.unpack(">I", b"\x00" + header[1:4])[0]
|
||||
|
||||
if block_type == 0: # STREAMINFO
|
||||
data = f.read(size)
|
||||
sample_rate = (
|
||||
(data[10] << 12)
|
||||
| (data[11] << 4)
|
||||
| (data[12] >> 4)
|
||||
)
|
||||
channels = ((data[12] >> 1) & 0x07) + 1
|
||||
total_samples = (
|
||||
((data[13] & 0x0F) << 32)
|
||||
| (data[14] << 24)
|
||||
| (data[15] << 16)
|
||||
| (data[16] << 8)
|
||||
| data[17]
|
||||
)
|
||||
length = total_samples / sample_rate
|
||||
bitrate = os.path.getsize(self.filename) * 8 / length
|
||||
return StreamInfo(length, sample_rate, channels, bitrate)
|
||||
else:
|
||||
f.seek(size, io.SEEK_CUR)
|
||||
|
||||
if is_last:
|
||||
break
|
||||
|
||||
raise ValueError
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MP3 Parser (frame-by-frame scanning, >98% accuracy)
|
||||
# ============================================================
|
||||
|
||||
# MP3 bitrate lookup table (indexed by header bits)
|
||||
MP3_BITRATES = [
|
||||
None, 32, 40, 48, 56, 64, 80, 96,
|
||||
112, 128, 160, 192, 224, 256, 320, None
|
||||
]
|
||||
|
||||
# MP3 sample rate lookup table (indexed by header bits)
|
||||
MP3_SAMPLE_RATES = [44100, 48000, 32000, None]
|
||||
|
||||
|
||||
class MP3File(FileType):
|
||||
"""
|
||||
MP3 audio parser using frame-by-frame scanning.
|
||||
|
||||
Scans the file for MP3 frame sync words (0xFF), counts frames, and
|
||||
accumulates samples. Accuracy: >98% (limited by variable bitrate
|
||||
and incomplete final frames).
|
||||
"""
|
||||
def _parse(self):
|
||||
filesize = os.path.getsize(self.filename)
|
||||
total_frames = 0
|
||||
|
||||
with open_file(self.filename) as f:
|
||||
while True:
|
||||
b = f.read(1)
|
||||
if not b:
|
||||
break
|
||||
if b != b"\xff":
|
||||
continue
|
||||
|
||||
hdr = f.read(3)
|
||||
if len(hdr) < 3:
|
||||
break
|
||||
if hdr[0] & 0xE0 != 0xE0:
|
||||
f.seek(-3, 1)
|
||||
continue
|
||||
|
||||
bitrate = MP3_BITRATES[(hdr[1] >> 4) & 0x0F]
|
||||
sample_rate = MP3_SAMPLE_RATES[(hdr[1] >> 2) & 0x03]
|
||||
if not bitrate or not sample_rate:
|
||||
f.seek(-3, 1)
|
||||
continue
|
||||
|
||||
frame_len = int(144000 * bitrate / sample_rate)
|
||||
total_frames += 1
|
||||
f.seek(frame_len - 4, 1)
|
||||
|
||||
length = total_frames * 1152 / sample_rate
|
||||
bitrate = filesize * 8 / length
|
||||
|
||||
return StreamInfo(length, sample_rate, 2, bitrate)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# AAC (ADTS) Parser (frame-by-frame, >99% accuracy)
|
||||
# ============================================================
|
||||
|
||||
AAC_SAMPLE_RATES = [
|
||||
96000, 88200, 64000, 48000, 44100, 32000,
|
||||
24000, 22050, 16000, 12000, 11025, 8000
|
||||
]
|
||||
|
||||
|
||||
class AACFile(FileType):
|
||||
"""
|
||||
AAC audio parser using ADTS (Audio Data Transport Stream) frame parsing.
|
||||
|
||||
Scans for ADTS sync words (0xFFF), parses frame headers to accumulate
|
||||
samples. Each AAC frame contains 1024 samples. Accuracy: >99%.
|
||||
"""
|
||||
def _parse(self):
|
||||
total_samples = 0
|
||||
|
||||
with open_file(self.filename) as f:
|
||||
while True:
|
||||
header = f.read(7)
|
||||
if len(header) < 7:
|
||||
break
|
||||
if header[0] != 0xFF or (header[1] & 0xF0) != 0xF0:
|
||||
break
|
||||
|
||||
sr = AAC_SAMPLE_RATES[(header[2] >> 2) & 0x0F]
|
||||
channels = ((header[2] & 1) << 2) | ((header[3] >> 6) & 3)
|
||||
frame_length = (
|
||||
((header[3] & 0x03) << 11)
|
||||
| (header[4] << 3)
|
||||
| (header[5] >> 5)
|
||||
)
|
||||
|
||||
total_samples += 1024
|
||||
f.seek(frame_length - 7, 1)
|
||||
|
||||
length = total_samples / sr
|
||||
bitrate = os.path.getsize(self.filename) * 8 / length
|
||||
|
||||
return StreamInfo(length, sr, channels, bitrate)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# OGG Vorbis Parser (granule position, 99.99% accuracy)
|
||||
# ============================================================
|
||||
|
||||
class OGGFile(FileType):
|
||||
"""
|
||||
OGG Vorbis audio parser using granule position.
|
||||
|
||||
Reads Ogg pages, extracts the granule position (total samples) from
|
||||
the last page. Also parses the Vorbis identification header for
|
||||
sample rate and channel count. Accuracy: 99.99%.
|
||||
"""
|
||||
def _parse(self):
|
||||
filesize = os.path.getsize(self.filename)
|
||||
|
||||
with open_file(self.filename) as f:
|
||||
sample_rate = channels = None
|
||||
last_granule = 0
|
||||
|
||||
while True:
|
||||
header = f.read(27)
|
||||
if len(header) < 27:
|
||||
break
|
||||
if header[:4] != b"OggS":
|
||||
break
|
||||
|
||||
granule = struct.unpack("<Q", header[6:14])[0]
|
||||
last_granule = max(last_granule, granule)
|
||||
|
||||
seg_count = header[26]
|
||||
seg_sizes = f.read(seg_count)
|
||||
f.seek(sum(seg_sizes), 1)
|
||||
|
||||
if sample_rate is None:
|
||||
pos = f.tell()
|
||||
f.seek(-sum(seg_sizes), 1)
|
||||
packet = f.read(seg_sizes[0])
|
||||
if packet.startswith(b"\x01vorbis"):
|
||||
channels = packet[11]
|
||||
sample_rate = struct.unpack("<I", packet[12:16])[0]
|
||||
f.seek(pos, 0)
|
||||
|
||||
length = last_granule / sample_rate
|
||||
bitrate = filesize * 8 / length
|
||||
|
||||
return StreamInfo(length, sample_rate, channels, bitrate)
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# OPUS Parser (delegates to opusplayer.py)
|
||||
# ============================================================
|
||||
|
||||
class OPUSFile(FileType):
|
||||
"""
|
||||
OPUS audio parser that delegates to opusplayer.py (OpusAudio engine).
|
||||
|
||||
Opus files are handled by the dedicated Opus engine (libopusfile-based)
|
||||
rather than a pure-Python parser. This class wraps the opusplayer
|
||||
metadata results into the standard StreamInfo interface so that the
|
||||
rest of the audio_parser API (open_audio / get_audio_metadata /
|
||||
get_audio_duration / batch_*) works uniformly for Opus files.
|
||||
|
||||
Accuracy: 100%% (derived from libopusfile stream metadata).
|
||||
"""
|
||||
def _parse(self):
|
||||
# Lazily import opusplayer to avoid forcing Opus DLL loading at
|
||||
# module import time (keeps pure-Python formats dependency-free).
|
||||
try:
|
||||
from . import opusplayer
|
||||
except ImportError:
|
||||
try:
|
||||
import opusplayer
|
||||
except ImportError:
|
||||
raise ValueError("opusplayer not available for Opus parsing")
|
||||
|
||||
meta = opusplayer._get_opus_metadata(self.filename)
|
||||
if not isinstance(meta, dict):
|
||||
raise ValueError("Invalid or unsupported Opus file")
|
||||
|
||||
length = float(meta.get("length", meta.get("duration", 0.0)) or 0.0)
|
||||
sample_rate = int(meta.get("sample_rate", 48000) or 48000)
|
||||
channels = int(meta.get("channels", 2) or 2)
|
||||
bitrate = int(meta.get("bitrate", 0) or 0)
|
||||
if length <= 0:
|
||||
raise ValueError("Invalid Opus duration")
|
||||
return StreamInfo(length, sample_rate, channels, bitrate)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Factory Function
|
||||
# ============================================================
|
||||
|
||||
def open_audio(filename):
|
||||
"""
|
||||
Factory function that returns the appropriate parser instance.
|
||||
|
||||
Args:
|
||||
filename: Path to the audio file
|
||||
|
||||
Returns:
|
||||
FileType: Parser instance (WAVFile, FLACFile, MP3File, AACFile, OGGFile, or OPUSFile)
|
||||
|
||||
Raises:
|
||||
ValueError: If the file format is unsupported
|
||||
"""
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext == ".wav":
|
||||
return WAVFile(filename)
|
||||
if ext == ".flac":
|
||||
return FLACFile(filename)
|
||||
if ext == ".mp3":
|
||||
return MP3File(filename)
|
||||
if ext == ".aac":
|
||||
return AACFile(filename)
|
||||
if ext == ".ogg":
|
||||
return OGGFile(filename)
|
||||
if ext == ".opus":
|
||||
# Opus is handled by opusplayer.py (dedicated Opus engine)
|
||||
return OPUSFile(filename)
|
||||
raise ValueError(f"Unsupported audio format: {ext}")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Single File Parsing
|
||||
# ============================================================
|
||||
|
||||
def _parse_single_file(file_path: str) -> Optional[Dict]:
|
||||
"""
|
||||
Parse a single audio file and return metadata as a dictionary.
|
||||
|
||||
Internal helper for batch operations. Returns None on failure.
|
||||
|
||||
Args:
|
||||
file_path: Path to the audio file
|
||||
|
||||
Returns:
|
||||
dict or None: Metadata dict with keys:
|
||||
path, format, duration, length, sample_rate, channels, bitrate
|
||||
"""
|
||||
try:
|
||||
audio = open_audio(file_path)
|
||||
info = audio.info
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower().lstrip(".")
|
||||
|
||||
return {
|
||||
"path": file_path,
|
||||
"format": ext,
|
||||
"duration": int(info.length),
|
||||
"length": float(info.length),
|
||||
"sample_rate": info.sample_rate,
|
||||
"channels": info.channels,
|
||||
"bitrate": info.bitrate,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Batch Processing API (ProcessPoolExecutor)
|
||||
# ============================================================
|
||||
|
||||
def batch_get_metadata(
|
||||
file_paths: Union[List[str], str],
|
||||
max_workers: Optional[int] = None,
|
||||
show_progress: bool = False
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Parse multiple audio files in parallel using multiprocessing.
|
||||
|
||||
ProcessPoolExecutor avoids file handle contention issues on Windows
|
||||
with free-threading Python builds.
|
||||
|
||||
Args:
|
||||
file_paths: List of file paths, or a single directory path string.
|
||||
If a directory is provided, all supported audio files
|
||||
in that directory are scanned recursively.
|
||||
max_workers: Maximum number of worker processes. Defaults to CPU count.
|
||||
show_progress: If True, prints progress to stdout.
|
||||
|
||||
Returns:
|
||||
List[Dict]: List of metadata dictionaries. Failed parses are omitted.
|
||||
|
||||
Examples:
|
||||
>>> results = batch_get_metadata(["song1.mp3", "song2.flac"])
|
||||
>>> results = batch_get_metadata("/music/playlist/", show_progress=True)
|
||||
"""
|
||||
# If a directory is given, expand to list of files
|
||||
if isinstance(file_paths, (str, Path)):
|
||||
dir_path = Path(file_paths)
|
||||
if dir_path.is_dir():
|
||||
supported_exts = {'.mp3', '.wav', '.flac', '.ogg', '.aac', '.opus'}
|
||||
file_paths = [
|
||||
str(p) for p in dir_path.rglob('*')
|
||||
if p.suffix.lower() in supported_exts and p.is_file()
|
||||
]
|
||||
else:
|
||||
file_paths = [str(file_paths)]
|
||||
|
||||
if not file_paths:
|
||||
return []
|
||||
|
||||
if max_workers is None:
|
||||
max_workers = min(os.cpu_count() or 4, len(file_paths))
|
||||
|
||||
# Invalid max_workers: build the pool and, on failure, return an error
|
||||
# tuple for the caller to handle (the library does not raise).
|
||||
try:
|
||||
executor = ProcessPoolExecutor(max_workers=max_workers)
|
||||
except (ValueError, TypeError) as e:
|
||||
return (AP_DS_ERR_UNKNOWN, f"Invalid max_workers: {e}",
|
||||
"max_workers must be a positive integer or None for automatic")
|
||||
|
||||
results = []
|
||||
total = len(file_paths)
|
||||
completed = 0
|
||||
|
||||
with executor:
|
||||
future_to_path = {
|
||||
executor.submit(_parse_single_file, path): path
|
||||
for path in file_paths
|
||||
}
|
||||
|
||||
for future in as_completed(future_to_path):
|
||||
path = future_to_path[future]
|
||||
completed += 1
|
||||
|
||||
if show_progress and completed % 10 == 0:
|
||||
print(f"Progress: {completed}/{total} files parsed")
|
||||
|
||||
try:
|
||||
metadata = future.result()
|
||||
if metadata:
|
||||
results.append(metadata)
|
||||
else:
|
||||
print(f"⚠️ Parse failed: {os.path.basename(path)}")
|
||||
except Exception as e:
|
||||
print(f"❌ Parse error [{os.path.basename(path)}]: {type(e).__name__}: {e}")
|
||||
|
||||
if show_progress:
|
||||
print(f"✅ Batch parse complete: {len(results)}/{total} files successful")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def batch_get_duration(
|
||||
file_paths: Union[List[str], str],
|
||||
max_workers: Optional[int] = None
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Get durations for multiple audio files in parallel.
|
||||
|
||||
Args:
|
||||
file_paths: List of file paths, or a single directory path string.
|
||||
max_workers: Maximum number of worker processes. Defaults to CPU count.
|
||||
|
||||
Returns:
|
||||
Dict[str, int]: Mapping of file_path -> duration_in_seconds.
|
||||
Files that failed to parse are omitted.
|
||||
|
||||
Examples:
|
||||
>>> durations = batch_get_duration(["song1.mp3", "song2.flac"])
|
||||
>>> print(durations["song1.mp3"]) # 240
|
||||
>>> durations = batch_get_duration("/music/playlist/")
|
||||
"""
|
||||
metadata_list = batch_get_metadata(
|
||||
file_paths,
|
||||
max_workers=max_workers,
|
||||
show_progress=False
|
||||
)
|
||||
return {item["path"]: item["duration"] for item in metadata_list}
|
||||
|
||||
|
||||
def batch_get_metadata_by_type(
|
||||
file_paths: Union[List[str], str],
|
||||
file_type: str,
|
||||
max_workers: Optional[int] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Parse multiple audio files but only return results for a specific format.
|
||||
|
||||
Useful when you only care about MP3 files in a mixed directory.
|
||||
|
||||
Args:
|
||||
file_paths: List of file paths, or a single directory path string.
|
||||
file_type: File extension to filter (e.g., "mp3", "flac")
|
||||
max_workers: Maximum number of worker processes.
|
||||
|
||||
Returns:
|
||||
List[Dict]: Metadata for files matching the specified type.
|
||||
"""
|
||||
file_type = file_type.lower().lstrip(".")
|
||||
all_results = batch_get_metadata(
|
||||
file_paths,
|
||||
max_workers=max_workers,
|
||||
show_progress=False
|
||||
)
|
||||
return [r for r in all_results if r.get("format", "").lower() == file_type]
|
||||
|
||||
def get_audio_duration(file_path: str) -> int:
|
||||
"""Get duration of a single audio file in seconds."""
|
||||
try:
|
||||
audio = open_audio(file_path)
|
||||
return int(audio.length)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def get_audio_metadata(file_path: str) -> Optional[Dict]:
|
||||
"""Get complete metadata for a single audio file."""
|
||||
try:
|
||||
audio = open_audio(file_path)
|
||||
info = audio.info
|
||||
ext = os.path.splitext(file_path)[1].lower().lstrip(".")
|
||||
return {
|
||||
"path": file_path,
|
||||
"format": ext,
|
||||
"duration": int(info.length),
|
||||
"length": float(info.length),
|
||||
"sample_rate": info.sample_rate,
|
||||
"channels": info.channels,
|
||||
"bitrate": info.bitrate,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
+1286
File diff suppressed because it is too large
Load Diff
+1364
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user