1287 lines
53 KiB
Python
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
|
|
# ============================================================================
|