Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034a1ec40d |
@@ -1,893 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
============================================================================
|
||||
AP_DS 4.0.1 - 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())
|
||||
+3
-89
@@ -93,7 +93,7 @@ def show_tech_manual() -> None:
|
||||
manual = r"""
|
||||
╔═══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ AP_DS 4.1.0 TECHNICAL MANUAL ║
|
||||
║ AP_DS 4.0.1 TECHNICAL MANUAL ║
|
||||
║ Audio Library By DVS - https://apds.top ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════════╝
|
||||
@@ -125,53 +125,8 @@ audio library for Python applications. Built on SDL2 and SDL2_mixer, it provides
|
||||
│ 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 4.1.0) │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
AP_DS 4.1.0 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 │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
@@ -474,41 +429,12 @@ Configure via: AP_DS_WAV_THRESHOLD environment variable
|
||||
• Supports seeking and fading
|
||||
• Best for long tracks (>=6s)
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 10.4 Opus Error Codes (NEW in 4.1.0) │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
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 4.1.0 (Current)
|
||||
Version 4.0.1 (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 4.0.1
|
||||
─────────────
|
||||
• Python 3.15t free-threading support
|
||||
• Lazy imports for Python 3.15+
|
||||
• DAP O(1) deduplication
|
||||
@@ -539,7 +465,7 @@ Configure via: AP_DS_WAV_THRESHOLD environment variable
|
||||
|
||||
╔═══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ END OF MANUAL ║
|
||||
║ AP_DS 4.1.0 - August 2026 ║
|
||||
║ AP_DS 4.0.1 - December 2025 ║
|
||||
║ ║
|
||||
║ 📖 For detailed Markdown documentation, visit: ║
|
||||
║ https://apds.top ║
|
||||
@@ -724,17 +650,6 @@ try:
|
||||
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)
|
||||
# ============================================================
|
||||
@@ -760,7 +675,6 @@ except Exception:
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"AudioLibrary",
|
||||
"OpusAudio",
|
||||
"get_audio_duration",
|
||||
"get_audio_metadata",
|
||||
"batch_get_metadata",
|
||||
|
||||
@@ -1,946 +0,0 @@
|
||||
# _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()
|
||||
+16
-61
@@ -112,61 +112,6 @@ def _load_user_config():
|
||||
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"""
|
||||
try:
|
||||
@@ -175,24 +120,33 @@ def _linux_auto_install():
|
||||
|
||||
if shutil.which('apt-get'):
|
||||
print("📦 Detected apt-based system (Ubuntu/Debian)")
|
||||
if _run_sudo_command(['apt-get', 'install', '-y'],
|
||||
['libsdl2-dev', 'libsdl2-mixer-dev']):
|
||||
result = subprocess.run(
|
||||
['sudo', 'apt-get', 'install', '-y', 'libsdl2-dev', 'libsdl2-mixer-dev'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
if _load_from_system():
|
||||
print("✅ SDL2 libraries installed and loaded")
|
||||
return True
|
||||
|
||||
elif shutil.which('dnf'):
|
||||
print("📦 Detected dnf-based system (Fedora)")
|
||||
if _run_sudo_command(['dnf', 'install', '-y'],
|
||||
['SDL2-devel', 'SDL2_mixer-devel']):
|
||||
result = subprocess.run(
|
||||
['sudo', 'dnf', 'install', '-y', 'SDL2-devel', 'SDL2_mixer-devel'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
if _load_from_system():
|
||||
print("✅ SDL2 libraries installed and loaded")
|
||||
return True
|
||||
|
||||
elif shutil.which('pacman'):
|
||||
print("📦 Detected pacman-based system (Arch)")
|
||||
if _run_sudo_command(['pacman', '-S', '--noconfirm'],
|
||||
['sdl2', 'sdl2_mixer']):
|
||||
result = subprocess.run(
|
||||
['sudo', 'pacman', '-S', '--noconfirm', 'sdl2', 'sdl2_mixer'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
if _load_from_system():
|
||||
print("✅ SDL2 libraries installed and loaded")
|
||||
return True
|
||||
@@ -201,6 +155,7 @@ def _linux_auto_install():
|
||||
print(f"⚠️ Automatic installation failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _linux_interactive_setup():
|
||||
"""Linux interactive setup for SDL2"""
|
||||
global _sdl_lib, _mix_lib
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "4.1.0rc1"
|
||||
__version__ = "4.0.1"
|
||||
|
||||
-1221
File diff suppressed because it is too large
Load Diff
+6
-212
@@ -140,30 +140,6 @@ except ImportError:
|
||||
print("Warning: audio_parser module not available, using fallback duration methods")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Opus Support Integration
|
||||
# ============================================================
|
||||
|
||||
OPUS_PLAYER_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from .opusplayer import OpusAudio as _OpusAudio
|
||||
OPUS_PLAYER_AVAILABLE = True
|
||||
except ImportError:
|
||||
try:
|
||||
from opusplayer import OpusAudio as _OpusAudio
|
||||
OPUS_PLAYER_AVAILABLE = True
|
||||
except ImportError:
|
||||
_OpusAudio = None
|
||||
print("Warning: opusplayer module not available, Opus playback disabled")
|
||||
|
||||
|
||||
def _is_opus_file(file_path):
|
||||
"""Check if a file is an Opus audio file."""
|
||||
ext = os.path.splitext(str(file_path))[1].lower()
|
||||
return ext == '.opus'
|
||||
|
||||
|
||||
# ============================================================
|
||||
# AudioLibrary Class
|
||||
# ============================================================
|
||||
@@ -197,31 +173,6 @@ class AudioLibrary:
|
||||
self._dap_recordings = [] # List of DAP format recordings
|
||||
self._dap_records_set = set() # O(1) deduplication set
|
||||
|
||||
# Opus playback support
|
||||
self._opus_audio = None # OpusAudio instance (lazy init)
|
||||
self._aid_to_opus_aid = {} # Main AID -> Opus sub-AID mapping
|
||||
|
||||
def _get_opus_player(self):
|
||||
"""Get or create the OpusAudio sub-player instance."""
|
||||
if self._opus_audio is None:
|
||||
if not OPUS_PLAYER_AVAILABLE:
|
||||
return None
|
||||
self._opus_audio = _OpusAudio()
|
||||
return self._opus_audio
|
||||
|
||||
def _map_opus_aid(self, main_aid, opus_aid):
|
||||
"""Map a main library AID to an Opus sub-library AID."""
|
||||
self._aid_to_opus_aid[main_aid] = opus_aid
|
||||
return main_aid
|
||||
|
||||
def _is_opus_aid(self, aid):
|
||||
"""Check if an AID is an Opus AID (has a mapped Opus sub-AID)."""
|
||||
return aid in self._aid_to_opus_aid
|
||||
|
||||
def _get_opus_aid(self, aid):
|
||||
"""Get the Opus sub-AID for a main AID."""
|
||||
return self._aid_to_opus_aid.get(aid)
|
||||
|
||||
def Delay(self, ms):
|
||||
_sdl_lib.SDL_Delay(ms)
|
||||
|
||||
@@ -230,46 +181,16 @@ class AudioLibrary:
|
||||
# ============================================================
|
||||
|
||||
def batch_get_metadata(self, file_paths, max_workers=None, show_progress=False):
|
||||
# Expand paths
|
||||
paths = file_paths if isinstance(file_paths, list) else [file_paths]
|
||||
opus_files = [p for p in paths if _is_opus_file(p)]
|
||||
non_opus_files = [p for p in paths if not _is_opus_file(p)]
|
||||
|
||||
results = []
|
||||
# Opus files -> use opusplayer (ensure player created)
|
||||
opus_player = self._get_opus_player()
|
||||
if opus_files and OPUS_PLAYER_AVAILABLE and opus_player is not None:
|
||||
results.extend(opus_player.batch_get_metadata(opus_files, max_workers, show_progress))
|
||||
# Non-Opus files -> use audio_parser
|
||||
if non_opus_files:
|
||||
if not AUDIO_PARSER_AVAILABLE:
|
||||
raise RuntimeError("audio_parser not available")
|
||||
results.extend(batch_get_metadata(non_opus_files, max_workers, show_progress))
|
||||
return results
|
||||
if not AUDIO_PARSER_AVAILABLE:
|
||||
raise RuntimeError("audio_parser not available")
|
||||
return batch_get_metadata(file_paths, max_workers, show_progress)
|
||||
|
||||
def batch_get_duration(self, file_paths, max_workers=None):
|
||||
# Expand paths
|
||||
paths = file_paths if isinstance(file_paths, list) else [file_paths]
|
||||
opus_files = [p for p in paths if _is_opus_file(p)]
|
||||
non_opus_files = [p for p in paths if not _is_opus_file(p)]
|
||||
|
||||
result = {}
|
||||
# Opus files -> use opusplayer (ensure player created)
|
||||
opus_player = self._get_opus_player()
|
||||
if opus_files and OPUS_PLAYER_AVAILABLE and opus_player is not None:
|
||||
result.update(opus_player.batch_get_duration(opus_files, max_workers))
|
||||
# Non-Opus files -> use audio_parser
|
||||
if non_opus_files:
|
||||
if not AUDIO_PARSER_AVAILABLE:
|
||||
raise RuntimeError("audio_parser not available")
|
||||
result.update(batch_get_duration(non_opus_files, max_workers))
|
||||
return result
|
||||
if not AUDIO_PARSER_AVAILABLE:
|
||||
raise RuntimeError("audio_parser not available")
|
||||
return batch_get_duration(file_paths, max_workers)
|
||||
|
||||
def batch_get_metadata_by_type(self, file_paths, file_type, max_workers=None):
|
||||
# If Opus files requested, use opusplayer (ensure player created)
|
||||
opus_player = self._get_opus_player()
|
||||
if OPUS_PLAYER_AVAILABLE and opus_player is not None and file_type.lower() in ('opus', '.opus'):
|
||||
return opus_player.batch_get_metadata_by_type(file_paths, file_type, max_workers)
|
||||
if not AUDIO_PARSER_AVAILABLE:
|
||||
raise RuntimeError("audio_parser not available")
|
||||
return batch_get_metadata_by_type(file_paths, file_type, max_workers)
|
||||
@@ -283,13 +204,6 @@ class AudioLibrary:
|
||||
Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success,
|
||||
(error_code, error_msg, suggestion) on failure
|
||||
"""
|
||||
# Opus playback: delegate to OpusAudio sub-player
|
||||
if self._is_opus_aid(aid):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module")
|
||||
return opus.play_audio(self._get_opus_aid(aid))
|
||||
|
||||
channel = self._find_channel_by_aid(aid)
|
||||
if channel is None:
|
||||
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded")
|
||||
@@ -339,20 +253,6 @@ class AudioLibrary:
|
||||
self._add_to_dap_recordings(file_path)
|
||||
self._aid_to_filepath[aid] = file_path
|
||||
|
||||
# Opus playback: detect and delegate to OpusAudio sub-player
|
||||
if _is_opus_file(file_path):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Opus support not available for: {file_path}", "Install opusplayer module")
|
||||
opus_result = opus.play_from_file(file_path, loops, start_pos)
|
||||
if isinstance(opus_result, int):
|
||||
# Map main AID to Opus sub-AID
|
||||
self._map_opus_aid(aid, opus_result)
|
||||
return aid
|
||||
else:
|
||||
# Opus playback failed, return the Opus error
|
||||
return opus_result
|
||||
|
||||
# Normal audio file playback (MP3, OGG, FLAC, WAV)
|
||||
# Music file handling
|
||||
if self._is_music_file(file_path):
|
||||
@@ -426,18 +326,6 @@ class AudioLibrary:
|
||||
self._add_to_dap_recordings(file_path)
|
||||
self._aid_to_filepath[aid] = file_path
|
||||
|
||||
# Opus playback: detect and delegate
|
||||
if _is_opus_file(file_path):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Opus support not available for: {file_path}", "Install opusplayer module")
|
||||
opus_result = opus.play_from_file(file_path, loops, start_pos)
|
||||
if isinstance(opus_result, int):
|
||||
self._map_opus_aid(aid, opus_result)
|
||||
return aid
|
||||
else:
|
||||
return opus_result
|
||||
|
||||
# Normal audio file playback
|
||||
if file_path in self._music_cache:
|
||||
if Mix_PlayMusic(self._music_cache[file_path], loops) != 0:
|
||||
@@ -491,16 +379,6 @@ class AudioLibrary:
|
||||
self._add_to_dap_recordings(file_path)
|
||||
self._aid_to_filepath[aid] = file_path
|
||||
|
||||
# Opus: register in opusplayer
|
||||
if _is_opus_file(file_path):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Opus support not available for: {file_path}", "Install opusplayer module")
|
||||
opus_aid = opus.new_aid(file_path)
|
||||
if isinstance(opus_aid, int):
|
||||
self._map_opus_aid(aid, opus_aid)
|
||||
return aid
|
||||
|
||||
# Normal audio file loading
|
||||
if self._is_music_file(file_path):
|
||||
if file_path not in self._music_cache:
|
||||
@@ -533,13 +411,6 @@ class AudioLibrary:
|
||||
Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success,
|
||||
(error_code, error_msg, suggestion) on failure
|
||||
"""
|
||||
# Opus playback: delegate to OpusAudio sub-player
|
||||
if self._is_opus_aid(aid):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module")
|
||||
return opus.fadein_music(self._get_opus_aid(aid), loops, ms)
|
||||
|
||||
# Find AID corresponding music info
|
||||
for channel, info in self._channel_info.items():
|
||||
if info['aid'] == aid and info['is_music']:
|
||||
@@ -594,20 +465,6 @@ class AudioLibrary:
|
||||
if not hasattr(_mix_lib, 'Mix_FadeInMusicPos'):
|
||||
return (AP_DS_ERR_FADE_NOT_SUPPORTED, "Mix_FadeInMusicPos not supported in this SDL_mixer version", "Update SDL_mixer or use fadein_music()")
|
||||
|
||||
# Opus playback: delegate to OpusAudio sub-player
|
||||
if self._is_opus_aid(aid):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module")
|
||||
return opus.fadein_music(self._get_opus_aid(aid), loops, ms)
|
||||
|
||||
# Opus playback: delegate to OpusAudio sub-player
|
||||
if self._is_opus_aid(aid):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module")
|
||||
return opus.fadein_music_pos(self._get_opus_aid(aid), loops, ms, position)
|
||||
|
||||
# Find AID corresponding music info
|
||||
for channel, info in self._channel_info.items():
|
||||
if info['aid'] == aid and info['is_music']:
|
||||
@@ -655,10 +512,6 @@ class AudioLibrary:
|
||||
Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success,
|
||||
(error_code, error_msg, suggestion) on failure
|
||||
"""
|
||||
# Opus playback: delegate to OpusAudio sub-player if Opus is playing
|
||||
if self._opus_audio is not None and self._opus_audio.is_music_playing():
|
||||
return self._opus_audio.fadeout_music(ms)
|
||||
|
||||
result = Mix_FadeOutMusic(ms)
|
||||
if result == 1:
|
||||
return (AP_DS_SUCCESS, "", "")
|
||||
@@ -672,9 +525,6 @@ class AudioLibrary:
|
||||
Returns:
|
||||
bool: True if playing, False otherwise
|
||||
"""
|
||||
# Check Opus sub-player first
|
||||
if self._opus_audio is not None and self._opus_audio.is_music_playing():
|
||||
return True
|
||||
return Mix_PlayingMusic() == 1
|
||||
|
||||
def is_music_paused(self) -> bool:
|
||||
@@ -684,9 +534,6 @@ class AudioLibrary:
|
||||
Returns:
|
||||
bool: True if paused, False otherwise
|
||||
"""
|
||||
# Check Opus sub-player first
|
||||
if self._opus_audio is not None and self._opus_audio.is_music_paused():
|
||||
return True
|
||||
return Mix_PausedMusic() == 1
|
||||
|
||||
def get_music_fading(self) -> int:
|
||||
@@ -699,9 +546,6 @@ class AudioLibrary:
|
||||
- 1 (MUS_FADING_IN): Fading in
|
||||
- 2 (MUS_FADING_OUT): Fading out
|
||||
"""
|
||||
# Check Opus sub-player first
|
||||
if self._opus_audio is not None and self._opus_audio.get_music_fading() != 0:
|
||||
return self._opus_audio.get_music_fading()
|
||||
return Mix_FadingMusic()
|
||||
|
||||
# ============================================================
|
||||
@@ -869,13 +713,6 @@ class AudioLibrary:
|
||||
Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success,
|
||||
(error_code, error_msg, suggestion) on failure
|
||||
"""
|
||||
# Opus playback: delegate to OpusAudio sub-player
|
||||
if self._is_opus_aid(aid):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module")
|
||||
return opus.pause_audio(self._get_opus_aid(aid))
|
||||
|
||||
channel = self._find_channel_by_aid(aid)
|
||||
if channel is None:
|
||||
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded")
|
||||
@@ -899,16 +736,6 @@ class AudioLibrary:
|
||||
float: Played duration in seconds on success
|
||||
Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure
|
||||
"""
|
||||
# Opus playback: delegate to OpusAudio sub-player
|
||||
if self._is_opus_aid(aid):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module")
|
||||
result = opus.stop_audio(self._get_opus_aid(aid))
|
||||
# Remove AID mapping after stop
|
||||
self._aid_to_opus_aid.pop(aid, None)
|
||||
return result
|
||||
|
||||
channel = self._find_channel_by_aid(aid)
|
||||
if channel is None:
|
||||
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded")
|
||||
@@ -932,13 +759,6 @@ class AudioLibrary:
|
||||
Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success,
|
||||
(error_code, error_msg, suggestion) on failure
|
||||
"""
|
||||
# Opus playback: delegate to OpusAudio sub-player
|
||||
if self._is_opus_aid(aid):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module")
|
||||
return opus.seek_audio(self._get_opus_aid(aid), position)
|
||||
|
||||
channel = self._find_channel_by_aid(aid)
|
||||
if channel is None:
|
||||
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded")
|
||||
@@ -1009,13 +829,6 @@ class AudioLibrary:
|
||||
Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success,
|
||||
(error_code, error_msg, suggestion) on failure
|
||||
"""
|
||||
# Opus playback: delegate to OpusAudio sub-player
|
||||
if self._is_opus_aid(aid):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module")
|
||||
return opus.set_volume(self._get_opus_aid(aid), volume)
|
||||
|
||||
channel = self._find_channel_by_aid(aid)
|
||||
if channel is None:
|
||||
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded")
|
||||
@@ -1051,13 +864,6 @@ class AudioLibrary:
|
||||
int: Current volume value (0-128) on success
|
||||
Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure
|
||||
"""
|
||||
# Opus playback: delegate to OpusAudio sub-player
|
||||
if self._is_opus_aid(aid):
|
||||
opus = self._get_opus_player()
|
||||
if opus is None:
|
||||
return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module")
|
||||
return opus.get_volume(self._get_opus_aid(aid))
|
||||
|
||||
channel = self._find_channel_by_aid(aid)
|
||||
if channel is None:
|
||||
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded")
|
||||
@@ -1130,12 +936,6 @@ class AudioLibrary:
|
||||
if not os.path.exists(file_path):
|
||||
return (AP_DS_ERR_FILE_NOT_FOUND, f"File not found: {file_path}", "Verify the file path exists")
|
||||
|
||||
# Opus duration: use opusplayer
|
||||
if _is_opus_file(file_path):
|
||||
opus = self._get_opus_player()
|
||||
if opus is not None:
|
||||
return opus.get_audio_duration(file_path, is_file=True)
|
||||
|
||||
if AUDIO_PARSER_AVAILABLE:
|
||||
try:
|
||||
from .audio_parser import get_audio_duration
|
||||
@@ -1237,12 +1037,6 @@ class AudioLibrary:
|
||||
if not os.path.exists(file_path):
|
||||
return (AP_DS_ERR_FILE_NOT_FOUND, f"File not found: {file_path}", "Verify the file path exists")
|
||||
|
||||
# Opus metadata: use opusplayer
|
||||
if _is_opus_file(file_path):
|
||||
opus = self._get_opus_player()
|
||||
if opus is not None:
|
||||
return opus.get_audio_metadata_by_path(file_path)
|
||||
|
||||
if AUDIO_PARSER_AVAILABLE:
|
||||
from .audio_parser import get_audio_metadata
|
||||
result = get_audio_metadata(file_path)
|
||||
|
||||
Reference in New Issue
Block a user