Files
ap_ds_afs/ap_ds/opusplayer.py
T

1287 lines
53 KiB
Python

# -*- coding: utf-8 -*-
"""
ap_ds Opus Support Module (Class-based API, aligned with ap_ds)
================================================================
Decode with libopusfile-0.dll + play with winmm waveOut
Design:
- AudioLibrary class (aligned with ap_ds class-based API)
- Function names copied from ap_ds
- State checks: is_music_playing / is_music_paused / get_music_fading
- Metadata parsing: basic metadata + extended metadata
- Fade-in play / fade-out stop
- AID management
- DLL handling delegated to _opusdll.py (with auto-download)
Key implementation points (verified):
- WAVEHDR structure: dwUser/reserved use c_void_p (8 bytes, DWORD_PTR)
- Multi-buffer (4) to eliminate stuttering
- CALLBACK_EVENT + WaitForSingleObject synchronization
- create_string_buffer to ensure stable pointers
"""
import os
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
# ============ Import DLL handling from _opusdll.py ============
# This imports: opusfile, winmm, kernel32, OpusHead, OpusTags,
# WAVEFORMATEX, WAVEHDR, all constants, and all function bindings
from ._opusdll import (
opusfile, winmm, kernel32,
OpusHead, OpusTags, WAVEFORMATEX, WAVEHDR,
WAVE_FORMAT_PCM, WAVE_MAPPER, CALLBACK_EVENT, WHDR_DONE,
MMSYSERR_NOERROR, WAIT_OBJECT_0,
op_open_file, op_free, op_head, op_tags,
op_channel_count, op_pcm_total, op_bitrate, op_seekable, op_link_count,
op_read_stereo, op_pcm_seek, op_pcm_tell,
waveOutOpen, waveOutPrepareHeader, waveOutWrite, waveOutUnprepareHeader,
waveOutClose, waveOutSetVolume, waveOutGetVolume,
waveOutPause, waveOutRestart, waveOutReset, waveOutGetErrorTextW,
CreateEventW, WaitForSingleObject, ResetEvent, CloseHandle,
import_opus, check_opus_dll, download_opus_libraries,
_opus_dll_error,
)
# ============ Paths ============
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
OPUS_FILE = os.path.join(BASE_DIR, "test.opus")
# ============ Error codes (aligned with ap_ds) ============
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_METADATA_PARSE_FAILED = 1011
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
# ============ Opus-specific error codes (2000+) ============
AP_DS_ERR_OPUS_LIB_LOAD_FAILED = 2001 # libopusfile-0.dll load failed
AP_DS_ERR_OPUS_DLL_DEPENDENCY = 2002 # DLL dependency missing (libogg/libopus etc.)
AP_DS_ERR_OPUS_OPEN_FAILED = 2003 # op_open_file open failed
AP_DS_ERR_OPUS_HEADER_CORRUPT = 2004 # OpusHead header corrupt
AP_DS_ERR_OPUS_TAGS_PARSE_FAILED = 2005 # OpusTags tag parse failed
AP_DS_ERR_OPUS_DECODE_FAILED = 2006 # op_read_stereo decode failed
AP_DS_ERR_OPUS_SEEK_FAILED = 2007 # op_pcm_seek seek failed
AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE = 2008 # bitrate unavailable
AP_DS_ERR_OPUS_NOT_SEEKABLE = 2009 # stream not seekable
AP_DS_ERR_OPUS_CHANNEL_INVALID = 2010 # invalid channel count
# Opus error code info table
OPUS_ERR_INFO = {
AP_DS_ERR_OPUS_LIB_LOAD_FAILED: ("Opus library load failed", "Ensure libopusfile-0.dll exists and is not locked"),
AP_DS_ERR_OPUS_DLL_DEPENDENCY: ("Opus DLL dependency missing", "Ensure libopus-0.dll and libogg-0.dll are in the same directory as libopusfile-0.dll"),
AP_DS_ERR_OPUS_OPEN_FAILED: ("Opus file open failed", "File may be corrupted or not a valid Opus stream"),
AP_DS_ERR_OPUS_HEADER_CORRUPT: ("OpusHead header corrupt", "File header information is invalid or corrupted"),
AP_DS_ERR_OPUS_TAGS_PARSE_FAILED: ("OpusTags tag parse failed", "Tag data is corrupted or in an invalid format"),
AP_DS_ERR_OPUS_DECODE_FAILED: ("Opus decode failed", "Audio data decode error"),
AP_DS_ERR_OPUS_SEEK_FAILED: ("Opus seek failed", "Unable to seek to the specified position"),
AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE: ("Bitrate unavailable", "Unable to determine bitrate for this Opus stream"),
AP_DS_ERR_OPUS_NOT_SEEKABLE: ("Stream not seekable", "This Opus stream does not support seeking"),
AP_DS_ERR_OPUS_CHANNEL_INVALID: ("Invalid channel count", "The channel count of the Opus stream is invalid"),
}
# Opus error message mapping
OP_ERR_MSG = {
-128: "Read error", -127: "Internal error", -126: "Not implemented",
-125: "Invalid argument", -124: "Not an Opus stream", -123: "Header corrupt",
-122: "Version not supported", -121: "Not audio", -120: "Packet corrupt",
-119: "Link corrupt", -118: "Not seekable",
}
# Fade state constants
MUS_NO_FADING = 0
MUS_FADING_IN = 1
MUS_FADING_OUT = 2
# Supported audio formats
SUPPORTED_AUDIO_EXTS = {'.opus', '.ogg', '.wav', '.mp3', '.flac', '.aac'}
def wave_error_str(code):
"""Get Windows wave error message string."""
buf = __import__('ctypes').create_unicode_buffer(256)
waveOutGetErrorTextW(code, buf, 256)
return buf.value
def opus_error_str(error_code):
"""Return description based on opusfile error code."""
return OP_ERR_MSG.get(error_code, f"Unknown Opus error ({error_code})")
def _require_opus_dll():
"""Ensure Opus DLL is loaded, return Opus-specific error tuple on failure.
Returns:
None or (error_code, msg, suggestion)
"""
if not import_opus():
# Distinguish dependency missing (2002) vs library missing (2001)
if _opus_dll_error and ("dependency" in _opus_dll_error.lower() or "libopus-0.dll" in _opus_dll_error or "libogg-0.dll" in _opus_dll_error):
return (AP_DS_ERR_OPUS_DLL_DEPENDENCY,
f"Opus dependency DLL missing: {_opus_dll_error}",
"Ensure libopus-0.dll and libogg-0.dll are in the same directory as libopusfile-0.dll")
return (AP_DS_ERR_OPUS_LIB_LOAD_FAILED,
f"Opus library load failed: {_opus_dll_error}",
"Ensure libopusfile-0.dll exists and its dependencies (libopus-0.dll, libogg-0.dll) are in the same directory")
return None
# ============================================================================
# Module-level metadata helper functions
# ============================================================================
def _open_opus_readonly(file_path):
"""Open opus file read-only (for metadata parsing), return handle or None"""
if not os.path.exists(file_path):
return None
if not import_opus():
return None
err = __import__('ctypes').c_int(0)
of = op_open_file(file_path.encode('utf-8'), __import__('ctypes').byref(err))
return of if of else None
def _get_opus_metadata(file_path):
"""Get basic metadata of Opus file, return dict or error tuple."""
of = _open_opus_readonly(file_path)
if of is None:
return (AP_DS_ERR_OPUS_OPEN_FAILED, f"Failed to open Opus file: {file_path}",
"File may be corrupted or not a valid Opus stream")
try:
total = op_pcm_total(of, -1)
bitrate = op_bitrate(of, -1)
channels = op_channel_count(of, -1)
head = op_head(of, -1)
if not head:
return (AP_DS_ERR_OPUS_HEADER_CORRUPT, f"OpusHead corrupt for: {file_path}",
"File header is invalid or corrupted")
# Check channel count validity
if channels < 1 or channels > 255:
return (AP_DS_ERR_OPUS_CHANNEL_INVALID, f"Invalid channel count: {channels} for {file_path}",
"Opus stream channel count is invalid")
# Check bitrate availability
if bitrate <= 0:
return (AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE, f"Bitrate unavailable for: {file_path}",
"Could not determine bitrate for this Opus stream")
sample_rate = head.contents.input_sample_rate if head else 48000
return {
"path": file_path,
"format": "opus",
"duration": total / 48000.0 if total > 0 else 0.0,
"length": total / 48000.0 if total > 0 else 0.0,
"sample_rate": sample_rate,
"channels": channels,
"bitrate": bitrate,
}
finally:
op_free(of)
def _get_opus_duration(file_path):
"""Get Opus file duration (seconds), return -1 on failure."""
of = _open_opus_readonly(file_path)
if of is None:
return -1
try:
total = op_pcm_total(of, -1)
if total <= 0:
return -1
return total / 48000.0
finally:
op_free(of)
def _get_opus_extended_metadata(file_path):
"""Get extended metadata of Opus file (title/artist/album etc.), return dict or error tuple."""
of = _open_opus_readonly(file_path)
if of is None:
return (AP_DS_ERR_OPUS_OPEN_FAILED, f"Failed to open Opus file: {file_path}",
"File may be corrupted or not a valid Opus stream")
try:
result = {}
links = op_link_count(of)
for li in range(links):
tags = op_tags(of, li)
if tags:
try:
t = tags.contents
if t.vendor:
result["vendor"] = t.vendor.decode('utf-8', 'replace')
for i in range(t.comments):
if t.user_comments[i]:
comment = t.user_comments[i].decode('utf-8', 'replace')
if '=' in comment:
k, v = comment.split('=', 1)
result[k.lower()] = v
else:
result[f"comment_{i}"] = comment
except Exception as e:
return (AP_DS_ERR_OPUS_TAGS_PARSE_FAILED,
f"Failed to parse Opus tags for: {file_path} ({e})",
"Tag data is corrupted or in an invalid format")
return result if result else None
finally:
op_free(of)
def _expand_file_paths(file_paths):
"""Expand file path list: supports file, directory, or list."""
if isinstance(file_paths, (str, os.PathLike)):
p = os.fspath(file_paths)
if os.path.isdir(p):
result = []
for root, _, files in os.walk(p):
for f in files:
if os.path.splitext(f)[1].lower() in SUPPORTED_AUDIO_EXTS:
result.append(os.path.join(root, f))
return result
else:
return [p] if os.path.exists(p) else []
elif isinstance(file_paths, (list, tuple)):
result = []
for item in file_paths:
result.extend(_expand_file_paths(item))
return result
return []
class OpusAudio:
"""ap_ds class-based API (Opus + waveOut implementation)
Function names aligned with ap_ds, but underlying implementation uses
libopusfile for decoding and winmm waveOut for playback.
"""
def __init__(self, frequency=48000, channels=2, volume_pct=80):
self.sample_rate = frequency
self.channels = channels
self.volume_pct = volume_pct
# Playback state
self._hwo = None
self._hEvent = None
self._of = None
self._thread = None
self._fade_thread = None
self._playing = False
self._paused = False
self._stop_flag = False
self._fade_stop_flag = False
self._fading = MUS_NO_FADING
self._played = 0
self._total = 0
self._lock = threading.Lock()
self._decode_error = None
# AID management
self._aid_counter = 0
self._aid_to_filepath = {}
self._filepath_to_aid = {}
self._channel_info = {}
# ============================================================
# Playback API (aligned with ap_ds)
# ============================================================
def play_from_file(self, file_path, loops=0, start_pos=0.0):
"""Play a file, return AID. Returns int AID on success, error tuple 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")
file_path = os.fspath(file_path)
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")
# Playback conflict detection: ensure old thread fully stopped
if self._playing or self._paused:
# Try to stop and wait for the old thread to exit
self._stop_flag = True
self._fade_stop_flag = True
if self._hwo is not None:
waveOutReset(self._hwo)
if self._thread is not None:
self._thread.join(timeout=2)
self._thread = None
self._playing = False
self._paused = False
# Small delay to ensure waveOut fully released
time.sleep(0.05)
# Check Opus DLL
dll_err = _require_opus_dll()
if dll_err is not None:
return dll_err
# Open opus file
import ctypes
err = ctypes.c_int(0)
of = op_open_file(file_path.encode('utf-8'), ctypes.byref(err))
if not of:
opus_err = opus_error_str(err.value)
return (AP_DS_ERR_OPUS_OPEN_FAILED, f"Failed to open Opus file: {file_path} ({opus_err})",
"File may be corrupted or not a valid Opus stream")
# Allocate AID
self._aid_counter += 1
aid = self._aid_counter
self._aid_to_filepath[aid] = file_path
self._filepath_to_aid[file_path] = aid
self._channel_info[aid] = {
"file_path": file_path,
"is_music": True,
"paused": False,
"loops": loops,
"start_time": time.time(),
}
# Set playback state
self._of = of
self.channels = op_channel_count(of, -1)
self._total = op_pcm_total(of, -1)
self._played = 0
self._stop_flag = False
self._paused = False
self._fading = MUS_NO_FADING
self._decode_error = None
# Start playback thread
self._thread = threading.Thread(target=self._play_worker, daemon=True)
self._thread.start()
self._playing = True
# Seek to start position
if start_pos > 0:
self.seek_audio(aid, start_pos)
return aid
def new_aid(self, file_path):
"""Generate AID for a file (without playing)."""
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")
file_path = os.fspath(file_path)
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")
if file_path in self._filepath_to_aid:
aid = self._filepath_to_aid[file_path]
# 如果 _channel_info 里没有 (可能被 stop_audio 删除), 重新添加
if aid not in self._channel_info:
self._channel_info[aid] = {
"file_path": file_path,
"is_music": True,
"paused": False,
"loops": 0,
"start_time": time.time(),
}
return aid
self._aid_counter += 1
aid = self._aid_counter
self._aid_to_filepath[aid] = file_path
self._filepath_to_aid[file_path] = aid
self._channel_info[aid] = {
"file_path": file_path,
"is_music": True,
"paused": False,
"loops": 0,
"start_time": time.time(),
}
return aid
def play_audio(self, aid):
"""Play/resume audio with the specified AID."""
if aid not in self._channel_info:
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}",
"Check that the AID is valid and the audio is loaded")
# Resume if paused
if self._paused and self._hwo is not None:
waveOutRestart(self._hwo)
self._paused = False
self._channel_info[aid]["paused"] = False
return (AP_DS_SUCCESS, "", "")
def play_from_memory(self, file_path, loops=0, start_pos=0.0):
"""Play Opus from memory (delegates to play_from_file for Opus)."""
return self.play_from_file(file_path, loops, start_pos)
# ============================================================
# Playback control API (aligned with ap_ds)
# ============================================================
def pause_audio(self, aid):
"""Pause audio with the specified AID."""
if aid not in self._channel_info:
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}",
"Check that the AID is valid and the audio is loaded")
if self._hwo is not None and self._playing and not self._paused:
waveOutPause(self._hwo)
self._paused = True
self._channel_info[aid]["paused"] = True
return (AP_DS_SUCCESS, "", "")
def stop_audio(self, aid):
"""Stop playback, return played duration."""
if aid not in self._channel_info:
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}",
"Check that the AID is valid and the audio is loaded")
played_time = self._played / self.sample_rate
# Set stop flags so the playback thread cleans up safely
self._stop_flag = True
self._fade_stop_flag = True
if aid in self._channel_info:
del self._channel_info[aid]
# Wait for the playback thread to fully exit (it handles waveOutClose/op_free)
if self._thread is not None:
self._thread.join(timeout=3)
self._thread = None
return played_time
def _stop_playback(self):
"""Safely stop the currently running playback thread (if any)."""
if not (self._playing or self._paused):
return
self._stop_flag = True
self._fade_stop_flag = True
if self._hwo is not None:
try:
waveOutReset(self._hwo)
except Exception:
pass
if self._thread is not None:
self._thread.join(timeout=2)
self._thread = None
self._playing = False
self._paused = False
time.sleep(0.05)
def seek_audio(self, aid, position):
"""Seek to the specified position (seconds)."""
if aid not in self._channel_info:
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)")
file_path = self._channel_info[aid]["file_path"]
position = max(0.0, float(position))
# Determine if a stream is seekable (open a probe handle if needed)
probe_of = self._of
needs_probe = probe_of is None
if needs_probe:
import ctypes as _ct
_err = _ct.c_int(0)
probe_of = op_open_file(file_path.encode('utf-8'), _ct.byref(_err))
if not probe_of:
return (AP_DS_ERR_OPUS_OPEN_FAILED, f"Failed to open Opus file: {file_path}",
"File may be corrupted or not a valid Opus stream")
try:
if not op_seekable(probe_of):
return (AP_DS_ERR_OPUS_NOT_SEEKABLE, "Opus stream is not seekable",
"This Opus stream does not support seeking")
finally:
if needs_probe:
op_free(probe_of)
# Restart playback from the new position so buffered data is reset.
# 1) Stop any current playback thread safely.
self._stop_playback()
# 2) Open a fresh opus handle (the stopped playback thread freed _of).
import ctypes
err = ctypes.c_int(0)
of = op_open_file(file_path.encode('utf-8'), ctypes.byref(err))
if not of:
return (AP_DS_ERR_OPUS_OPEN_FAILED, f"Failed to open Opus file: {file_path}",
"File may be corrupted or not a valid Opus stream")
target_sample = int(position * self.sample_rate)
ret = op_pcm_seek(of, target_sample)
if ret != 0:
op_free(of)
return (AP_DS_ERR_OPUS_SEEK_FAILED, f"Opus seek failed at position {position}",
"The Opus stream may not support seeking to this position")
self._of = of
self._played = target_sample
self._total = op_pcm_total(of, -1)
self._stop_flag = False
self._paused = False
self._decode_error = None
# 3) Always restart the playback thread so that seek resumes playback
# from the new position (even if fade-out had previously stopped it).
self._thread = threading.Thread(target=self._play_worker, daemon=True)
self._thread.start()
self._playing = True
return (AP_DS_SUCCESS, "", "")
# ============================================================
# Volume API (aligned with ap_ds, 0~128)
# ============================================================
def set_volume(self, aid, volume):
"""Set volume (0~128, aligned with ap_ds)."""
if aid not in self._channel_info:
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")
# Convert to 0~100 (for waveOut)
pct = int(volume / 128 * 100)
self.volume_pct = pct
if self._hwo is not None:
vol = int(0xFFFF * pct / 100)
vol_dw = (vol << 16) | vol
err = waveOutSetVolume(self._hwo, vol_dw)
if err != MMSYSERR_NOERROR:
return (AP_DS_ERR_PLAYBACK_FAILED, f"Set volume failed: {wave_error_str(err)}",
"Check audio device")
return (AP_DS_SUCCESS, "", "")
def get_volume(self, aid):
"""Get volume (0~128, aligned with ap_ds)."""
if aid not in self._channel_info:
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}",
"Check that the AID is valid and the audio is loaded")
if self._hwo is None:
return int(self.volume_pct / 100 * 128)
import ctypes
cur = __import__('ctypes.wintypes', fromlist=['DWORD']).DWORD()
err = waveOutGetVolume(self._hwo, ctypes.byref(cur))
if err != MMSYSERR_NOERROR:
return int(self.volume_pct / 100 * 128)
lv = cur.value & 0xFFFF
rv = (cur.value >> 16) & 0xFFFF
avg = (lv + rv) // 2
pct = int(avg / 65535 * 100)
return int(pct / 100 * 128)
# ============================================================
# State check API (aligned with ap_ds)
# ============================================================
def is_music_playing(self):
"""Check if music is currently playing."""
return self._playing and self._thread is not None and self._thread.is_alive()
def is_music_paused(self):
"""Check if music is paused."""
return self._paused
def get_music_fading(self):
"""Get fade state: 0=none, 1=fading in, 2=fading out."""
return self._fading
# ============================================================
# Fade API (fade-in play / fade-out stop)
# ============================================================
def fadein_music(self, aid, loops=-1, ms=0):
"""Fade-in play: start from silence, gradually increase volume to target."""
if aid not in self._channel_info:
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}",
"Check that the AID is valid and the audio is loaded")
# Fade-in must STOP any current playback first, then start a fresh
# playback (new AID) from silence and ramp the volume up.
self._stop_playback()
target = self.volume_pct
old_volume = self.volume_pct
self.volume_pct = 0
file_path = self._aid_to_filepath.get(aid, OPUS_FILE)
result = self.play_from_file(file_path)
if isinstance(result, tuple):
self.volume_pct = old_volume
return result
self._fading = MUS_FADING_IN
self._fade_stop_flag = False
self._fade_thread = threading.Thread(
target=self._fade_worker, args=(0, target, (2000 if not isinstance(ms, (int, float)) or ms <= 0 else ms), MUS_FADING_IN), daemon=True)
self._fade_thread.start()
return (AP_DS_SUCCESS, "", "")
def fadein_music_pos(self, aid, loops=-1, ms=0, position=0.0):
"""Fade-in play from a specified position."""
if aid not in self._channel_info:
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, "Invalid position type for fadein_music_pos",
"position must be a number (seconds)")
result = self.fadein_music(aid, loops, ms)
if isinstance(result, tuple) and result[0] != AP_DS_SUCCESS:
return result
if position > 0:
self.seek_audio(aid, position)
return (AP_DS_SUCCESS, "", "")
def fadeout_music(self, ms=0):
"""Fade-out stop: gradually decrease volume to 0, then stop playback."""
if not self._playing:
return (AP_DS_ERR_PLAYBACK_FAILED, "No music playing", "Ensure music is playing")
if self._fading != MUS_NO_FADING:
return (AP_DS_ERR_PLAYBACK_FAILED, "Fade already in progress", "Wait for fade to finish")
start = self.get_volume(list(self._channel_info.keys())[0]) if self._channel_info else self.volume_pct
self._fading = MUS_FADING_OUT
self._fade_stop_flag = False
self._fade_thread = threading.Thread(
target=self._fade_worker, args=(start, 0, (2000 if not isinstance(ms, (int, float)) or ms <= 0 else ms), MUS_FADING_OUT), daemon=True)
self._fade_thread.start()
return (AP_DS_SUCCESS, "", "")
# ============================================================
# Metadata API (aligned with ap_ds)
# ============================================================
def get_audio_duration(self, source, is_file=False):
"""Get duration (seconds). Supports file path or AID."""
file_path = self._resolve_source(source, is_file)
if isinstance(file_path, tuple):
return file_path
duration = _get_opus_duration(file_path)
if duration < 0:
return (AP_DS_ERR_METADATA_PARSE_FAILED, f"Failed to parse duration for: {file_path}",
"File may be corrupted or unsupported")
return int(duration)
def get_audio_metadata_by_path(self, file_path):
"""Get complete metadata by file path."""
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")
meta = _get_opus_metadata(file_path)
if isinstance(meta, tuple):
return meta # Already an error tuple (Opus-specific error code)
if meta is None:
return (AP_DS_ERR_METADATA_PARSE_FAILED, f"Failed to parse metadata for: {file_path}",
"File may be corrupted or unsupported")
return meta
def get_audio_metadata_by_aid(self, aid):
"""Get complete metadata by AID."""
if aid not in self._aid_to_filepath:
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}",
"Check that the AID is valid")
return self.get_audio_metadata_by_path(self._aid_to_filepath[aid])
def get_audio_metadata(self, source, is_file=False):
"""Get metadata. Supports file path or AID."""
if is_file or isinstance(source, str):
return self.get_audio_metadata_by_path(source)
elif isinstance(source, int):
return self.get_audio_metadata_by_aid(source)
return (AP_DS_ERR_INVALID_SOURCE, f"Invalid source type: {type(source).__name__}. Expected str or int.",
"Use file path (str) or AID (int)")
def get_audio_extended_metadata(self, file_path):
"""Get extended metadata (artist/title/album etc.)."""
return _get_opus_extended_metadata(file_path)
def _get_sample_rate(self, source):
"""Get sample rate (default 48000)."""
meta = self.get_audio_metadata(source, is_file=isinstance(source, str))
if isinstance(meta, dict) and 'sample_rate' in meta:
return meta['sample_rate']
return 48000
def _get_channels(self, source):
"""Get channel count (default 2)."""
meta = self.get_audio_metadata(source, is_file=isinstance(source, str))
if isinstance(meta, dict) and 'channels' in meta:
return meta['channels']
return 2
# ============================================================
# Batch API (aligned with ap_ds)
# ============================================================
def batch_get_metadata(self, file_paths, max_workers=None, show_progress=False):
"""Batch parse metadata."""
files = _expand_file_paths(file_paths)
if not files:
return []
if max_workers is None:
max_workers = min(os.cpu_count() or 4, len(files))
results = []
total = len(files)
completed = 0
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_path = {executor.submit(_get_opus_metadata, p): p for p in files}
for future in as_completed(future_to_path):
completed += 1
if show_progress and completed % 10 == 0:
print(f"Progress: {completed}/{total} files parsed")
try:
meta = future.result()
if isinstance(meta, dict):
results.append(meta)
elif show_progress:
print(f"⚠️ Parse failed: {os.path.basename(future_to_path[future])}")
except Exception as e:
if show_progress:
print(f"❌ Parse error: {e}")
if show_progress:
print(f"✅ Batch parse complete: {len(results)}/{total} files successful")
return results
def batch_get_duration(self, file_paths, max_workers=None):
"""Batch get durations."""
metadata_list = self.batch_get_metadata(file_paths, max_workers=max_workers)
return {item["path"]: item["duration"] for item in metadata_list}
def batch_get_metadata_by_type(self, file_paths, file_type, max_workers=None):
"""Batch parse by format."""
file_type = file_type.lower().lstrip(".")
all_results = self.batch_get_metadata(file_paths, max_workers=max_workers)
return [r for r in all_results if r.get("format", "").lower() == file_type]
# ============================================================
# Helper methods (aligned with ap_ds)
# ============================================================
def _find_channel_by_aid(self, aid):
"""Find channel by AID (returns aid itself)."""
return aid if aid in self._channel_info else None
def _get_file_path_by_aid(self, aid):
"""Get file path by AID."""
if aid not in self._aid_to_filepath:
return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}",
"Check that the AID is valid")
return self._aid_to_filepath[aid]
def _is_music_file(self, file_path):
"""Check if file is a music file (Opus/OGG/FLAC/MP3 are True)."""
ext = os.path.splitext(file_path)[1].lower()
return ext in ('.opus', '.ogg', '.flac', '.mp3')
def _resolve_source(self, source, is_file):
"""Resolve source to a file path."""
if is_file or isinstance(source, str):
return os.fspath(source)
elif 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")
return self._aid_to_filepath[source]
return (AP_DS_ERR_INVALID_SOURCE, f"Invalid source type: {type(source).__name__}",
"Use file path (str) or AID (int)")
# ============================================================
# Resource management (aligned with ap_ds)
# ============================================================
def cleanup_function(self):
"""Release all resources."""
self._stop_flag = True
self._fade_stop_flag = True
if self._hwo is not None:
waveOutReset(self._hwo)
if self._thread is not None:
self._thread.join(timeout=2)
self._thread = None
if self._fade_thread is not None:
self._fade_thread.join(timeout=2)
self._fade_thread = None
if self._of is not None:
op_free(self._of)
self._of = None
self._hwo = None
self._hEvent = None
self._playing = False
self._paused = False
# ============================================================
# Internal implementation
# ============================================================
def _play_worker(self):
"""Playback thread (non-blocking) - cross-platform dispatch"""
if sys.platform == "win32":
self._play_worker_windows()
elif sys.platform.startswith("linux"):
self._play_worker_linux()
elif sys.platform == "darwin":
self._play_worker_macos()
else:
self._playing = False
self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED,
f"Unsupported platform: {sys.platform}",
"Opus playback is supported on Windows, Linux, and macOS")
def _play_worker_windows(self):
"""Playback thread (Windows) - libopusfile + winmm waveOut"""
import ctypes
NUM_BUFFERS = 4
BLOCK_SAMPLES = self.sample_rate // 20 # 50ms
channels = self.channels
of = self._of
self._hEvent = CreateEventW(None, False, False, None)
fmt = WAVEFORMATEX()
fmt.wFormatTag = WAVE_FORMAT_PCM
fmt.nChannels = channels
fmt.nSamplesPerSec = self.sample_rate
fmt.wBitsPerSample = 16
fmt.nBlockAlign = channels * 2
fmt.nAvgBytesPerSec = self.sample_rate * fmt.nBlockAlign
fmt.cbSize = 0
self._hwo = __import__('ctypes.wintypes', fromlist=['HANDLE']).HANDLE()
err = waveOutOpen(ctypes.byref(self._hwo), WAVE_MAPPER, ctypes.byref(fmt),
self._hEvent, 0, CALLBACK_EVENT)
if err != MMSYSERR_NOERROR:
self._playing = False
return
# Set initial volume (0~100)
pct = self.volume_pct
vol = int(0xFFFF * pct / 100)
waveOutSetVolume(self._hwo, (vol << 16) | vol)
bufs = [ctypes.create_string_buffer(BLOCK_SAMPLES * channels * 2) for _ in range(NUM_BUFFERS)]
hdrs = [WAVEHDR() for _ in range(NUM_BUFFERS)]
for i in range(NUM_BUFFERS):
hdrs[i].lpData = ctypes.cast(bufs[i], __import__('ctypes.wintypes', fromlist=['LPSTR']).LPSTR)
hdrs[i].dwBufferLength = BLOCK_SAMPLES * channels * 2
hdrs[i].dwFlags = 0
valid = [0] * NUM_BUFFERS
in_queue = [False] * NUM_BUFFERS
queued = 0
finished = False
def decode_to(idx):
nonlocal finished
pcm = (ctypes.c_int16 * (BLOCK_SAMPLES * channels))()
n = op_read_stereo(of, pcm, BLOCK_SAMPLES)
if n < 0:
# Decode error
self._decode_error = (AP_DS_ERR_OPUS_DECODE_FAILED,
f"Opus decode failed: {opus_error_str(n)}",
"Audio data is corrupted or the Opus stream is invalid")
finished = True
return 0
if n <= 0:
finished = True
return 0
nbytes = n * channels * 2
ctypes.memmove(bufs[idx], pcm, nbytes)
hdrs[idx].dwBufferLength = nbytes
valid[idx] = n
return n
def submit(idx):
nonlocal queued
hdrs[idx].dwFlags = 0
err = waveOutPrepareHeader(self._hwo, ctypes.byref(hdrs[idx]), ctypes.sizeof(WAVEHDR))
if err != MMSYSERR_NOERROR:
return False
err = waveOutWrite(self._hwo, ctypes.byref(hdrs[idx]), ctypes.sizeof(WAVEHDR))
if err != MMSYSERR_NOERROR:
return False
in_queue[idx] = True
queued += 1
with self._lock:
self._played += valid[idx]
return True
# Initial submit
for i in range(NUM_BUFFERS):
if decode_to(i) > 0:
if not submit(i):
break
# Playback loop
try:
while queued > 0 and not self._stop_flag:
ret = WaitForSingleObject(self._hEvent, 100)
for i in range(NUM_BUFFERS):
if in_queue[i] and (hdrs[i].dwFlags & WHDR_DONE):
waveOutUnprepareHeader(self._hwo, ctypes.byref(hdrs[i]), ctypes.sizeof(WAVEHDR))
hdrs[i].dwFlags = 0
in_queue[i] = False
queued -= 1
if not finished and not self._stop_flag:
if decode_to(i) > 0:
submit(i)
finally:
# Unprepare any remaining queued buffers
for i in range(NUM_BUFFERS):
if in_queue[i]:
waveOutUnprepareHeader(self._hwo, ctypes.byref(hdrs[i]), ctypes.sizeof(WAVEHDR))
# Reset the device first (stop playback) then close
try:
waveOutReset(self._hwo)
except Exception:
pass
try:
waveOutClose(self._hwo)
except Exception:
pass
try:
CloseHandle(self._hEvent)
except Exception:
pass
self._hwo = None
self._hEvent = None
self._playing = False
if of is not None:
try:
op_free(of)
except Exception:
pass
self._of = None
def _play_worker_linux(self):
"""Playback thread (Linux) - direct ALSA via libasound.so (verified)"""
import ctypes as _ct
NUM_BUFFERS = 4
BLOCK_SAMPLES = self.sample_rate // 20 # 50ms
channels = self.channels
of = self._of
# Load ALSA library
try:
alsa = _ct.CDLL("libasound.so.2")
except Exception as e:
self._playing = False
self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED,
f"Failed to load ALSA: {e}",
"Install libasound2: sudo apt-get install libasound2")
return
# ALSA constants
SND_PCM_STREAM_PLAYBACK = 0
SND_PCM_FORMAT_S16_LE = 2
SND_PCM_ACCESS_RW_INTERLEAVED = 3
# Configure ALSA functions
alsa.snd_pcm_open.restype = _ct.c_int
alsa.snd_pcm_open.argtypes = [_ct.POINTER(_ct.c_void_p), _ct.c_char_p, _ct.c_int, _ct.c_int]
alsa.snd_pcm_set_params.restype = _ct.c_int
alsa.snd_pcm_set_params.argtypes = [
_ct.c_void_p, _ct.c_int, _ct.c_int, _ct.c_uint, _ct.c_uint,
_ct.c_int, _ct.c_uint]
alsa.snd_pcm_writei.restype = _ct.c_long
alsa.snd_pcm_writei.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_ulong]
alsa.snd_pcm_drain.restype = _ct.c_int
alsa.snd_pcm_drain.argtypes = [_ct.c_void_p]
alsa.snd_pcm_close.restype = _ct.c_int
alsa.snd_pcm_close.argtypes = [_ct.c_void_p]
alsa.snd_pcm_recover.restype = _ct.c_int
alsa.snd_pcm_recover.argtypes = [_ct.c_void_p, _ct.c_int, _ct.c_int]
# Open PCM device
pcm = _ct.c_void_p()
ret = alsa.snd_pcm_open(_ct.byref(pcm), b"default", SND_PCM_STREAM_PLAYBACK, 0)
if ret != 0:
self._playing = False
self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED,
f"snd_pcm_open failed: {ret}",
"No audio device available or no permission")
return
# Set parameters
ret = alsa.snd_pcm_set_params(
pcm, SND_PCM_FORMAT_S16_LE, SND_PCM_ACCESS_RW_INTERLEAVED,
channels, self.sample_rate, 1, 500000
)
if ret != 0:
self._playing = False
self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED,
f"snd_pcm_set_params failed: {ret}",
"Audio device does not support these parameters")
alsa.snd_pcm_close(pcm)
return
# Decode and play via ALSA
pcm_buf = (_ct.c_int16 * (BLOCK_SAMPLES * channels))()
total_played = 0
try:
while not self._stop_flag:
n = op_read_stereo(of, pcm_buf, BLOCK_SAMPLES)
if n < 0:
self._decode_error = (AP_DS_ERR_OPUS_DECODE_FAILED,
f"Opus decode failed: {opus_error_str(n)}",
"Audio data is corrupted")
break
if n <= 0:
break
# Write to ALSA
frames_written = 0
while frames_written < n:
ret = alsa.snd_pcm_writei(
pcm, _ct.byref(pcm_buf, frames_written * channels * 2),
n - frames_written)
if ret < 0:
if ret == -32: # EPIPE (underrun)
alsa.snd_pcm_recover(pcm, ret, 1)
continue
break
frames_written += ret
with self._lock:
self._played += n
total_played += n
except Exception as e:
pass
finally:
try:
alsa.snd_pcm_drain(pcm)
except Exception:
pass
try:
alsa.snd_pcm_close(pcm)
except Exception:
pass
self._playing = False
if of is not None:
op_free(of)
self._of = None
def _play_worker_macos(self):
"""Playback thread (macOS) - Core Audio AudioQueue (Apple official C impl)"""
import ctypes as _ct
import time as _time
NUM_BUFFERS = 4
BLOCK_SAMPLES = self.sample_rate // 20 # 50ms
channels = self.channels
of = self._of
# Load AudioToolbox framework (system built-in on macOS)
try:
at = _ct.CDLL('/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox')
except Exception as e:
self._playing = False
self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED,
f"Failed to load AudioToolbox: {e}",
"macOS AudioToolbox should be system built-in")
return
# --- AudioStreamBasicDescription (C struct) ---
class AudioStreamBasicDescription(_ct.Structure):
_fields_ = [
("mSampleRate", _ct.c_double),
("mFormatID", _ct.c_uint32),
("mFormatFlags", _ct.c_uint32),
("mBytesPerPacket", _ct.c_uint32),
("mFramesPerPacket", _ct.c_uint32),
("mBytesPerFrame", _ct.c_uint32),
("mChannelsPerFrame", _ct.c_uint32),
("mBitsPerChannel", _ct.c_uint32),
("mReserved", _ct.c_uint32),
]
# --- AudioQueueBuffer (Apple known layout) ---
class AudioQueueBuffer(_ct.Structure):
_fields_ = [
("mAudioDataBytesCapacity", _ct.c_uint32),
("mAudioDataByteSize", _ct.c_uint32),
("mAudioData", _ct.c_void_p),
("mPacketDescriptionCapacity", _ct.c_uint32),
("mPacketDescriptionCount", _ct.c_uint32),
("mPacketDescriptions", _ct.c_void_p),
]
AudioQueueRef = _ct.c_void_p
AudioQueueBufferRef = _ct.POINTER(AudioQueueBuffer)
# --- Constants (from CoreAudioTypes.h / AudioQueue.h) ---
kAudioFormatLinearPCM = 0x6C70636D # 'lpcm'
kAudioFormatFlagIsSignedInteger = 0x00000001
kAudioFormatFlagIsPacked = 0x00000002
kAudioQueueParam_Volume = 1 # AudioQueueParameterID for volume
# --- Configure AudioQueue functions (Apple official) ---
at.AudioQueueNewOutput.restype = _ct.c_int
at.AudioQueueNewOutput.argtypes = [
_ct.POINTER(AudioStreamBasicDescription), # inFormat
_ct.c_void_p, # inCallbackProc
_ct.c_void_p, # inUserData
_ct.c_void_p, # inCallbackRunLoop
_ct.c_void_p, # inCallbackRunLoopMode
_ct.c_uint32, # inFlags
_ct.POINTER(AudioQueueRef), # outAQ
]
at.AudioQueueAllocateBuffer.restype = _ct.c_int
at.AudioQueueAllocateBuffer.argtypes = [
AudioQueueRef, _ct.c_uint32, _ct.POINTER(AudioQueueBufferRef)]
at.AudioQueueEnqueueBuffer.restype = _ct.c_int
at.AudioQueueEnqueueBuffer.argtypes = [
AudioQueueRef, AudioQueueBufferRef, _ct.c_uint32, _ct.c_void_p]
at.AudioQueueStart.restype = _ct.c_int
at.AudioQueueStart.argtypes = [AudioQueueRef, _ct.POINTER(_ct.c_uint32)]
at.AudioQueueStop.restype = _ct.c_int
at.AudioQueueStop.argtypes = [AudioQueueRef, _ct.c_int]
at.AudioQueueDispose.restype = _ct.c_int
at.AudioQueueDispose.argtypes = [AudioQueueRef, _ct.c_int]
at.AudioQueueSetParameter.restype = _ct.c_int
at.AudioQueueSetParameter.argtypes = [AudioQueueRef, _ct.c_uint32, _ct.c_float]
# --- Set up PCM format (like C: AudioStreamBasicDescription) ---
fmt = AudioStreamBasicDescription()
fmt.mSampleRate = self.sample_rate
fmt.mFormatID = kAudioFormatLinearPCM
fmt.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked
fmt.mBytesPerPacket = channels * 2
fmt.mFramesPerPacket = 1
fmt.mBytesPerFrame = channels * 2
fmt.mChannelsPerFrame = channels
fmt.mBitsPerChannel = 16
fmt.mReserved = 0
# --- Shared state (like C: AQPlayerState) ---
buffer_size = BLOCK_SAMPLES * channels * 2
state = {
'of': of,
'block_samples': BLOCK_SAMPLES,
'channels': channels,
'buffer_size': buffer_size,
'mIsRunning': True, # like C: aqData.mIsRunning
'mCurrentPacket': 0, # like C: packet index
'error': None,
'player': self,
}
# --- Callback type (like C: AudioQueueOutputCallback) ---
CALLBACK_TYPE = _ct.CFUNCTYPE(None, AudioQueueRef, AudioQueueBufferRef)
@CALLBACK_TYPE
def HandleOutputBuffer(inAQ, inBuffer):
"""AudioQueue output callback (like C: HandleOutputBuffer).
Decodes Opus and fills the buffer with PCM data.
"""
if not state['mIsRunning']:
return
# Decode next block (like C: AudioFileReadPackets)
pcm = (_ct.c_int16 * (state['block_samples'] * state['channels']))()
n = op_read_stereo(state['of'], pcm, state['block_samples'])
if n < 0:
state['error'] = f"decode error {n}"
state['mIsRunning'] = False
return
if n <= 0:
# No more data -> stop (like C: AudioQueueStop)
at.AudioQueueStop(inAQ, False)
state['mIsRunning'] = False
return
# Copy PCM to buffer's mAudioData (like C: inBuffer->mAudioData)
data_bytes = n * state['channels'] * 2
_ct.memmove(inBuffer.contents.mAudioData, pcm, data_bytes)
inBuffer.contents.mAudioDataByteSize = data_bytes
# Enqueue buffer (like C: AudioQueueEnqueueBuffer)
at.AudioQueueEnqueueBuffer(inAQ, inBuffer, 0, None)
state['mCurrentPacket'] += n
with state['player']._lock:
state['player']._played += n
# --- Create AudioQueue (like C: AudioQueueNewOutput) ---
queue = AudioQueueRef()
ret = at.AudioQueueNewOutput(
_ct.byref(fmt), HandleOutputBuffer, None, None, None, 0,
_ct.byref(queue))
if ret != 0:
self._playing = False
self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED,
f"AudioQueueNewOutput failed: {ret}",
"Could not create audio output queue")
return
# --- Set volume (like C: AudioQueueSetParameter) ---
gain = self.volume_pct / 100.0
at.AudioQueueSetParameter(queue, kAudioQueueParam_Volume, gain)
# --- Allocate buffers and prime (like C: loop + HandleOutputBuffer) ---
buffers = []
for _ in range(NUM_BUFFERS):
buf = AudioQueueBufferRef()
ret = at.AudioQueueAllocateBuffer(queue, buffer_size, _ct.byref(buf))
if ret != 0:
break
buffers.append(buf)
# Prime: fill all buffers (like C: HandleOutputBuffer pre-fill)
for buf in buffers:
HandleOutputBuffer(queue, buf)
# --- Start playback (like C: AudioQueueStart) ---
ret = at.AudioQueueStart(queue, None)
if ret != 0:
self._playing = False
self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED,
f"AudioQueueStart failed: {ret}",
"Could not start audio queue")
at.AudioQueueDispose(queue, 1)
return
# --- Wait for playback to complete (like C: CFRunLoopRunInMode) ---
try:
while state['mIsRunning'] and not self._stop_flag:
_time.sleep(0.05)
# Wait for remaining buffers to drain
if not self._stop_flag:
_time.sleep(0.3)
except Exception:
pass
finally:
# --- Cleanup (like C: AudioQueueDispose) ---
at.AudioQueueStop(queue, 1)
at.AudioQueueDispose(queue, 1)
self._playing = False
if of is not None:
op_free(of)
self._of = None
def _fade_worker(self, start_vol, end_vol, ms, fade_type):
"""Fade in/out worker thread (volume gradient)"""
steps = 50
step_ms = ms / steps
step_delta = (end_vol - start_vol) / steps
try:
for i in range(1, steps + 1):
if self._fade_stop_flag:
break
current = start_vol + step_delta * i
# Directly set waveOut volume (0~100)
pct = max(0, min(100, int(current)))
if self._hwo is not None:
vol = int(0xFFFF * pct / 100)
waveOutSetVolume(self._hwo, (vol << 16) | vol)
time.sleep(step_ms / 1000.0)
finally:
self._fading = MUS_NO_FADING
if fade_type == MUS_FADING_OUT:
# Fade-out finished: stop playback. Restore volume_pct to the
# pre-fade level so that any later play/seek/fadein has audible
# volume (otherwise it would stay at 0 and be silent).
self.volume_pct = max(0, min(100, int(start_vol)))
if not self._fade_stop_flag:
self._stop_flag = True
if self._hwo is not None:
waveOutReset(self._hwo)
else:
# Fade-in finished: volume_pct reaches the target (end_vol).
self.volume_pct = max(0, min(100, int(end_vol)))
# ============================================================================
# TUI main program test
# ============================================================================