Files
ap_ds_afs/ap_ds/player.py
T

1365 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# player.py - AudioLibrary class only (SDL2 imported from _sdl2.py)
import os
import sys
import time
import atexit
import warnings
from collections import defaultdict
from typing import *
# ============================================================
# Import SDL2 from _sdl2.py (local file, NOT pysdl2 package)
# ============================================================
try:
from ._sdl2 import *
from ._sdl2 import _mix_lib, _sdl_lib
except ImportError:
try:
from _sdl2 import *
from _sdl2 import _mix_lib, _sdl_lib
except ImportError:
raise ImportError(
"SDL2 module (_sdl2) not found. "
"Please ensure _sdl2.py is in the ap_ds package."
)
# ============================================================
# Python 3.15+ lazy import support
# ============================================================
# Check if _IS_PYTHON_315_PLUS already exists (imported from other module)
try:
# Try to get from current module first
_IS_PYTHON_315_PLUS = _IS_PYTHON_315_PLUS
print(f"ℹ️ _IS_PYTHON_315_PLUS already exists: {_IS_PYTHON_315_PLUS} (from other module)")
except NameError:
# Not defined yet, get it now
_IS_PYTHON_315_PLUS = sys.version_info >= (3, 15)
print(f"ℹ️ _IS_PYTHON_315_PLUS defined now: {_IS_PYTHON_315_PLUS} (Python {sys.version_info.major}.{sys.version_info.minor})")
if _IS_PYTHON_315_PLUS:
try:
# Try to use lazy import (Python 3.15+)
exec("""
lazy import ctypes
lazy import urllib.request
lazy import struct
lazy import json
lazy import typing
lazy import shutil
lazy import tempfile
lazy import subprocess
lazy import ssl
lazy import hashlib
""")
print("✅ Using lazy imports (Python 3.15+)")
except (SyntaxError, TypeError, NameError) as e:
# Fallback to regular imports if lazy import fails
print(f"⚠️ Lazy import failed ({e}), falling back to regular imports")
import ctypes
import urllib.request
import struct
import json
import typing
import shutil
import tempfile
import subprocess
import ssl
import hashlib
else:
import ctypes
import urllib.request
import struct
import json
import typing
import shutil
import tempfile
import subprocess
import ssl
import hashlib
# ctypes content still usable (already imported)
from ctypes import *
from typing import *
# ============================================================
# Error Codes
# ============================================================
AP_DS_SUCCESS = 0
AP_DS_ERR_FILE_NOT_FOUND = 1001
AP_DS_ERR_INVALID_AID = 1002
AP_DS_ERR_AUDIO_LOAD_FAILED = 1003
AP_DS_ERR_PLAYBACK_FAILED = 1004
AP_DS_ERR_SDL_INIT_FAILED = 1005
AP_DS_ERR_MIXER_INIT_FAILED = 1006
AP_DS_ERR_UNSUPPORTED_FORMAT = 1007
AP_DS_ERR_NOT_MUSIC_FILE = 1008
AP_DS_ERR_DAP_INVALID_EXT = 1009
AP_DS_ERR_DAP_SAVE_FAILED = 1010
AP_DS_ERR_METADATA_PARSE_FAILED = 1011
AP_DS_ERR_FADE_NOT_SUPPORTED = 1012
AP_DS_ERR_AUDIO_NOT_LOADED = 1013
AP_DS_ERR_INVALID_SOURCE = 1014
AP_DS_ERR_INVALID_VOLUME = 1015
AP_DS_ERR_SEEK_NOT_SUPPORTED = 1016
AP_DS_ERR_UNKNOWN = 1999
# ============================================================
# WAV Threshold
# ============================================================
WAV_THRESHOLD = int(os.environ.get('AP_DS_WAV_THRESHOLD', '6'))
if WAV_THRESHOLD >= 30:
print(f"Warning: WAV threshold {WAV_THRESHOLD}s is too large. Using default 6s to prevent memory leaks.")
WAV_THRESHOLD = 6
elif WAV_THRESHOLD < 0:
print(f"Warning: WAV threshold {WAV_THRESHOLD}s is negative. Using default 6s.")
WAV_THRESHOLD = 6
print(f"🎵 WAV playback mode threshold: {WAV_THRESHOLD}s (Files >= {WAV_THRESHOLD}s use music mode, < {WAV_THRESHOLD}s use sound effect mode)")
# ============================================================
# audio_parser import
# ============================================================
AUDIO_PARSER_AVAILABLE = False
try:
from .audio_parser import *
AUDIO_PARSER_AVAILABLE = True
except ImportError:
try:
from audio_parser import *
AUDIO_PARSER_AVAILABLE = True
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
# ============================================================
class AudioLibrary:
def __init__(self, frequency: int = 44100, format: int = MIX_DEFAULT_FORMAT,
channels: int = 2, chunksize: int = 2048):
"""Initialize the audio library"""
# Initialize SDL audio
if SDL_Init(SDL_INIT_AUDIO) != 0:
raise RuntimeError(f"SDL initialization failed")
if Mix_OpenAudio(frequency, format, channels, chunksize) != 0:
raise RuntimeError(f"Mixer initialization failed")
atexit.register(self.cleanup_function)
self.MUS_NO_FADING = 0
self.MUS_FADING_IN = 1
self.MUS_FADING_OUT = 2
# Audio state tracking
self._audio_cache = {} # File path -> Mix_Chunk
self._music_cache = {} # File path -> Mix_Music
self._channel_info = {} # Channel ID -> Playback info
self._aid_to_filepath = {} # Store AID to file mapping
self._aid_counter = 0
self._sample_rate = frequency
self._format = format
self._channels = channels
# Initialize DAP recordings list
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)
# ============================================================
# Batch APIs (passthrough to audio_parser)
# ============================================================
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
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
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)
# Core playback functionality ======================================================
def play_audio(self, aid: int) -> Tuple[int, str, str]:
"""
Play/resume audio with specified AID
Returns:
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")
info = self._channel_info[channel]
if info['paused']:
if info['is_music']:
Mix_ResumeMusic()
else:
Mix_Resume(channel)
info['paused'] = False
info['start_time'] = time.time() - info['paused_position']
return (AP_DS_SUCCESS, "", "")
# DAP recording functionality ======================================================
def play_from_file(self, file_path: str, loops: int = 0, start_pos: float = 0.0) -> Union[int, Tuple[int, str, str]]:
"""
Play audio directly from file, return AID.
Note: .ap-ds-dap files are DAP export records (output format) and are
NOT supported as playback input. Passing one will return
AP_DS_ERR_AUDIO_LOAD_FAILED.
Args:
file_path: Path to audio file
loops: Number of loops
start_pos: Starting position
Returns:
int: Audio ID (AID) on success
Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure
"""
if not isinstance(file_path, (str, bytes, os.PathLike)):
return (AP_DS_ERR_FILE_NOT_FOUND, f"Invalid file path type: {type(file_path).__name__}", "file_path must be a string or os.PathLike")
if not isinstance(loops, int) or isinstance(loops, bool):
return (AP_DS_ERR_UNKNOWN, f"Invalid loops type: {type(loops).__name__}. Expected int.", "loops must be an integer (-1=infinite, 0=once, >0=count)")
if not os.path.exists(file_path):
return (AP_DS_ERR_FILE_NOT_FOUND, f"Audio file not found: {file_path}", "Verify the file path exists and is accessible")
# Generate AID
self._aid_counter += 1
aid = self._aid_counter
# Record to DAP list without playing
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):
music = Mix_LoadMUS(file_path.encode())
if not music:
return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load music file: {file_path}", "Check file format and integrity")
if Mix_PlayMusic(music, loops) != 0:
Mix_FreeMusic(music)
return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to play music: {file_path}", "Check audio device and file format")
channel = -1
self._music_cache[file_path] = music
# Sound effect handling
else:
audio = Mix_LoadWAV(file_path.encode())
if not audio:
return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load audio file: {file_path}", "Check file format and integrity")
channel = Mix_PlayChannel(-1, audio, loops)
if channel == -1:
Mix_FreeChunk(audio)
return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to play audio: {file_path}", "No available audio channels")
self._audio_cache[file_path] = audio
# Record playback information
self._channel_info[channel] = {
'aid': aid,
'start_time': time.time() - start_pos,
'paused': False,
'file_path': file_path,
'is_music': channel == -1,
'loops': loops
}
# Seek to specified position
if start_pos > 0:
self._seek_audio(channel, start_pos)
self._aid_to_filepath[aid] = file_path
return aid
def play_from_memory(self, file_path: str, loops: int = 0, start_pos: float = 0.0) -> Union[int, Tuple[int, str, str]]:
"""
Play audio from memory cache, return AID.
Note: .ap-ds-dap files are DAP export records (output format) and are
NOT supported as playback input.
Args:
file_path: Path to audio file
loops: Number of loops
start_pos: Starting position
Returns:
int: Audio ID (AID) on success
Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure
"""
if not isinstance(file_path, (str, bytes, os.PathLike)):
return (AP_DS_ERR_AUDIO_NOT_LOADED, f"Invalid file path type: {type(file_path).__name__}", "file_path must be a string or os.PathLike")
if not isinstance(loops, int) or isinstance(loops, bool):
return (AP_DS_ERR_UNKNOWN, f"Invalid loops type: {type(loops).__name__}. Expected int.", "loops must be an integer (-1=infinite, 0=once, >0=count)")
# Generate AID
self._aid_counter += 1
aid = self._aid_counter
# Record to DAP list without playing
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:
return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to play music from cache: {file_path}", "Check audio device")
channel = -1
elif file_path in self._audio_cache:
channel = Mix_PlayChannel(-1, self._audio_cache[file_path], loops)
if channel == -1:
return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to play audio from cache: {file_path}", "No available audio channels")
else:
return (AP_DS_ERR_AUDIO_NOT_LOADED, f"Audio not loaded in memory: {file_path}", "Call new_aid() first to load the file")
# Record playback information
self._channel_info[channel] = {
'aid': aid,
'start_time': time.time() - start_pos,
'paused': False,
'file_path': file_path,
'is_music': channel == -1,
'loops': loops
}
if start_pos > 0:
self._seek_audio(channel, start_pos)
self._aid_to_filepath[aid] = file_path
return aid
def new_aid(self, file_path: str) -> Union[int, Tuple[int, str, str]]:
"""
Generate AID for file.
Args:
file_path: Path to audio file
Returns:
int: Audio ID (AID) on success
Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure
"""
if not isinstance(file_path, (str, bytes, os.PathLike)):
return (AP_DS_ERR_FILE_NOT_FOUND, f"Invalid file path type: {type(file_path).__name__}", "file_path must be a string or os.PathLike")
if not os.path.exists(file_path):
return (AP_DS_ERR_FILE_NOT_FOUND, f"Audio file not found: {file_path}", "Verify the file path exists and is accessible")
# Generate AID
self._aid_counter += 1
aid = self._aid_counter
# Record to DAP list without loading
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:
music = Mix_LoadMUS(file_path.encode())
if not music:
return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load music file: {file_path}", "Check file format and integrity")
self._music_cache[file_path] = music
else:
if file_path not in self._audio_cache:
audio = Mix_LoadWAV(file_path.encode())
if not audio:
return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load audio file: {file_path}", "Check file format and integrity")
self._audio_cache[file_path] = audio
self._aid_to_filepath[aid] = file_path
return aid
# Music fade control functionality ======================================================
def fadein_music(self, aid: int, loops: int = -1, ms: int = 0) -> Tuple[int, str, str]:
"""
Fade in music (basic fade in)
Args:
aid: AID of the music file
loops: Number of loops, -1 for infinite, 0 for no loop, >0 for loop count
ms: Fade in time in milliseconds
Returns:
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']:
file_path = info['file_path']
# Record to DAP (important!)
self._add_to_dap_recordings(file_path)
# Load music file
music = self._music_cache.get(file_path)
if not music:
music = Mix_LoadMUS(file_path.encode())
if not music:
return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load music for fadein: {file_path}", "Check file format and integrity")
self._music_cache[file_path] = music
# Stop current playback
Mix_HaltMusic()
# Validate fade parameters
if not isinstance(ms, int) or not isinstance(loops, int):
return (AP_DS_ERR_UNKNOWN, "Invalid fade parameters: ms and loops must be integers", "Check fade parameters (ms: int milliseconds, loops: int)")
# Start fade in
result = Mix_FadeInMusic(music, loops, ms)
if result == 0:
# Update playback info
info['start_time'] = time.time()
info['paused'] = False
info['loops'] = loops
return (AP_DS_SUCCESS, "", "")
else:
error_msg = SDL_GetError().decode() if SDL_GetError() else 'Unknown error'
return (AP_DS_ERR_PLAYBACK_FAILED, f"Mix_FadeInMusic failed: {error_msg}", "Check SDL_mixer compatibility")
return (AP_DS_ERR_INVALID_AID, f"AID {aid} not found or not a music file", "Verify the AID corresponds to a music file")
def fadein_music_pos(self, aid: int, loops: int = -1, ms: int = 0, position: float = 0.0) -> Tuple[int, str, str]:
"""
Fade in music from specified position
Args:
aid: AID of the music file
loops: Number of loops, -1 for infinite
ms: Fade in time in milliseconds
position: Starting position in seconds
Returns:
Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success,
(error_code, error_msg, suggestion) on failure
"""
# Check if function exists
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']:
file_path = info['file_path']
# Record to DAP (important!)
self._add_to_dap_recordings(file_path)
# Load music file
music = self._music_cache.get(file_path)
if not music:
music = Mix_LoadMUS(file_path.encode())
if not music:
return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load music for fadein: {file_path}", "Check file format and integrity")
self._music_cache[file_path] = music
# Stop current playback
Mix_HaltMusic()
# Validate fade parameters
if not isinstance(ms, int) or not isinstance(loops, int) or not isinstance(position, (int, float)):
return (AP_DS_ERR_UNKNOWN, "Invalid fade parameters: ms/loops must be int, position must be a number", "Check fade parameters")
# Start fade in from specified position
result = Mix_FadeInMusicPos(music, loops, ms, position)
if result == 0:
# Update playback info
info['start_time'] = time.time() - position
info['paused'] = False
info['loops'] = loops
return (AP_DS_SUCCESS, "", "")
else:
error_msg = SDL_GetError().decode() if SDL_GetError() else 'Unknown error'
return (AP_DS_ERR_PLAYBACK_FAILED, f"Mix_FadeInMusicPos failed: {error_msg}", "Check SDL_mixer compatibility")
return (AP_DS_ERR_INVALID_AID, f"AID {aid} not found or not a music file", "Verify the AID corresponds to a music file")
def fadeout_music(self, ms: int = 0) -> Tuple[int, str, str]:
"""
Fade out currently playing music
Args:
ms: Fade out time in milliseconds
Returns:
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, "", "")
else:
return (AP_DS_ERR_PLAYBACK_FAILED, "Mix_FadeOutMusic failed or no music playing", "Ensure music is playing before calling fadeout")
def is_music_playing(self) -> bool:
"""
Check if music is currently playing
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:
"""
Check if music is paused
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:
"""
Get fade state of music
Returns:
int: State code
- 0 (MUS_NO_FADING): No fade
- 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()
# ============================================================
# DAP Recording System (v3.1.0+ with O(1) deduplication + fallback)
# ============================================================
def _add_to_dap_recordings(self, file_path: str) -> None:
"""
Add audio file metadata to DAP (Dvs Audio Playlist) records.
Primary: O(1) set-based deduplication.
Fallback: O(n) linear scan if set fails.
Args:
file_path: Path to the audio file
"""
try:
# Get metadata using audio_parser
metadata = self.get_audio_metadata_by_path(file_path)
if not metadata or not isinstance(metadata, dict):
return
# Create DAP record
record = {
'path': file_path,
'duration': metadata.get('duration', 0),
'bitrate': metadata.get('bitrate', 0),
'channels': metadata.get('channels', 2)
}
# Primary: O(1) set-based deduplication
if file_path not in self._dap_records_set:
self._dap_records_set.add(file_path)
self._dap_recordings.append(record)
print(f"📝 Recorded DAP file: {file_path}")
return
except Exception as e:
# Fallback: O(n) linear scan if set fails
print(f"⚠️ DAP set deduplication failed: {e}, falling back to O(n) scan")
# ============================================================
# Fallback: O(n) linear scan (redundant backup)
# ============================================================
try:
metadata = self.get_audio_metadata_by_path(file_path)
if not metadata or not isinstance(metadata, dict):
return
record = {
'path': file_path,
'duration': metadata.get('duration', 0),
'bitrate': metadata.get('bitrate', 0),
'channels': metadata.get('channels', 2)
}
# O(n) linear deduplication
if not any(r.get('path') == file_path for r in self._dap_recordings):
self._dap_recordings.append(record)
print(f"📝 Recorded DAP file (fallback): {file_path}")
except Exception as e:
print(f"⚠️ Failed to record DAP (fallback also failed): {e}")
def save_dap_to_json(self, save_path: str) -> Tuple[int, str, str]:
"""
Save DAP recordings to JSON file. ONLY WHEN USER CALLS THIS FUNCTION.
Args:
save_path: Path to save JSON file
Returns:
Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success,
(error_code, error_msg, suggestion) on failure
"""
try:
# Validate file extension
if not save_path.lower().endswith('.ap-ds-dap'):
return (AP_DS_ERR_DAP_INVALID_EXT,
f"Invalid file extension. Expected '.ap-ds-dap' but got '{os.path.splitext(save_path)[1]}'",
"Use .ap-ds-dap extension for DAP files")
# Save to JSON with UTF-8 encoding
with open(save_path, 'w', encoding='utf-8') as f:
json.dump(self._dap_recordings, f, ensure_ascii=False, indent=2)
print(f"✅ Saved {len(self._dap_recordings)} DAP records to: {save_path}")
return (AP_DS_SUCCESS, "", "")
except Exception as e:
return (AP_DS_ERR_DAP_SAVE_FAILED, f"Error saving DAP to JSON: {str(e)}", "Check write permissions and disk space")
def get_dap_recordings(self) -> List[Dict]:
"""
Get current DAP recordings from memory.
Returns:
List[Dict]: List of DAP records
"""
return self._dap_recordings.copy()
# ============================================================
# clear_dap_recordings() - Clear both list and set
# ============================================================
def clear_dap_recordings(self) -> None:
"""Clear all DAP recordings from memory."""
self._dap_recordings.clear()
self._dap_records_set.clear()
print("🗑️ Cleared all DAP recordings")
def _is_music_file(self, file_path: str) -> bool:
"""
Check if file should be treated as music file.
For WAV files:
- Duration < threshold (default 6s) → sound effect mode
- Duration >= threshold → music mode
For other formats:
MP3, OGG, FLAC → music mode
Others (non-WAV) → sound effect mode
Args:
file_path: Path to audio file
Returns:
bool: True if music mode, False if sound effect mode
"""
ext = os.path.splitext(file_path)[1].lower()
# Non-WAV formats
if ext in ['.mp3', '.ogg', '.flac']:
return True
# WAV files - check duration using existing metadata API
if ext == '.wav':
try:
# Use existing metadata method to get duration
metadata = self.get_audio_metadata_by_path(file_path)
if metadata and 'duration' in metadata:
duration = metadata['duration']
# Use global WAV_THRESHOLD
return duration >= WAV_THRESHOLD
else:
# If metadata not available, default to music mode for safety
print(f"Warning: Could not get duration for WAV file: {file_path}, defaulting to music mode")
return True
except Exception as e:
print(f"Warning: Error getting WAV metadata for {file_path}: {e}")
# Default to music mode for safety if metadata fetch fails
return True
# Other formats (AIF, AU, etc.) - sound effect mode
return False
# Control functionality ======================================================
def pause_audio(self, aid: int) -> Tuple[int, str, str]:
"""
Pause audio with specified AID
Returns:
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")
info = self._channel_info[channel]
if not info['paused']:
if info['is_music']:
Mix_PauseMusic()
else:
Mix_Pause(channel)
info['paused'] = True
info['paused_position'] = time.time() - info['start_time']
return (AP_DS_SUCCESS, "", "")
def stop_audio(self, aid: int) -> Union[float, Tuple[int, str, str]]:
"""
Stop playback and return played duration
Returns:
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")
info = self._channel_info[channel]
played_time = time.time() - info['start_time'] if not info['paused'] else info['paused_position']
if info['is_music']:
Mix_HaltMusic()
else:
Mix_HaltChannel(channel)
del self._channel_info[channel]
return played_time
def seek_audio(self, aid: int, position: float) -> Tuple[int, str, str]:
"""
Seek to specified position (seconds)
Returns:
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")
if not isinstance(position, (int, float)):
return (AP_DS_ERR_UNKNOWN, f"Invalid position type: {type(position).__name__}. Expected int or float.", "Position must be a number (seconds)")
return self._seek_audio(channel, position)
def _seek_audio(self, channel: int, position: float) -> Tuple[int, str, str]:
"""Internal method: seek audio"""
info = self._channel_info.get(channel)
if not info:
return (AP_DS_ERR_INVALID_AID, f"Channel {channel} not found", "Internal error - channel not in tracking")
# Music seeking
if info['is_music']:
Mix_HaltMusic()
# Reuse the cached music object to avoid re-decoding the whole
# file and leaking the previous Mix_Music instance on every seek.
music = self._music_cache.get(info['file_path'])
if not music:
music = Mix_LoadMUS(info['file_path'].encode())
if not music:
return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load music for seeking: {info['file_path']}", "Check file format and integrity")
self._music_cache[info['file_path']] = music
if Mix_PlayMusic(music, info['loops']) != 0:
return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to reload music for seeking: {info['file_path']}", "Check file integrity")
if hasattr(Mix_SetMusicPosition, '__call__'):
Mix_SetMusicPosition(position)
self._channel_info[channel] = {
**info,
'start_time': time.time() - position,
'paused': False
}
return (AP_DS_SUCCESS, "", "")
# Sound effect seeking - not supported
else:
Mix_HaltChannel(channel)
audio = self._audio_cache.get(info['file_path'])
if audio is None:
return (AP_DS_ERR_AUDIO_NOT_LOADED, f"Audio not in cache: {info['file_path']}", "Call new_aid() to reload the file")
new_channel = Mix_PlayChannel(-1, audio, info['loops'])
if new_channel == -1:
return (AP_DS_ERR_PLAYBACK_FAILED, "Failed to replay audio after seek", "No available audio channels")
self._channel_info[new_channel] = {
**info,
'start_time': time.time() - position,
'paused': False
}
if new_channel != channel:
del self._channel_info[channel]
return (AP_DS_SUCCESS, "", "")
def set_volume(self, aid: int, volume: int) -> Tuple[int, str, str]:
"""
Set audio volume
Args:
aid: Audio ID
volume: Volume value (0-128)
Returns:
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")
if not isinstance(volume, int):
return (AP_DS_ERR_INVALID_VOLUME, f"Invalid volume type: {type(volume).__name__} (must be integer 0-128)", "Volume must be an integer between 0 and 128")
if volume < 0 or volume > 128:
return (AP_DS_ERR_INVALID_VOLUME, f"Invalid volume: {volume} (must be 0-128)", "Volume range is 0-128")
info = self._channel_info[channel]
if info['is_music']:
result = Mix_VolumeMusic(volume)
if result != -1:
return (AP_DS_SUCCESS, "", "")
else:
return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to set music volume", "Check audio device")
else:
result = Mix_Volume(channel, volume)
if result != -1:
return (AP_DS_SUCCESS, "", "")
else:
return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to set channel volume", "Check audio device")
def get_volume(self, aid: int) -> Union[int, Tuple[int, str, str]]:
"""
Get current audio volume
Args:
aid: Audio ID
Returns:
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")
info = self._channel_info[channel]
if info['is_music']:
return Mix_VolumeMusic(-1) # -1 means get without setting
else:
return Mix_Volume(channel, -1) # -1 means get without setting
# Helper methods ======================================================
def _find_channel_by_aid(self, aid: int) -> Optional[int]:
"""Find channel by AID"""
for channel, info in self._channel_info.items():
if info['aid'] == aid:
return channel
return None
def _get_file_path_by_aid(self, aid: int) -> Union[str, Tuple[int, str, str]]:
"""Get file path by AID"""
for info in self._channel_info.values():
if info['aid'] == aid:
return info['file_path']
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid")
def get_audio_duration(self, source: Union[str, int], is_file: bool = False) -> Union[int, Tuple[int, str, str]]:
"""
Get the duration of an audio file in seconds
This method supports both file paths and AID (Audio ID) as input sources.
It automatically detects the file format and uses the appropriate parser
to calculate the duration accurately.
Args:
source: Either a file path string or an integer AID
is_file: If True, treats source as file path; if False, as AID
Returns:
int: Duration in seconds (rounded) on success
Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure
Examples:
>>> # Get duration by file path
>>> duration = lib.get_audio_duration("audio/song.mp3", is_file=True)
>>> # Get duration by AID
>>> duration = lib.get_audio_duration(123, is_file=False)
"""
try:
# Case 1: Get duration by AID (Audio ID)
if not is_file and isinstance(source, int):
if source not in self._aid_to_filepath:
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {source}", "Check that the AID is valid")
file_path = self._aid_to_filepath[source]
return self._get_duration_by_filepath(file_path)
# Case 2: Get duration by file path
file_path = str(source)
return self._get_duration_by_filepath(file_path)
except Exception as e:
return (AP_DS_ERR_UNKNOWN, f"Error getting audio duration: {str(e)}", "Check file integrity and try again")
def _get_duration_by_filepath(self, file_path: str) -> Union[int, Tuple[int, str, str]]:
try:
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
duration = get_audio_duration(file_path)
if duration > 0:
return duration
except Exception as e:
print(f"Audio parser error: {e}")
# Fallback: estimate from file size
try:
return int(self.simple_mp3_duration_estimation(file_path))
except:
return (AP_DS_ERR_METADATA_PARSE_FAILED, f"Could not determine duration for {file_path}", "File may be corrupted or unsupported format")
except Exception as e:
return (AP_DS_ERR_UNKNOWN, f"Error calculating audio duration: {str(e)}", "Check file integrity and try again")
def simple_mp3_duration_estimation(self, filename: str) -> float:
"""
Estimate MP3 duration based on file size and common bitrates
This provides a fallback when frame-by-frame parsing fails.
Args:
filename: Path to the MP3 file
Returns:
float: Estimated duration in seconds, 0 on error
"""
try:
file_size = os.path.getsize(filename)
# Estimate audio data size (subtract possible ID3 tag)
audio_data_size = max(file_size - 2048, file_size * 0.98)
# Estimate bitrate based on file size
if file_size < 2 * 1024 * 1024: # < 2MB
bitrate = 128
elif file_size < 5 * 1024 * 1024: # < 5MB
bitrate = 192
elif file_size < 10 * 1024 * 1024: # < 10MB
bitrate = 256
else:
bitrate = 320
# Calculate duration: (file_size_bytes * 8) / (bitrate_bps)
duration = (audio_data_size * 8) / (bitrate * 1000)
return duration
except Exception as e:
print(f"MP3 duration estimation error: {e}")
return 0
def get_audio_metadata_by_aid(self, aid: int) -> Union[Dict, Tuple[int, str, str]]:
"""
Obtain complete metadata of audio files based on AID
Args:
aid: Audio ID
Returns:
Dict: Dictionary containing complete audio metadata on success
Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure
Contains fields: path, format, duration, length, sample_rate, channels, bitrate
"""
try:
if aid not in self._aid_to_filepath:
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid")
file_path = self._aid_to_filepath[aid]
# Opus metadata: use opusplayer (delegate to its class method)
if _is_opus_file(file_path):
opus = self._get_opus_player()
if opus is None:
return (AP_DS_ERR_METADATA_PARSE_FAILED, "Opus player not available", "Install opusplayer module")
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)
if result is None:
return (AP_DS_ERR_METADATA_PARSE_FAILED, f"Failed to parse metadata for: {file_path}", "File may be corrupted or unsupported")
return result
else:
return (AP_DS_ERR_METADATA_PARSE_FAILED, "audio_parser not available", "Install audio_parser module")
except Exception as e:
return (AP_DS_ERR_UNKNOWN, f"Error getting audio metadata by AID: {str(e)}", "Check file integrity and try again")
def get_audio_metadata_by_path(self, file_path: str) -> Union[Dict, Tuple[int, str, str]]:
"""
Directly obtain the complete metadata of an audio file based on its file path
Args:
file_path: Audio file path
Returns:
dict: Dictionary containing complete audio metadata on success
Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure
Fields included: path, format, duration, length, sample_rate, channels, bitrate
"""
try:
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)
if result is None:
return (AP_DS_ERR_METADATA_PARSE_FAILED, f"Failed to parse metadata for: {file_path}", "File may be corrupted or unsupported")
return result
else:
return (AP_DS_ERR_METADATA_PARSE_FAILED, "audio_parser not available", "Install audio_parser module")
except Exception as e:
return (AP_DS_ERR_UNKNOWN, f"Error getting audio metadata by path: {str(e)}", "Check file integrity and try again")
def get_audio_metadata(self, source: Union[str, int], is_file: bool = False) -> Union[Dict, Tuple[int, str, str]]:
"""
Retrieve audio metadata based on AID (Audio Identifier) or audio file path.
Args:
source: AID or audio file path.
is_file: If True, treats source as file path; if False, as AID
Returns:
Dict: Dictionary containing complete audio metadata on success
Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure
Contains fields: path, format, duration, length, sample_rate, channels, bitrate
"""
if is_file or isinstance(source, str):
return self.get_audio_metadata_by_path(str(source))
elif isinstance(source, int):
return self.get_audio_metadata_by_aid(source)
else:
return (AP_DS_ERR_INVALID_SOURCE, f"Invalid source type: {type(source)}. Expected str or int.", "Use file path (str) or AID (int)")
def _get_sample_rate(self, source: Union[str, int]) -> int:
"""Get the actual sample rate from audio metadata.
Args:
source: Audio source (file path string or AID integer)
Returns:
int: Sample rate in Hz. Returns 44100 as fallback if metadata not available.
"""
try:
metadata = self.get_audio_metadata(source, is_file=isinstance(source, str))
if isinstance(metadata, dict) and 'sample_rate' in metadata:
return metadata['sample_rate']
except Exception:
pass
return 44100 # Fallback default value
def _get_channels(self, source: Union[str, int]) -> int:
"""Get the actual channel count from audio metadata.
Args:
source: Audio source (file path string or AID integer)
Returns:
int: Number of audio channels. Returns 2 as fallback if metadata not available.
"""
try:
metadata = self.get_audio_metadata(source, is_file=isinstance(source, str))
if isinstance(metadata, dict) and 'channels' in metadata:
return metadata['channels']
except Exception:
pass
return 2 # Fallback default value
def _get_aid_for_audio(self, file_path: str) -> Union[int, Tuple[int, str, str]]:
"""Find corresponding AID by file path"""
for channel, info in self._channel_info.items():
if info.get('file_path') == file_path and not info.get('is_music', True):
return info.get('aid')
return (AP_DS_ERR_INVALID_AID, f"No AID found for file: {file_path}", "File may not be loaded or is a music file")
def _get_aid_for_music(self, file_path: str) -> Union[int, Tuple[int, str, str]]:
"""Find corresponding AID by music file path"""
for channel, info in self._channel_info.items():
if info.get('file_path') == file_path and info.get('is_music', False):
return info.get('aid')
return (AP_DS_ERR_INVALID_AID, f"No AID found for music file: {file_path}", "File may not be loaded or is not a music file")
def _get_playing_duration(self, aid: int) -> float:
"""Get total duration of playing audio"""
file_path = self._get_file_path_by_aid(aid)
return self._get_file_duration(file_path) if file_path else 0.0
def _get_file_duration(self, file_path: str) -> float:
result = self._get_duration_by_filepath(file_path)
if isinstance(result, tuple):
return 0.0
return float(result)
# Resource management ======================================================
def clear_memory_cache(self) -> None:
"""Clear memory cache"""
for audio in self._audio_cache.values():
if audio:
Mix_FreeChunk(audio)
for music in self._music_cache.values():
if music:
Mix_FreeMusic(music)
self._audio_cache.clear()
self._music_cache.clear()
def cleanup_function(self):
"""Clean up resources"""
self.clear_memory_cache()
Mix_CloseAudio()
SDL_Quit()