Initial commit: ap_ds 音频播放库 (Audio Player By DVS AFS)

This commit is contained in:
dvs
2026-08-27 19:11:29 +08:00
commit bb521483b9
16 changed files with 11045 additions and 0 deletions
+893
View File
@@ -0,0 +1,893 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
============================================================================
AP_DS 0.0.1-AFS - Opus Specialized Test Suite
Audio Library By DVS
============================================================================
Automated + interactive test coverage for the Opus playback support
(opusplayer.py + _opusdll.py) integrated into AudioLibrary.
Sections:
[OP-1] Opus Module Imports / Constants / Error Codes
[OP-2] _opusdll.py DLL Loading & Auto-Download
[OP-3] Opus Metadata Parsing (basic + extended)
[OP-4] Opus Playback (play_from_file / new_aid / play_from_memory)
[OP-5] Opus Playback Control (pause / resume / stop)
[OP-6] Opus Volume Control
[OP-7] Opus Seek
[OP-8] Opus Fade In / Out
[OP-9] Opus vs Original Distinction
[OP-10] Opus Batch Parsing
[OP-11] Opus Error Codes (normal / boundary / error)
[OP-12] Opus AID Mapping (1:1)
[OP-13] Opus Resource Management
[OP-L] Listening Tests (interactive, requires ears)
Usage:
python OPUS_TEST.py --auto Run automated tests only
python OPUS_TEST.py --listen Run interactive listening tests only
python OPUS_TEST.py --full Run everything (default)
The suite verifies Opus-specific error codes (2001-2010), normal operation,
boundary values, and error paths.
============================================================================
"""
import os
import sys
import io
import time
import struct
import hashlib
import contextlib
# ============================================================================
# Environment
# ============================================================================
os.environ.setdefault('AP_DS_SKIP_AUTO_CHECK', '1')
os.environ.setdefault('AP_DS_SUPPRESS_WARNINGS', '1')
_HERE = os.path.dirname(os.path.abspath(__file__))
if os.path.basename(_HERE) == 'ap_ds':
sys.path.insert(0, os.path.dirname(_HERE))
else:
sys.path.insert(0, _HERE)
import ap_ds
from ap_ds import AudioLibrary
import ap_ds.player as player
import ap_ds.opusplayer as opusplayer
import ap_ds._opusdll as opusdll
# ============================================================================
# Test resources
# ============================================================================
TMP_DIR = os.path.join(_HERE, 'opus_tmp')
os.makedirs(TMP_DIR, exist_ok=True)
# Opus test file (use the real test.opus if available, else search package)
OPUS_FILE = r"D:\DVS开发目录\ap_ds opus支持 开发目录\test.opus"
if not os.path.exists(OPUS_FILE):
pkg_dir = os.path.dirname(os.path.abspath(opusplayer.__file__))
for f in os.listdir(pkg_dir):
if f.endswith('.opus'):
OPUS_FILE = os.path.join(pkg_dir, f)
break
def _prompt_opus():
"""Prompt the user for an Opus file path. Returns default if empty/EOF."""
print("\n" + "=" * 60)
print(" Opus Test Suite - Test Audio Selection")
print("=" * 60)
default = OPUS_FILE
try:
answer = input(
"Enter path to an Opus file for tests\n"
f"(or press Enter to use default: {default})\n> "
).strip().strip('"').strip("'")
if answer and os.path.exists(answer):
return answer
if answer and not os.path.exists(answer):
print(f" ! File not found: {answer}, using default")
return default
except EOFError:
# Non-interactive execution -> use default
return default
# Original (non-Opus) test file - a WAV
WAV_FILE = os.path.join(TMP_DIR, 'orig.wav')
def _make_wav(path, seconds=2, sr=22050, ch=1):
import wave
n = sr * seconds * ch
with wave.open(path, 'w') as w:
w.setnchannels(ch)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(b''.join(struct.pack('<h', 0) for _ in range(n)))
return path
_make_wav(WAV_FILE)
# Corrupted Opus file
BAD_OPUS = os.path.join(TMP_DIR, 'bad.opus')
with open(BAD_OPUS, 'wb') as f:
f.write(b'\x00\x01\x02\x03' * 100) # not a valid Opus stream
# ============================================================================
# Test framework
# ============================================================================
class OpusTest:
def __init__(self):
self.passed = 0
self.failed = 0
self.skipped = 0
self.failures = []
def log(self, msg):
print(msg, flush=True)
def check(self, name, cond, detail=""):
if cond:
self.passed += 1
self.log(f" [PASS] {name}" + (f" | {detail}" if detail else ""))
else:
self.failed += 1
self.failures.append((name, detail))
self.log(f" [FAIL] {name}" + (f" | {detail}" if detail else ""))
def skip(self, name, reason):
self.skipped += 1
self.log(f" [SKIP] {name} | {reason}")
def section(self, title):
self.log("\n" + "=" * 66)
self.log(f" {title}")
self.log("=" * 66)
def summary(self):
self.log("\n" + "=" * 66)
self.log(" Opus Test Summary")
self.log("=" * 66)
self.log(f" Passed : {self.passed}")
self.log(f" Failed : {self.failed}")
self.log(f" Skipped: {self.skipped}")
if self.failures:
self.log(" --- Failed details ---")
for name, detail in self.failures:
self.log(f" [FAIL] {name}: {detail}")
self.log("=" * 66)
# ============================================================================
# [OP-1] Opus Module Imports / Constants / Error Codes
# ============================================================================
def test_opus_imports(t):
t.section("[OP-1] Opus Module Imports / Constants / Error Codes")
# Module imports
t.check("opusplayer importable", callable(opusplayer.OpusAudio))
t.check("_opusdll importable", opusdll is not None)
t.check("OpusAudio class exists", hasattr(opusplayer, 'OpusAudio'))
t.check("check_opus_dll callable", callable(opusplayer.check_opus_dll))
t.check("import_opus callable", callable(opusdll.import_opus))
t.check("download_opus_libraries callable", callable(opusdll.download_opus_libraries))
# Constants
t.check("MUS_NO_FADING=0", opusplayer.MUS_NO_FADING == 0)
t.check("MUS_FADING_IN=1", opusplayer.MUS_FADING_IN == 1)
t.check("MUS_FADING_OUT=2", opusplayer.MUS_FADING_OUT == 2)
t.check("WAVE_FORMAT_PCM=1", opusplayer.WAVE_FORMAT_PCM == 1)
t.check("CALLBACK_EVENT set", opusplayer.CALLBACK_EVENT == 0x00050000)
# Structures
t.check("OpusHead structure", hasattr(opusdll, 'OpusHead'))
t.check("OpusTags structure", hasattr(opusdll, 'OpusTags'))
t.check("WAVEFORMATEX structure", hasattr(opusdll, 'WAVEFORMATEX'))
t.check("WAVEHDR structure", hasattr(opusdll, 'WAVEHDR'))
# DLL file presence
pkg = os.path.dirname(os.path.abspath(opusdll.__file__))
for dll in ('libopusfile-0.dll', 'libopus-0.dll', 'libogg-0.dll', 'libopusurl-0.dll'):
t.check(f"{dll} exists", os.path.exists(os.path.join(pkg, dll)))
# DLL hash table
t.check("OPUS_DLL_HASHES defined", hasattr(opusdll, 'OPUS_DLL_HASHES'))
if hasattr(opusdll, 'OPUS_DLL_HASHES'):
for dll in ('libopusfile-0.dll', 'libopus-0.dll', 'libogg-0.dll', 'libopusurl-0.dll'):
t.check(f"hash for {dll}", dll in opusdll.OPUS_DLL_HASHES)
# ============================================================================
# [OP-2] _opusdll.py DLL Loading & Auto-Download
# ============================================================================
def test_opus_dll(t):
t.section("[OP-2] _opusdll.py DLL Loading & Auto-Download")
ok, msg = opusplayer.check_opus_dll()
t.check("check_opus_dll returns (bool,str)", isinstance(ok, bool) and isinstance(msg, str), f"got={ok},{msg}")
t.check("check_opus_dll ok", ok is True, f"msg={msg}")
r = opusdll.import_opus()
t.check("import_opus returns bool", isinstance(r, bool), f"got={r}")
t.check("import_opus ok", r is True)
t.check("opusfile handle loaded", opusdll.opusfile is not None)
t.check("OPUS_DLL_FILES has 4", len(opusdll.OPUS_DLL_FILES) == 4, f"got={len(opusdll.OPUS_DLL_FILES)}")
if hasattr(opusdll, 'OPUS_DLL_FILES'):
for dll in opusdll.OPUS_DLL_FILES:
t.check(f"DLL config {dll['filename']}", 'url' in dll and 'filename' in dll)
# ============================================================================
# [OP-3] Opus Metadata Parsing
# ============================================================================
def test_opus_metadata(t):
t.section("[OP-3] Opus Metadata Parsing (basic + extended)")
if not os.path.exists(OPUS_FILE):
t.skip("Opus metadata", "no Opus file available")
return
lib = opusplayer.OpusAudio()
meta = lib.get_audio_metadata_by_path(OPUS_FILE)
t.check("get_audio_metadata_by_path is dict", isinstance(meta, dict), f"got={type(meta).__name__}")
if isinstance(meta, dict):
t.check("format=opus", meta.get('format') == 'opus', f"got={meta.get('format')}")
t.check("duration>0", meta.get('duration', 0) > 0, f"got={meta.get('duration')}")
t.check("sample_rate=48000", meta.get('sample_rate') == 48000, f"got={meta.get('sample_rate')}")
t.check("channels>0", meta.get('channels', 0) > 0, f"got={meta.get('channels')}")
t.check("bitrate>0", meta.get('bitrate', 0) > 0, f"got={meta.get('bitrate')}")
t.check("fields complete", all(k in meta for k in ('path', 'format', 'duration', 'length', 'sample_rate', 'channels', 'bitrate')))
dur = lib.get_audio_duration(OPUS_FILE, is_file=True)
t.check("get_audio_duration>0", isinstance(dur, int) and dur > 0, f"got={dur}")
sr = lib._get_sample_rate(OPUS_FILE)
t.check("_get_sample_rate=48000", sr == 48000, f"got={sr}")
ch = lib._get_channels(OPUS_FILE)
t.check("_get_channels>0", ch > 0, f"got={ch}")
ext = lib.get_audio_extended_metadata(OPUS_FILE)
t.check("extended metadata is dict", isinstance(ext, dict), f"got={type(ext).__name__}")
if isinstance(ext, dict):
t.check("extended has title/album/artist", 'title' in ext or 'album' in ext or 'artist' in ext, f"keys={list(ext.keys())[:5]}")
t.check("extended has vendor", 'vendor' in ext, f"vendor={ext.get('vendor')}")
lib.cleanup_function()
# ============================================================================
# [OP-4] Opus Playback
# ============================================================================
def test_opus_play(t):
t.section("[OP-4] Opus Playback")
if not os.path.exists(OPUS_FILE):
t.skip("Opus playback", "no Opus file available")
return
lib = opusplayer.OpusAudio()
aid = lib.play_from_file(OPUS_FILE)
t.check("play_from_file returns int AID", isinstance(aid, int), f"got={aid}")
if isinstance(aid, int):
time.sleep(0.3)
t.check("is_music_playing", lib.is_music_playing() is True)
lib.stop_audio(aid)
aid2 = lib.new_aid(OPUS_FILE)
t.check("new_aid returns int AID", isinstance(aid2, int), f"got={aid2}")
r = lib.play_from_memory(OPUS_FILE)
t.check("play_from_memory returns int", isinstance(r, int), f"got={r}")
if isinstance(r, int):
lib.stop_audio(r)
r = lib.play_from_file(os.path.join(TMP_DIR, 'missing.opus'))
t.check("play_from_file(missing) -> 1001", isinstance(r, tuple) and r[0] == 1001, f"got={r}")
r = lib.play_from_file(None)
t.check("play_from_file(None) -> 1001", isinstance(r, tuple) and r[0] == 1001, f"got={r}")
r = lib.play_from_file(BAD_OPUS)
t.check("play_from_file(corrupted) -> 2003", isinstance(r, tuple) and r[0] == 2003, f"got={r}")
lib.cleanup_function()
# ============================================================================
# [OP-5] Opus Playback Control
# ============================================================================
def test_opus_control(t):
t.section("[OP-5] Opus Playback Control")
if not os.path.exists(OPUS_FILE):
t.skip("Opus control", "no Opus file available")
return
lib = opusplayer.OpusAudio()
aid = lib.play_from_file(OPUS_FILE)
if not isinstance(aid, int):
t.check("Play opus ok", False, f"got={aid}")
lib.cleanup_function()
return
time.sleep(0.3)
r = lib.pause_audio(aid)
t.check("pause_audio ok", r[0] == 0, f"got={r}")
time.sleep(0.2)
t.check("is_music_paused=True", lib.is_music_paused() is True)
r = lib.play_audio(aid)
t.check("play_audio resume ok", r[0] == 0, f"got={r}")
time.sleep(0.2)
t.check("is_music_playing=True after resume", lib.is_music_playing() is True)
r = lib.stop_audio(aid)
t.check("stop_audio returns float", isinstance(r, float), f"got={r}")
# Error paths
r = lib.pause_audio(99999)
t.check("pause_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
r = lib.play_audio(99999)
t.check("play_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
r = lib.stop_audio(99999)
t.check("stop_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
lib.cleanup_function()
# ============================================================================
# [OP-6] Opus Volume Control
# ============================================================================
def test_opus_volume(t):
t.section("[OP-6] Opus Volume Control")
if not os.path.exists(OPUS_FILE):
t.skip("Opus volume", "no Opus file available")
return
lib = opusplayer.OpusAudio()
aid = lib.play_from_file(OPUS_FILE)
if isinstance(aid, int):
# Valid volumes
for vol in (0, 1, 64, 100, 128):
r = lib.set_volume(aid, vol)
t.check(f"set_volume({vol}) ok", r[0] == 0, f"got={r}")
g = lib.get_volume(aid)
t.check("get_volume is int", isinstance(g, int), f"got={g}")
t.check("get_volume in range 0-128", 0 <= g <= 128, f"got={g}")
# Invalid volumes
for vol in (-1, 129, 200):
r = lib.set_volume(aid, vol)
t.check(f"set_volume({vol}) -> 1015", r[0] == 1015, f"got={r}")
# Invalid types
for vol in (None, '50', 1.5):
r = lib.set_volume(aid, vol)
t.check(f"set_volume({vol!r}) -> 1015", isinstance(r, tuple) and r[0] == 1015, f"got={r}")
lib.stop_audio(aid)
# Invalid AID
r = lib.set_volume(99999, 50)
t.check("set_volume(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
r = lib.get_volume(99999)
t.check("get_volume(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
lib.cleanup_function()
# ============================================================================
# [OP-7] Opus Seek
# ============================================================================
def test_opus_seek(t):
t.section("[OP-7] Opus Seek")
if not os.path.exists(OPUS_FILE):
t.skip("Opus seek", "no Opus file available")
return
lib = opusplayer.OpusAudio()
aid = lib.play_from_file(OPUS_FILE)
if isinstance(aid, int):
time.sleep(0.2)
# Valid seeks
for pos in (0.0, 1.0, 30.0, 100.5):
r = lib.seek_audio(aid, pos)
t.check(f"seek_audio({pos}) ok", r[0] == 0, f"got={r}")
t.check("still playing after seek", lib.is_music_playing() is True)
# Invalid types
for pos in (None, '5', [], {}):
r = lib.seek_audio(aid, pos)
t.check(f"seek_audio({pos!r}) -> tuple", isinstance(r, tuple), f"got={r}")
lib.stop_audio(aid)
r = lib.seek_audio(99999, 1.0)
t.check("seek_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
lib.cleanup_function()
# ============================================================================
# [OP-8] Opus Fade In / Out
# ============================================================================
def test_opus_fade(t):
t.section("[OP-8] Opus Fade In / Out")
if not os.path.exists(OPUS_FILE):
t.skip("Opus fade", "no Opus file available")
return
lib = opusplayer.OpusAudio()
# Fade-in play (via new_aid)
aid = lib.new_aid(OPUS_FILE)
r = lib.fadein_music(aid, ms=1000)
t.check("fadein_music ok", r[0] == 0, f"got={r}")
if r[0] == 0:
time.sleep(0.3)
fading = lib.get_music_fading()
t.check("get_music_fading=1 (fading in)", fading == 1, f"got={fading}")
t.check("playing during fadein", lib.is_music_playing() is True)
time.sleep(1.5) # wait for fade-in to complete
t.check("fading=0 after fadein", lib.get_music_fading() == 0, f"got={lib.get_music_fading()}")
# Fade-out
r = lib.fadeout_music(ms=1000)
t.check("fadeout_music ok", r[0] == 0, f"got={r}")
time.sleep(3)
t.check("stopped after fadeout", not lib.is_music_playing())
# Fade-in from position
aid2 = lib.new_aid(OPUS_FILE)
r = lib.fadein_music_pos(aid2, ms=500, position=30.0)
t.check("fadein_music_pos ok", r[0] == 0, f"got={r}")
if r[0] == 0:
time.sleep(2)
lib.stop_audio(aid2)
# Error paths
r = lib.fadein_music(99999)
t.check("fadein_music(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
r = lib.fadein_music_pos(99999, ms=500)
t.check("fadein_music_pos(invalid AID) -> 1002", r[0] == 1002, f"got={r}")
lib.cleanup_function()
# ============================================================================
# [OP-9] Opus vs Original Distinction
# ============================================================================
def test_opus_distinction(t):
t.section("[OP-9] Opus vs Original Distinction")
# _is_opus_file detection
t.check("_is_opus_file(opus)=True", player._is_opus_file(OPUS_FILE) is True)
t.check("_is_opus_file(wav)=False", player._is_opus_file(WAV_FILE) is False)
t.check("_is_opus_file(.mp3)=False", player._is_opus_file('x.mp3') is False)
t.check("_is_opus_file(.ogg)=False", player._is_opus_file('x.ogg') is False)
# AudioLibrary routes opus to OpusAudio
lib = AudioLibrary()
opus_aid = lib.play_from_file(OPUS_FILE)
t.check("AudioLibrary play opus returns int", isinstance(opus_aid, int), f"got={opus_aid}")
if isinstance(opus_aid, int):
t.check("opus AID mapped", lib._is_opus_aid(opus_aid), f"mapping={lib._aid_to_opus_aid}")
lib.stop_audio(opus_aid)
# AudioLibrary routes wav to SDL2
wav_aid = lib.play_from_file(WAV_FILE)
t.check("AudioLibrary play wav returns int", isinstance(wav_aid, int), f"got={wav_aid}")
if isinstance(wav_aid, int):
t.check("wav NOT mapped to opus", not lib._is_opus_aid(wav_aid), "wav uses SDL2")
lib.stop_audio(wav_aid)
# Metadata distinction
meta_opus = lib.get_audio_metadata_by_path(OPUS_FILE)
meta_wav = lib.get_audio_metadata_by_path(WAV_FILE)
t.check("opus metadata format=opus", isinstance(meta_opus, dict) and meta_opus.get('format') == 'opus', f"got={meta_opus.get('format') if isinstance(meta_opus,dict) else meta_opus}")
t.check("wav metadata format=wav", isinstance(meta_wav, dict) and meta_wav.get('format') == 'wav', f"got={meta_wav.get('format') if isinstance(meta_wav,dict) else meta_wav}")
lib.cleanup_function()
# ============================================================================
# [OP-10] Opus Batch Parsing
# ============================================================================
def test_opus_batch(t):
t.section("[OP-10] Opus Batch Parsing")
if not os.path.exists(OPUS_FILE):
t.skip("Opus batch", "no Opus file available")
return
lib = opusplayer.OpusAudio()
files = [OPUS_FILE]
bl = lib.batch_get_metadata(files)
t.check("batch_get_metadata returns 1", len(bl) == 1, f"got={len(bl)}")
if bl:
t.check("batch metadata format=opus", bl[0].get('format') == 'opus', f"got={bl[0].get('format')}")
bd = lib.batch_get_duration(files)
t.check("batch_get_duration returns dict", isinstance(bd, dict), f"got={type(bd).__name__}")
t.check("batch_get_duration has opus", OPUS_FILE in bd, f"keys={list(bd.keys())}")
bt = lib.batch_get_metadata_by_type(files, 'opus')
t.check("batch_by_type opus -> 1", len(bt) == 1, f"got={len(bt)}")
bt2 = lib.batch_get_metadata_by_type(files, 'wav')
t.check("batch_by_type wav -> 0", len(bt2) == 0, f"got={len(bt2)}")
# Mixed batch via AudioLibrary (test opus and wav separately to avoid
# multiprocessing spawn limitations in test environments)
lib2 = AudioLibrary()
# Opus files in batch use opusplayer
opus_batch = lib2.batch_get_metadata([OPUS_FILE])
t.check("AudioLibrary batch opus -> 1", len(opus_batch) == 1, f"got={len(opus_batch)}")
if opus_batch:
t.check("AudioLibrary batch opus format", opus_batch[0].get('format') == 'opus', f"got={opus_batch[0].get('format')}")
# WAV files in batch use audio_parser
wav_batch = lib2.batch_get_metadata([WAV_FILE])
t.check("AudioLibrary batch wav -> 1", len(wav_batch) == 1, f"got={len(wav_batch)}")
if wav_batch:
t.check("AudioLibrary batch wav format", wav_batch[0].get('format') == 'wav', f"got={wav_batch[0].get('format')}")
lib2.cleanup_function()
lib.cleanup_function()
# ============================================================================
# [OP-11] Opus Error Codes (normal / boundary / error)
# ============================================================================
def test_opus_errors(t):
t.section("[OP-11] Opus Error Codes (normal / boundary / error)")
# Error code constants
err_codes = {
'AP_DS_ERR_OPUS_LIB_LOAD_FAILED': 2001,
'AP_DS_ERR_OPUS_DLL_DEPENDENCY': 2002,
'AP_DS_ERR_OPUS_OPEN_FAILED': 2003,
'AP_DS_ERR_OPUS_HEADER_CORRUPT': 2004,
'AP_DS_ERR_OPUS_TAGS_PARSE_FAILED': 2005,
'AP_DS_ERR_OPUS_DECODE_FAILED': 2006,
'AP_DS_ERR_OPUS_SEEK_FAILED': 2007,
'AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE': 2008,
'AP_DS_ERR_OPUS_NOT_SEEKABLE': 2009,
'AP_DS_ERR_OPUS_CHANNEL_INVALID': 2010,
}
for name, code in err_codes.items():
t.check(f"{name}={code}", getattr(opusplayer, name, None) == code, f"got={getattr(opusplayer, name, None)}")
# OPUS_ERR_INFO table
t.check("OPUS_ERR_INFO defined", hasattr(opusplayer, 'OPUS_ERR_INFO'))
if hasattr(opusplayer, 'OPUS_ERR_INFO'):
for code in err_codes.values():
t.check(f"OPUS_ERR_INFO[{code}]", code in opusplayer.OPUS_ERR_INFO)
# Normal error paths (corrupted file -> 2003)
lib = opusplayer.OpusAudio()
r = lib.get_audio_metadata_by_path(BAD_OPUS)
t.check("corrupted metadata -> 2003", isinstance(r, tuple) and r[0] == 2003, f"got={r}")
# Invalid source type
r = lib.get_audio_metadata(1.5)
t.check("invalid source type -> 1014", isinstance(r, tuple) and r[0] == 1014, f"got={r}")
# Missing file
r = lib.get_audio_metadata_by_path(os.path.join(TMP_DIR, 'missing.opus'))
t.check("missing file -> 1001", isinstance(r, tuple) and r[0] == 1001, f"got={r}")
lib.cleanup_function()
# ============================================================================
# [OP-12] Opus AID Mapping (1:1)
# ============================================================================
def test_opus_aid_mapping(t):
t.section("[OP-12] Opus AID Mapping (1:1)")
if not os.path.exists(OPUS_FILE):
t.skip("Opus AID mapping", "no Opus file available")
return
lib = AudioLibrary()
main_aid = lib.play_from_file(OPUS_FILE)
t.check("main AID is int", isinstance(main_aid, int), f"got={main_aid}")
if isinstance(main_aid, int):
t.check("AID mapped to opus", lib._is_opus_aid(main_aid), f"mapping={lib._aid_to_opus_aid}")
opus_aid = lib._get_opus_aid(main_aid)
t.check("opus AID is int", isinstance(opus_aid, int), f"got={opus_aid}")
t.check("mapping is 1:1", isinstance(main_aid, int) and isinstance(opus_aid, int))
r = lib.pause_audio(main_aid)
t.check("pause via main AID -> opus", r[0] == 0, f"got={r}")
r = lib.play_audio(main_aid)
t.check("resume via main AID -> opus", r[0] == 0, f"got={r}")
r = lib.set_volume(main_aid, 80)
t.check("set_volume via main AID -> opus", r[0] == 0, f"got={r}")
g = lib.get_volume(main_aid)
t.check("get_volume via main AID -> opus", isinstance(g, int), f"got={g}")
r = lib.seek_audio(main_aid, 30.0)
t.check("seek via main AID -> opus", r[0] == 0, f"got={r}")
r = lib.stop_audio(main_aid)
t.check("stop via main AID -> opus", isinstance(r, float), f"got={r}")
t.check("mapping removed after stop", not lib._is_opus_aid(main_aid), f"mapping={lib._aid_to_opus_aid}")
lib.cleanup_function()
# ============================================================================
# [OP-13] Opus Resource Management
# ============================================================================
def test_opus_resources(t):
t.section("[OP-13] Opus Resource Management")
if not os.path.exists(OPUS_FILE):
t.skip("Opus resources", "no Opus file available")
return
lib = opusplayer.OpusAudio()
lib.play_from_file(OPUS_FILE)
t.check("playing before cleanup", lib.is_music_playing() is True)
lib.cleanup_function()
t.check("not playing after cleanup", not lib.is_music_playing())
aid = lib.play_from_file(OPUS_FILE)
t.check("reusable after cleanup", isinstance(aid, int), f"got={aid}")
if isinstance(aid, int):
lib.stop_audio(aid)
lib.cleanup_function()
# ============================================================================
# [OP-14] Opus Boundary & Error Tests (reference CI/CD test_edge_cases)
# ============================================================================
def test_opus_boundary(t):
t.section("[OP-14] Opus Boundary & Error Tests")
lib = opusplayer.OpusAudio()
# --- B1: invalid argument types -> error tuples, never crash ---
t.log(" --- B1 invalid argument types ---")
for bad in (None, 1.5, [], {}, ('a',)):
r = lib.play_from_file(bad)
ok = isinstance(r, tuple) and len(r) == 3 and r[0] == 1001
t.check(f"play_from_file({type(bad).__name__}) -> tuple(1001)", ok, f"got={r!r}")
for bad in (None, 1.5, []):
r = lib.new_aid(bad)
ok = isinstance(r, tuple) and len(r) == 3 and r[0] == 1001
t.check(f"new_aid({type(bad).__name__}) -> tuple(1001)", ok, f"got={r!r}")
# --- B2: seek / volume / fade boundary values ---
t.log(" --- B2 boundary values ---")
aid = lib.play_from_file(OPUS_FILE)
if isinstance(aid, int):
# Seek boundary
for pos in (None, '5', [], {}, -1.0, 0.0, 999999.0):
r = lib.seek_audio(aid, pos)
ok = isinstance(r, tuple)
t.check(f"seek_audio({pos!r}) -> tuple", ok, f"got={r!r}")
r = lib.seek_audio(aid, 0.0)
t.check("seek_audio(0.0) -> success", isinstance(r, tuple) and r[0] == 0, f"got={r!r}")
# Volume boundary
for v in (None, '50', 1.5, [], {}):
r = lib.set_volume(aid, v)
t.check(f"set_volume({v!r}) -> 1015", isinstance(r, tuple) and r[0] == 1015, f"got={r!r}")
for v in (0, 1, 64, 100, 128):
r = lib.set_volume(aid, v)
t.check(f"set_volume({v}) boundary -> success", r[0] == 0, f"got={r}")
for v in (-1, 129, 200, 1000):
r = lib.set_volume(aid, v)
t.check(f"set_volume({v}) out of range -> 1015", r[0] == 1015, f"got={r}")
# Fade boundary
r = lib.fadein_music(aid, ms=None)
t.check("fadein_music(ms=None) -> tuple", isinstance(r, tuple), f"got={r!r}")
r = lib.fadein_music_pos(aid, ms=100, position=None)
t.check("fadein_music_pos(position=None) -> tuple", isinstance(r, tuple), f"got={r!r}")
lib.stop_audio(aid)
# --- B3: exact error codes ---
t.log(" --- B3 exact error codes ---")
cases = {
'1001 missing file': (lambda: lib.play_from_file(os.path.join(TMP_DIR, 'no_such.opus')), 1001),
'1002 invalid AID': (lambda: lib.pause_audio(99999), 1002),
'1014 invalid source type': (lambda: lib.get_audio_metadata(1.5), 1014),
'2003 corrupted opus': (lambda: lib.get_audio_metadata_by_path(BAD_OPUS), 2003),
}
for name, (fn, expect_code) in cases.items():
r = fn()
ok = isinstance(r, tuple) and r[0] == expect_code
t.check(f"{name} -> {expect_code}", ok, f"got={r!r}")
lib.cleanup_function()
# ============================================================================
# [OP-15] Opus DLL Specialized Tests (reference CI/CD test_sdl2_bindings)
# ============================================================================
def test_opus_dll_deep(t):
t.section("[OP-15] Opus DLL Specialized Tests")
# DLL file presence and sizes
pkg = os.path.dirname(os.path.abspath(opusdll.__file__))
dll_info = {
'libopusfile-0.dll': 55884,
'libopus-0.dll': 500112,
'libogg-0.dll': 40580,
'libopusurl-0.dll': 76772,
}
for dll, size in dll_info.items():
path = os.path.join(pkg, dll)
t.check(f"{dll} exists", os.path.exists(path))
if os.path.exists(path):
actual = os.path.getsize(path)
t.check(f"{dll} size={size}", actual == size, f"got={actual}")
# DLL hash verification
for dll in dll_info:
path = os.path.join(pkg, dll)
if os.path.exists(path):
with open(path, 'rb') as f:
content = f.read()
h = hashlib.sha256(content).hexdigest()
expected = opusdll.OPUS_DLL_HASHES.get(dll)
t.check(f"{dll} hash matches", h == expected, f"got={h[:16]}...")
# import_opus idempotent
r1 = opusdll.import_opus()
r2 = opusdll.import_opus()
t.check("import_opus idempotent", r1 == r2 == True, f"r1={r1} r2={r2}")
# opusfile handle
t.check("opusfile handle valid", opusdll.opusfile is not None)
# OPUS_DLL_FILES structure
t.check("OPUS_DLL_FILES has 4", len(opusdll.OPUS_DLL_FILES) == 4)
for dll in opusdll.OPUS_DLL_FILES:
t.check(f"DLL {dll['filename']} has url", 'url' in dll and dll['url'].startswith('http'))
t.check(f"DLL {dll['filename']} has size", 'size' in dll and dll['size'] > 0)
# check_opus_dll
ok, msg = opusplayer.check_opus_dll()
t.check("check_opus_dll ok", ok is True, f"msg={msg}")
# ============================================================================
# [OP-16] Opus Error Code Trigger Tests (all 2001-2010)
# ============================================================================
def test_opus_error_triggers(t):
t.section("[OP-16] Opus Error Code Trigger Tests")
lib = opusplayer.OpusAudio()
# 2003: corrupted file open failure
r = lib.play_from_file(BAD_OPUS)
t.check("2003 OPEN_FAILED trigger", isinstance(r, tuple) and r[0] == 2003, f"got={r}")
# 2004: HEADER_CORRUPT (monkey-patch op_head to return empty)
import ctypes
orig_head = opusdll.opusfile.op_head
opusdll.opusfile.op_head = lambda of, li: ctypes.POINTER(opusdll.OpusHead)()
r = opusplayer._get_opus_metadata(OPUS_FILE)
t.check("2004 HEADER_CORRUPT trigger", isinstance(r, tuple) and r[0] == 2004, f"got={r}")
opusdll.opusfile.op_head = orig_head
# 2008: BITRATE_UNAVAILABLE (monkey-patch op_bitrate to return 0)
orig_bitrate = opusdll.opusfile.op_bitrate
opusdll.opusfile.op_bitrate = lambda of, li: 0
r = opusplayer._get_opus_metadata(OPUS_FILE)
t.check("2008 BITRATE_UNAVAILABLE trigger", isinstance(r, tuple) and r[0] == 2008, f"got={r}")
opusdll.opusfile.op_bitrate = orig_bitrate
# 2010: CHANNEL_INVALID (monkey-patch op_channel_count to return 0)
orig_channels = opusdll.opusfile.op_channel_count
opusdll.opusfile.op_channel_count = lambda of, li: 0
r = opusplayer._get_opus_metadata(OPUS_FILE)
t.check("2010 CHANNEL_INVALID trigger", isinstance(r, tuple) and r[0] == 2010, f"got={r}")
opusdll.opusfile.op_channel_count = orig_channels
# 2009: NOT_SEEKABLE (monkey-patch op_seekable to return 0)
aid = lib.play_from_file(OPUS_FILE)
if isinstance(aid, int):
orig_seekable = opusdll.opusfile.op_seekable
opusdll.opusfile.op_seekable = lambda of: 0
r = lib.seek_audio(aid, 10.0)
t.check("2009 NOT_SEEKABLE trigger", isinstance(r, tuple) and r[0] == 2009, f"got={r}")
opusdll.opusfile.op_seekable = orig_seekable
lib.stop_audio(aid)
# 2007: SEEK_FAILED (monkey-patch op_pcm_seek to return -1)
aid = lib.play_from_file(OPUS_FILE)
if isinstance(aid, int):
orig_seek = opusdll.opusfile.op_pcm_seek
opusdll.opusfile.op_pcm_seek = lambda of, pos: -1
r = lib.seek_audio(aid, 10.0)
t.check("2007 SEEK_FAILED trigger", isinstance(r, tuple) and r[0] == 2007, f"got={r}")
opusdll.opusfile.op_pcm_seek = orig_seek
lib.stop_audio(aid)
# 2005: TAGS_PARSE_FAILED (monkey-patch op_tags to raise)
class BadTags:
@property
def contents(self):
raise ValueError("corrupt tags")
orig_tags = opusdll.opusfile.op_tags
opusdll.opusfile.op_tags = lambda of, li: BadTags()
r = opusplayer._get_opus_extended_metadata(OPUS_FILE)
t.check("2005 TAGS_PARSE_FAILED trigger", isinstance(r, tuple) and r[0] == 2005, f"got={r}")
opusdll.opusfile.op_tags = orig_tags
# 2006: DECODE_FAILED (monkey-patch op_read_stereo to return -1)
orig_read = opusdll.opusfile.op_read_stereo
opusdll.opusfile.op_read_stereo = lambda of, pcm, size: -1
aid = lib.play_from_file(OPUS_FILE)
time.sleep(0.5)
r = lib._decode_error
t.check("2006 DECODE_FAILED trigger", r is not None and r[0] == 2006, f"got={r}")
opusdll.opusfile.op_read_stereo = orig_read
lib.stop_audio(aid)
lib.cleanup_function()
# ============================================================================
# [OP-L] Listening Tests (interactive, requires ears)
# ============================================================================
def test_opus_listen(t):
t.section("[OP-L] Opus Listening Tests (interactive)")
if not os.path.exists(OPUS_FILE):
t.skip("Opus listening", "no Opus file available")
return
try:
ans = input("\nRun Opus listening tests? (y/n): ").strip().lower()
if ans != 'y':
t.skip("Opus listening", "user skipped")
return
except EOFError:
t.skip("Opus listening", "non-interactive")
return
lib = opusplayer.OpusAudio()
t.log("\n--- Listening 1: Normal playback ---")
t.log(" Playing Opus at normal volume...")
aid = lib.play_from_file(OPUS_FILE)
if isinstance(aid, int):
time.sleep(3)
try:
ans = input(" Did you hear audio? (y/n): ").strip().lower()
t.check("normal playback audible", ans == 'y')
except EOFError:
t.skip("normal playback audible", "no input")
lib.stop_audio(aid)
t.log("\n--- Listening 2: Fade-in ---")
t.log(" Fading in over 3 seconds...")
aid = lib.new_aid(OPUS_FILE)
r = lib.fadein_music(aid, ms=3000)
if r[0] == 0:
time.sleep(3)
try:
ans = input(" Did volume gradually increase? (y/n): ").strip().lower()
t.check("fade-in audible", ans == 'y')
except EOFError:
t.skip("fade-in audible", "no input")
time.sleep(1)
lib.stop_audio(aid)
t.log("\n--- Listening 3: Fade-out ---")
t.log(" Playing at normal volume for 3 seconds, then fading out...")
aid = lib.play_from_file(OPUS_FILE)
time.sleep(3) # Play at normal volume first
r = lib.fadeout_music(ms=3000)
if r[0] == 0:
time.sleep(3)
try:
ans = input(" Did volume gradually decrease then stop? (y/n): ").strip().lower()
t.check("fade-out audible", ans == 'y')
except EOFError:
t.skip("fade-out audible", "no input")
time.sleep(1)
t.log("\n--- Listening 4: Volume change ---")
t.log(" Playing at normal volume for 2s, then volume 128 -> 0 -> 128...")
aid = lib.play_from_file(OPUS_FILE)
time.sleep(2) # Play at normal volume first
lib.set_volume(aid, 128)
time.sleep(1)
lib.set_volume(aid, 0)
time.sleep(1)
try:
ans = input(" Did sound become silent at volume 0? (y/n): ").strip().lower()
t.check("volume 0 silent", ans == 'y')
except EOFError:
t.skip("volume 0 silent", "no input")
lib.set_volume(aid, 100)
time.sleep(1)
lib.stop_audio(aid)
t.log("\n--- Listening 5: Seek position ---")
t.log(" Playing for 3s, then seeking to 30s, 60s, 120s...")
aid = lib.play_from_file(OPUS_FILE)
time.sleep(3) # Play at normal position first
for pos in (30, 60, 120):
lib.seek_audio(aid, pos)
time.sleep(1)
try:
ans = input(f" After seek to {pos}s, did position change? (y/n): ").strip().lower()
t.check(f"seek to {pos}s audible", ans == 'y')
except EOFError:
t.skip(f"seek to {pos}s audible", "no input")
lib.stop_audio(aid)
lib.cleanup_function()
# ============================================================================
# main()
# ============================================================================
def main():
global OPUS_FILE
mode = sys.argv[1].lower().lstrip('-') if len(sys.argv) > 1 else 'full'
if mode not in ('auto', 'listen', 'full'):
print(f"Unknown mode: {mode}, using full")
mode = 'full'
# Prompt for Opus file path (use default if empty)
OPUS_FILE = _prompt_opus()
print(f"\n Python: {sys.version.split()[0]} | Platform: {sys.platform} | Mode: {mode}")
print(f" ap_ds version: {ap_ds.__version__} | Path: {os.path.dirname(ap_ds.__file__)}")
print(f" Opus test file: {OPUS_FILE} (exists={os.path.exists(OPUS_FILE)})")
t = OpusTest()
if mode in ('auto', 'full'):
test_opus_imports(t)
test_opus_dll(t)
test_opus_metadata(t)
test_opus_play(t)
test_opus_control(t)
test_opus_volume(t)
test_opus_seek(t)
test_opus_fade(t)
test_opus_distinction(t)
test_opus_batch(t)
test_opus_errors(t)
test_opus_aid_mapping(t)
test_opus_resources(t)
test_opus_boundary(t)
test_opus_dll_deep(t)
test_opus_error_triggers(t)
if mode in ('listen', 'full'):
test_opus_listen(t)
t.summary()
return 0 if t.failed == 0 else 1
if __name__ == '__main__':
sys.exit(main())