diff --git a/LXST/Filters.def b/LXST/Filters.def new file mode 100644 index 0000000..1ca2e84 --- /dev/null +++ b/LXST/Filters.def @@ -0,0 +1,4 @@ +EXPORTS +highpass_filter +lowpass_filter +agc_process \ No newline at end of file diff --git a/LXST/Filters.py b/LXST/Filters.py index c5c870d..c08ec95 100644 --- a/LXST/Filters.py +++ b/LXST/Filters.py @@ -16,16 +16,16 @@ else: ffi = FFI() try: - # Disable native filterlib loading on Windows - # for now due to strange linking behaviour, - # but allow local compilation if the user has - # a C compiler installed. if not RNS.vendor.platformutils.is_windows(): filterlib_spec = find_spec("LXST.filterlib") if not filterlib_spec or filterlib_spec.origin == None: raise ImportError("Could not locate pre-compiled LXST.filterlib module") with open(os.path.join(c_src_path, "Filters.h"), "r") as f: ffi.cdef(f.read()) native_functions = ffi.dlopen(filterlib_spec.origin) USE_NATIVE_FILTERS = True + else: + with open(os.path.join(c_src_path, "Filters.h"), "r") as f: ffi.cdef(f.read()) + native_functions = ffi.dlopen(os.path.join(c_src_path, "filterlib.dll")) + USE_NATIVE_FILTERS = True except Exception as e: RNS.log(f"Could not load pre-compiled LXST filters library. The contained exception was: {e}", RNS.LOG_WARNING) diff --git a/LXST/Platforms/android/soundcard.py b/LXST/Platforms/android/soundcard.py index 2639157..b7e22c9 100644 --- a/LXST/Platforms/android/soundcard.py +++ b/LXST/Platforms/android/soundcard.py @@ -1,3 +1,33 @@ +# Reticulum License +# +# Copyright (c) 2025 Mark Qvist +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# - The Software shall not be used in any kind of system which includes amongst +# its functions the ability to purposefully do harm to human beings. +# +# - The Software shall not be used, directly or indirectly, in the creation of +# an artificial intelligence, machine learning or language model training +# dataset, including but not limited to any use that contributes to the +# training or development of such a model or algorithm. +# +# - The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + import atexit import collections.abc import time @@ -81,28 +111,28 @@ class _AndroidAudio: # Populate device type descriptions from JNI self.device_type_descriptions = { - adi.TYPE_AUX_LINE: "Aux Line", # 0x13 - API level 23 - adi.TYPE_BLUETOOTH_A2DP: "Bluetooth A2DP", # 0x08 - API level 23 - adi.TYPE_BLUETOOTH_SCO: "Bluetooth SCO", # 0x07 - API level 23 + adi.TYPE_AUX_LINE: "Aux Line", # 0x13 - API level 23 + adi.TYPE_BLUETOOTH_A2DP: "Bluetooth A2DP", # 0x08 - API level 23 + adi.TYPE_BLUETOOTH_SCO: "Bluetooth SCO", # 0x07 - API level 23 adi.TYPE_BUILTIN_EARPIECE: "Internal Earpiece", # 0x01 - API level 23 - adi.TYPE_BUILTIN_MIC: "Internal Microphone", # 0x0f - API level 23 - adi.TYPE_BUILTIN_SPEAKER: "Internal Speaker", # 0x02 - API level 23 - adi.TYPE_DOCK: "Dock", # 0x0d - API level 23 - adi.TYPE_FM: "FM", # 0x0e - API level 23 - adi.TYPE_FM_TUNER: "FM Tuner", # 0x10 - API level 23 - adi.TYPE_HDMI: "HDMI", # 0x09 - API level 23 - adi.TYPE_HDMI_ARC: "HDMI ARC", # 0x0a - API level 23 - adi.TYPE_IP: "IP", # 0x14 - API level 23 - adi.TYPE_LINE_ANALOG: "Analog Line", # 0x05 - API level 23 - adi.TYPE_LINE_DIGITAL: "Digital Line", # 0x06 - API level 23 - adi.TYPE_TELEPHONY: "Telephony", # 0x12 - API level 23 - adi.TYPE_TV_TUNER: "TV Tuner", # 0x11 - API level 23 - adi.TYPE_UNKNOWN: "Unknown", # 0x00 - API level 23 - adi.TYPE_USB_ACCESSORY: "USB Accessory", # 0x0c - API level 23 - adi.TYPE_USB_DEVICE: "USB Device", # 0x0b - API level 23 - adi.TYPE_WIRED_HEADPHONES: "Wired Headphones", # 0x04 - API level 23 - adi.TYPE_WIRED_HEADSET: "Wired Headset", # 0x03 - API level 23 - adi.TYPE_BUS: "Bus", # 0x15 - API level 24 + adi.TYPE_BUILTIN_MIC: "Internal Microphone", # 0x0f - API level 23 + adi.TYPE_BUILTIN_SPEAKER: "Internal Speaker", # 0x02 - API level 23 + adi.TYPE_DOCK: "Dock", # 0x0d - API level 23 + adi.TYPE_FM: "FM", # 0x0e - API level 23 + adi.TYPE_FM_TUNER: "FM Tuner", # 0x10 - API level 23 + adi.TYPE_HDMI: "HDMI", # 0x09 - API level 23 + adi.TYPE_HDMI_ARC: "HDMI ARC", # 0x0a - API level 23 + adi.TYPE_IP: "IP", # 0x14 - API level 23 + adi.TYPE_LINE_ANALOG: "Analog Line", # 0x05 - API level 23 + adi.TYPE_LINE_DIGITAL: "Digital Line", # 0x06 - API level 23 + adi.TYPE_TELEPHONY: "Telephony", # 0x12 - API level 23 + adi.TYPE_TV_TUNER: "TV Tuner", # 0x11 - API level 23 + adi.TYPE_UNKNOWN: "Unknown", # 0x00 - API level 23 + adi.TYPE_USB_ACCESSORY: "USB Accessory", # 0x0c - API level 23 + adi.TYPE_USB_DEVICE: "USB Device", # 0x0b - API level 23 + adi.TYPE_WIRED_HEADPHONES: "Wired Headphones", # 0x04 - API level 23 + adi.TYPE_WIRED_HEADSET: "Wired Headset", # 0x03 - API level 23 + adi.TYPE_BUS: "Bus", # 0x15 - API level 24 } if self.android_api_version >= 26: @@ -115,9 +145,9 @@ class _AndroidAudio: self.device_type_descriptions[adi.TYPE_BUILTIN_SPEAKER_SAFE] = "Ringer Speaker" # 0x18 - API level 30 if self.android_api_version >= 31: - self.device_type_descriptions[adi.TYPE_BLE_HEADSET] = "BLE Headset" # 0x1a - API level 31 - self.device_type_descriptions[adi.TYPE_BLE_SPEAKER] = "BLE Speaker" # 0x1b - API level 31 - self.device_type_descriptions[adi.TYPE_HDMI_EARC] = "HDMI EARC" # 0x1d - API level 31 + self.device_type_descriptions[adi.TYPE_BLE_HEADSET] = "BLE Headset" # 0x1a - API level 31 + self.device_type_descriptions[adi.TYPE_BLE_SPEAKER] = "BLE Speaker" # 0x1b - API level 31 + self.device_type_descriptions[adi.TYPE_HDMI_EARC] = "HDMI EARC" # 0x1d - API level 31 self.device_type_descriptions[adi.TYPE_REMOTE_SUBMIX] = "Remote Submix" # 0x19 - API level 31 if self.android_api_version >= 33: diff --git a/LXST/Platforms/darwin/__init__.py b/LXST/Platforms/darwin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/LXST/Platforms/darwin/coreaudio.h b/LXST/Platforms/darwin/coreaudio.h new file mode 100644 index 0000000..cd9824f --- /dev/null +++ b/LXST/Platforms/darwin/coreaudio.h @@ -0,0 +1,261 @@ +// All files are found in /System/Library/Frameworks + +// CoreFoundation/CFBase.h: +typedef unsigned char Boolean; +typedef unsigned char UInt8; +typedef signed char SInt8; +typedef unsigned short UInt16; +typedef signed short SInt16; +typedef unsigned int UInt32; +typedef signed int SInt32; +typedef uint64_t UInt64; +typedef int64_t SInt64; +typedef SInt32 OSStatus; +typedef float Float32; +typedef double Float64; +typedef unsigned short UniChar; +typedef unsigned long UniCharCount; +typedef unsigned char * StringPtr; +typedef const unsigned char * ConstStringPtr; +typedef unsigned char Str255[256]; +typedef const unsigned char * ConstStr255Param; +typedef SInt16 OSErr; +typedef SInt16 RegionCode; +typedef SInt16 LangCode; +typedef SInt16 ScriptCode; +typedef UInt32 FourCharCode; +typedef FourCharCode OSType; +typedef UInt8 Byte; +typedef SInt8 SignedByte; +typedef UInt32 UTF32Char; +typedef UInt16 UTF16Char; +typedef UInt8 UTF8Char; +typedef signed long long CFIndex; +typedef const void * CFStringRef; + +// CoreFoundation/CFString.h +typedef UInt32 CFStringEncoding; +CFIndex CFStringGetLength(CFStringRef theString); +Boolean CFStringGetCString(CFStringRef theString, char *buffer, CFIndex bufferSize, CFStringEncoding encoding); + +// CoreFoundation/CFRunLoop.h +typedef struct __CFRunLoop * CFRunLoopRef; + +// CoreAudio/AudioHardwareBase.h +typedef UInt32 AudioObjectID; +typedef UInt32 AudioObjectPropertySelector; +typedef UInt32 AudioObjectPropertyScope; +typedef UInt32 AudioObjectPropertyElement; +struct AudioObjectPropertyAddress +{ + AudioObjectPropertySelector mSelector; + AudioObjectPropertyScope mScope; + AudioObjectPropertyElement mElement; +}; +typedef struct AudioObjectPropertyAddress AudioObjectPropertyAddress; + +// CoreAudio/AudioHardware.h +Boolean AudioObjectHasProperty(AudioObjectID inObjectID, const AudioObjectPropertyAddress* inAddress); +OSStatus AudioObjectGetPropertyDataSize(AudioObjectID inObjectID, + const AudioObjectPropertyAddress* inAddress, + UInt32 inQualifierDataSize, + const void* inQualifierData, + UInt32* outDataSize); +OSStatus AudioObjectGetPropertyData(AudioObjectID inObjectID, + const AudioObjectPropertyAddress* inAddress, + UInt32 inQualifierDataSize, + const void* inQualifierData, + UInt32* ioDataSize, + void* outData); +OSStatus AudioObjectSetPropertyData(AudioObjectID inObjectID, + const AudioObjectPropertyAddress* inAddress, + UInt32 inQualifierDataSize, + const void* inQualifierData, + UInt32 inDataSize, + const void* inData); + + +// CoreAudioTypes.h +typedef UInt32 AudioFormatID; +typedef UInt32 AudioFormatFlags; +struct AudioStreamBasicDescription +{ + Float64 mSampleRate; + AudioFormatID mFormatID; + AudioFormatFlags mFormatFlags; + UInt32 mBytesPerPacket; + UInt32 mFramesPerPacket; + UInt32 mBytesPerFrame; + UInt32 mChannelsPerFrame; + UInt32 mBitsPerChannel; + UInt32 mReserved; +}; +typedef struct AudioStreamBasicDescription AudioStreamBasicDescription; +struct AudioStreamPacketDescription +{ + SInt64 mStartOffset; + UInt32 mVariableFramesInPacket; + UInt32 mDataByteSize; +}; +typedef struct AudioStreamPacketDescription AudioStreamPacketDescription; + +// AudioToolbox/AudioQueue.h + +// data structures: + +struct SMPTETime +{ + SInt16 mSubframes; + SInt16 mSubframeDivisor; + UInt32 mCounter; + UInt32 mType; + UInt32 mFlags; + SInt16 mHours; + SInt16 mMinutes; + SInt16 mSeconds; + SInt16 mFrames; +}; +typedef struct SMPTETime SMPTETime; +struct AudioTimeStamp +{ + Float64 mSampleTime; + UInt64 mHostTime; + Float64 mRateScalar; + UInt64 mWordClockTime; + SMPTETime mSMPTETime; + UInt32 mFlags; + UInt32 mReserved; +}; +typedef struct AudioTimeStamp AudioTimeStamp; + +// AudioComponent.h + +typedef struct AudioComponentDescription { + OSType componentType; + OSType componentSubType; + OSType componentManufacturer; + UInt32 componentFlags; + UInt32 componentFlagsMask; +} AudioComponentDescription; +typedef struct OpaqueAudioComponent * AudioComponent; +typedef struct ComponentInstanceRecord * AudioComponentInstance; +AudioComponent AudioComponentFindNext(AudioComponent inComponent, + const AudioComponentDescription *inDesc); +OSStatus AudioComponentInstanceNew(AudioComponent inComponent, + AudioComponentInstance *outInstance); +OSStatus AudioComponentInstanceDispose(AudioComponentInstance inInstance); +OSStatus AudioComponentCopyName(AudioComponent inComponent, + CFStringRef *outName); +OSStatus AudioComponentGetDescription(AudioComponent inComponent, + AudioComponentDescription *outDesc); + +// AUComponent.h + +typedef AudioComponentInstance AudioUnit; +typedef UInt32 AudioUnitPropertyID; +typedef UInt32 AudioUnitScope; +typedef UInt32 AudioUnitElement; + +OSStatus AudioUnitInitialize(AudioUnit inUnit); +OSStatus AudioUnitGetPropertyInfo(AudioUnit inUnit, + AudioUnitPropertyID inID, + AudioUnitScope inScope, + AudioUnitElement inElement, + UInt32 *outDataSize, + Boolean *outWritable); +OSStatus AudioUnitGetProperty(AudioUnit inUnit, + AudioUnitPropertyID inID, + AudioUnitScope inScope, + AudioUnitElement inElement, + void *outData, + UInt32 *ioDataSize); +OSStatus AudioUnitSetProperty(AudioUnit inUnit, + AudioUnitPropertyID inID, + AudioUnitScope inScope, + AudioUnitElement inElement, + const void *inData, + UInt32 inDataSize); + +OSStatus AudioOutputUnitStart(AudioUnit ci); +OSStatus AudioOutputUnitStop(AudioUnit ci); + +typedef UInt32 AudioUnitRenderActionFlags; + +struct AudioBuffer +{ + UInt32 mNumberChannels; + UInt32 mDataByteSize; + void* mData; +}; +typedef struct AudioBuffer AudioBuffer; + +struct AudioBufferList +{ + UInt32 mNumberBuffers; + AudioBuffer mBuffers[]; // this is a variable length array of mNumberBuffers elements +}; +typedef struct AudioBufferList AudioBufferList; + +OSStatus AudioUnitProcess(AudioUnit inUnit, + AudioUnitRenderActionFlags * ioActionFlags, + const AudioTimeStamp *inTimeStamp, + UInt32 inNumberFrames, + AudioBufferList *ioData); +OSStatus AudioUnitRender(AudioUnit inUnit, + AudioUnitRenderActionFlags * ioActionFlags, + const AudioTimeStamp * inTimeStamp, + UInt32 inOutputBusNumber, + UInt32 inNumberFrames, + AudioBufferList *ioData); + +typedef OSStatus (*AURenderCallback)(void * inRefCon, + AudioUnitRenderActionFlags *ioActionFlags, + const AudioTimeStamp *inTimeStamp, + UInt32 inBusNumber, + UInt32 inNumberFrames, + AudioBufferList *ioData); + +typedef struct AURenderCallbackStruct { + AURenderCallback inputProc; + void *inputProcRefCon; +} AURenderCallbackStruct; + +struct AudioValueRange +{ + Float64 mMinimum; + Float64 mMaximum; +}; +typedef struct AudioValueRange AudioValueRange; + + +// AudioConverter.h +typedef struct OpaqueAudioConverter * AudioConverterRef; +typedef UInt32 AudioConverterPropertyID; + +OSStatus AudioConverterNew(const AudioStreamBasicDescription *inSourceFormat, + const AudioStreamBasicDescription *inDestinationFormat, + AudioConverterRef *outAudioConverter); +OSStatus AudioConverterDispose(AudioConverterRef inAudioConverter); +typedef OSStatus (*AudioConverterComplexInputDataProc)( + AudioConverterRef inAudioConverter, + UInt32 *ioNumberDataPackets, + AudioBufferList *ioData, + AudioStreamPacketDescription **outDataPacketDescription, + void *inUserData); +extern OSStatus AudioConverterFillComplexBuffer( + AudioConverterRef inAudioConverter, + AudioConverterComplexInputDataProc inInputDataProc, + void *inInputDataProcUserData, + UInt32 *ioOutputDataPacketSize, + AudioBufferList *outOutputData, + AudioStreamPacketDescription *outPacketDescription); +extern OSStatus AudioConverterSetProperty( + AudioConverterRef inAudioConverter, + AudioConverterPropertyID inPropertyID, + UInt32 inPropertyDataSize, + const void *inPropertyData); +extern OSStatus AudioConverterGetProperty( + AudioConverterRef inAudioConverter, + AudioConverterPropertyID inPropertyID, + UInt32 *ioPropertyDataSize, + void *outPropertyData); diff --git a/LXST/Platforms/darwin/soundcard.py b/LXST/Platforms/darwin/soundcard.py new file mode 100644 index 0000000..a9ed662 --- /dev/null +++ b/LXST/Platforms/darwin/soundcard.py @@ -0,0 +1,945 @@ +# Adapted from Bastian Bechtold's soundcard library, originally released +# under the BSD 3-Clause License +# +# https://github.com/bastibe/SoundCard +# +# Copyright (c) 2016 Bastian Bechtold +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the +# distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Modifications and improvements Copyright 2025 Mark Qvist, and released +# under the same BSD 3-Clause License. + +import os +import cffi +import numpy +import collections +import time +import re +import math +import threading +import warnings + +_ffi = cffi.FFI() +_package_dir, _ = os.path.split(__file__) +with open(os.path.join(_package_dir, 'coreaudio.h'), 'rt') as f: + _ffi.cdef(f.read()) + +_ca = _ffi.dlopen('CoreAudio') +_au = _ffi.dlopen('AudioUnit') + +from soundcard import coreaudioconstants as _cac + + +def all_speakers(): + """A list of all connected speakers.""" + device_ids = _CoreAudio.get_property( + _cac.kAudioObjectSystemObject, + _cac.kAudioHardwarePropertyDevices, + "AudioObjectID") + return [_Speaker(id=d) for d in device_ids + if _Speaker(id=d).channels > 0] + + +def all_microphones(include_loopback=False): + """A list of all connected microphones.""" + + # macOS does not support loopback recording functionality + if include_loopback: + warnings.warn("macOS does not support loopback recording functionality", Warning) + + device_ids = _CoreAudio.get_property( + _cac.kAudioObjectSystemObject, + _cac.kAudioHardwarePropertyDevices, + "AudioObjectID") + return [_Microphone(id=d) for d in device_ids + if _Microphone(id=d).channels > 0] + + +def default_speaker(): + """The default speaker of the system.""" + device_id, = _CoreAudio.get_property( + _cac.kAudioObjectSystemObject, + _cac.kAudioHardwarePropertyDefaultOutputDevice, + "AudioObjectID") + return _Speaker(id=device_id) + + +def get_speaker(id): + """Get a specific speaker by a variety of means. + + id can be an a CoreAudio id, a substring of the speaker name, or a + fuzzy-matched pattern for the speaker name. + + """ + return _match_device(id, all_speakers()) + + +def default_microphone(): + """The default microphone of the system.""" + device_id, = _CoreAudio.get_property( + _cac.kAudioObjectSystemObject, + _cac.kAudioHardwarePropertyDefaultInputDevice, + "AudioObjectID") + return _Microphone(id=device_id) + + +def get_microphone(id, include_loopback=False): + """Get a specific microphone by a variety of means. + + id can be a CoreAudio id, a substring of the microphone name, or a + fuzzy-matched pattern for the microphone name. + + """ + return _match_device(id, all_microphones(include_loopback)) + + +def _match_device(id, devices): + """Find id in a list of devices. + + id can be a CoreAudio id, a substring of the device name, or a + fuzzy-matched pattern for the microphone name. + + """ + devices_by_id = {device.id: device for device in devices} + devices_by_name = {device.name: device for device in devices} + if id in devices_by_id: + return devices_by_id[id] + # try substring match: + for name, device in devices_by_name.items(): + if id in name: + return device + # try fuzzy match: + pattern = '.*'.join(id) + for name, device in devices_by_name.items(): + if re.match(pattern, name): + return device + raise IndexError('no device with id {}'.format(id)) + + +def get_name(): + raise NotImplementedError() + + +def set_name(name): + raise NotImplementedError() + + +class _Soundcard: + """A soundcard. This is meant to be subclassed. + + Properties: + - `name`: the name of the soundcard + + """ + def __init__(self, *, id): + self._id = id + + @property + def id(self): + return self._id + + @property + def name(self): + name = _CoreAudio.get_property( + self._id, _cac.kAudioObjectPropertyName, 'CFStringRef') + return _CoreAudio.CFString_to_str(name) + + +class _Speaker(_Soundcard): + """A soundcard output. Can be used to play audio. + + Use the `play` method to play one piece of audio, or use the + `player` method to get a context manager for playing continuous + audio. + + Properties: + - `channels`: either the number of channels to play, or a list + of channel indices. Index -1 is silence, and subsequent numbers + are channel numbers (left, right, center, ...) + - `name`: the name of the soundcard + + """ + + @property + def channels(self): + bufferlist = _CoreAudio.get_property( + self._id, + _cac.kAudioDevicePropertyStreamConfiguration, + 'AudioBufferList', scope=_cac.kAudioObjectPropertyScopeOutput) + if bufferlist and bufferlist[0].mNumberBuffers > 0: + return bufferlist[0].mBuffers[0].mNumberChannels + else: + return 0 + + def __repr__(self): + return ''.format(self.name, self.channels) + + def player(self, samplerate, channels=None, blocksize=None): + if channels is None: + channels = self.channels + return _Player(self._id, samplerate, channels, blocksize) + + def play(self, data, samplerate, channels=None, blocksize=None): + if channels is None and len(data.shape) == 2: + channels = data.shape[1] + elif channels is None: + channels = self.channels + with self.player(samplerate, channels, blocksize) as p: + p.play(data) + + +class _Microphone(_Soundcard): + """A soundcard input. Can be used to record audio. + + Use the `record` method to record a piece of audio, or use the + `recorder` method to get a context manager for recording + continuous audio. + + Properties: + - `channels`: either the number of channels to record, or a list + of channel indices. Index -1 is silence, and subsequent numbers + are channel numbers (left, right, center, ...) + - `name`: the name of the soundcard + + """ + + @property + def isloopback(self): + return False + + @property + def channels(self): + bufferlist = _CoreAudio.get_property( + self._id, + _cac.kAudioDevicePropertyStreamConfiguration, + 'AudioBufferList', scope=_cac.kAudioObjectPropertyScopeInput) + if bufferlist and bufferlist[0].mNumberBuffers > 0: + return bufferlist[0].mBuffers[0].mNumberChannels + else: + return 0 + + def __repr__(self): + return ''.format(self.name, self.channels) + + def recorder(self, samplerate, channels=None, blocksize=None): + if channels is None: + channels = self.channels + return _Recorder(self._id, samplerate, channels, blocksize) + + def record(self, numframes, samplerate, channels=None, blocksize=None): + if channels is None: + channels = self.channels + with self.recorder(samplerate, channels, blocksize) as p: + return p.record(numframes) + + +class _CoreAudio: + """A helper class for interacting with CoreAudio.""" + + @staticmethod + def get_property(target, selector, ctype, scope=_cac.kAudioObjectPropertyScopeGlobal): + """Get a CoreAudio property. + + This might include things like a list of available sound + cards, or various meta data about those sound cards. + + Arguments: + - `target`: The AudioObject that the property belongs to + - `selector`: The Selector for this property + - `scope`: The Scope for this property + - `ctype`: The type of the property + + Returns: + A list of objects of type `ctype` + + """ + + prop = _ffi.new("AudioObjectPropertyAddress*", + {'mSelector': selector, + 'mScope': scope, + 'mElement': _cac.kAudioObjectPropertyElementMaster}) + + has_prop = _ca.AudioObjectHasProperty(target, prop) + assert has_prop == 1, 'Core Audio does not have the requested property' + + size = _ffi.new("UInt32*") + err = _ca.AudioObjectGetPropertyDataSize(target, prop, 0, _ffi.NULL, size) + assert err == 0, "Can't get Core Audio property size" + num_values = int(size[0]//_ffi.sizeof(ctype)) + + prop_data = _ffi.new(ctype+'[]', num_values) + err = _ca.AudioObjectGetPropertyData(target, prop, 0, _ffi.NULL, + size, prop_data) + assert err == 0, "Can't get Core Audio property data" + return prop_data + + @staticmethod + def set_property(target, selector, prop_data, scope=_cac.kAudioObjectPropertyScopeGlobal): + """Set a CoreAudio property. + + This is typically a piece of meta data about a sound card. + + Arguments: + - `target`: The AudioObject that the property belongs to + - `selector`: The Selector for this property + - `scope`: The Scope for this property + - `prop_data`: The new property value + + """ + + prop = _ffi.new("AudioObjectPropertyAddress*", + {'mSelector': selector, + 'mScope': scope, + 'mElement': _cac.kAudioObjectPropertyElementMaster}) + + err = _ca.AudioObjectSetPropertyData(target, prop, 0, _ffi.NULL, + _ffi.sizeof(_ffi.typeof(prop_data).item.cname), prop_data) + assert err == 0, "Can't set Core Audio property data" + + @staticmethod + def CFString_to_str(cfstrptr): + """Converts a CFStringRef to a Python str.""" + + # Multiply by 4, the maximum number of bytes used per character in UTF-8. + str_length = _ca.CFStringGetLength(cfstrptr[0]) * 4 + str_buffer = _ffi.new('char[]', str_length+1) + + err = _ca.CFStringGetCString(cfstrptr[0], str_buffer, str_length+1, _cac.kCFStringEncodingUTF8) + assert err == 1, "Could not decode string" + + return _ffi.string(str_buffer).decode() + + +class _Player: + """A context manager for an active output stream. + + Audio playback is available as soon as the context manager is + entered. Audio data can be played using the `play` method. + Successive calls to `play` will queue up the audio one piece after + another. If no audio is queued up, this will play silence. + + This context manager can only be entered once, and can not be used + after it is closed. + + """ + + def __init__(self, id, samplerate, channels, blocksize=None): + self._au = _AudioUnit("output", id, samplerate, channels, blocksize) + + def __enter__(self): + self._queue = collections.deque() + + @_ffi.callback("AURenderCallback") + def render_callback(userdata, actionflags, timestamp, + busnumber, numframes, bufferlist): + for bufferidx in range(bufferlist.mNumberBuffers): + dest = bufferlist.mBuffers[bufferidx] + channels = dest.mNumberChannels + bytes_written = 0 + to_write = dest.mDataByteSize + while bytes_written < to_write: + if self._queue: + data = self._queue.popleft() + srcbuffer = _ffi.from_buffer(data) + numbytes = min(len(srcbuffer), to_write-bytes_written) + _ffi.memmove(dest.mData+bytes_written, srcbuffer, numbytes) + if numbytes < len(srcbuffer): + leftover = data[numbytes//4//channels:] + self._queue.appendleft(leftover) + bytes_written += numbytes + else: + src = bytearray(to_write-bytes_written) + _ffi.memmove(dest.mData+bytes_written, src, len(src)) + bytes_written += len(src) + return 0 + + self._au.set_callback(render_callback) + + self._au.start() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self._au.close() + + def play(self, data, wait=True): + """Play some audio data. + + Internally, all data is handled as float32 and with the + appropriate number of channels. For maximum performance, + provide data as a `frames × channels` float32 numpy array. + + If single-channel or one-dimensional data is given, this data + will be played on all available channels. + + This function will return *before* all data has been played, + so that additional data can be provided for gapless playback. + The amount of buffering can be controlled through the + blocksize of the player object. + + If data is provided faster than it is played, later pieces + will be queued up and played one after another. + + """ + + data = numpy.asarray(data, dtype="float32", order='C') + data[data>1] = 1 + data[data<-1] = -1 + if data.ndim == 1: + data = data[:, None] # force 2d + if data.ndim != 2: + raise TypeError('data must be 1d or 2d, not {}d'.format(data.ndim)) + if data.shape[1] == 1 and self._au.channels != 1: + data = numpy.tile(data, [1, self._au.channels]) + if data.shape[1] != self._au.channels: + raise TypeError('second dimension of data must be equal to the number of channels, not {}'.format(data.shape[1])) + idx = 0 + while idx < len(data)-self._au.blocksize: + self._queue.append(data[idx:idx+self._au.blocksize]) + idx += self._au.blocksize + self._queue.append(data[idx:]) + while self._queue and wait: + time.sleep(0.001) + +class _AudioUnit: + """Communication helper with AudioUnits. + + This provides an abstraction over a single AudioUnit. Can be used + as soon as it instatiated. + + Properties: + - `enableinput`, `enableoutput`: set up the AudioUnit for playback + or recording. It is not possible to record and play at the same + time. + - `device`: The numeric ID of the underlying CoreAudio device. + - `blocksize`: The amount of buffering in the AudioUnit. Values + outside of `blocksizerange` will be silently clamped to that + range. + - `blocksizerange`: The minimum and maximum possible block size. + - `samplerate`: The sampling rate of the CoreAudio device. This + will lead to errors if changed in a recording AudioUnit. + - `channels`: The number of channels of the AudioUnit. + + """ + + def __init__(self, iotype, device, samplerate, channels, blocksize): + self._iotype = iotype + + desc = _ffi.new( + "AudioComponentDescription*", + dict(componentType=_cac.kAudioUnitType_Output, + componentSubType=_cac.kAudioUnitSubType_HALOutput, + componentFlags=0, + componentFlagsMask=0, + componentManufacturer=_cac.kAudioUnitManufacturer_Apple)) + + audiocomponent = _au.AudioComponentFindNext(_ffi.NULL, desc) + if not audiocomponent: + raise RuntimeError("could not find audio component") + self.ptr = _ffi.new("AudioComponentInstance*") + status = _au.AudioComponentInstanceNew(audiocomponent, self.ptr) + if status: + raise RuntimeError(_cac.error_number_to_string(status)) + + if iotype == 'input': + self.enableinput = True + self.enableoutput = False + self._au_scope = _cac.kAudioUnitScope_Output + self._au_element = 1 + elif iotype == 'output': + self.enableinput = False + self.enableoutput = True + self._au_scope = _cac.kAudioUnitScope_Input + self._au_element = 0 + + self.device = device + + blocksize = blocksize or self.blocksize + + # Input AudioUnits can't use non-native sample rates. + # Therefore, if a non-native sample rate is requested, use a + # resampled block size and resample later, manually: + if iotype == 'input': + # Get the input device format + curr_device_format = self._get_property(_cac.kAudioUnitProperty_StreamFormat, + _cac.kAudioUnitScope_Input, + 1, + "AudioStreamBasicDescription") + + self.samplerate = curr_device_format[0].mSampleRate + self.resample = self.samplerate/samplerate + else: + self.resample = 1 + self.samplerate = samplerate + + # there are two maximum block sizes for some reason: + maxblocksize = min(self.blocksizerange[1], + self.maxblocksize) + if self.blocksizerange[0] <= blocksize <= maxblocksize: + self.blocksize = blocksize + else: + raise TypeError("blocksize must be between {} and {}" + .format(self.blocksizerange[0], + maxblocksize)) + + if isinstance(channels, collections.abc.Iterable): + if iotype == 'output': + # invert channel map and fill with -1 ([2, 0] -> [1, -1, 0]): + self.channels = len([c for c in channels if c >= 0]) + channelmap = [-1]*(max(channels)+1) + for idx, c in enumerate(channels): + channelmap[c] = idx + self.channelmap = channelmap + else: + self.channels = len(channels) + self.channelmap = channels + elif isinstance(channels, int): + self.channels = channels + else: + raise TypeError('channels must be iterable or integer') + self._set_channels(self.channels) + + def _set_property(self, property, scope, element, data): + if '[]' in _ffi.typeof(data).cname: + num_values = len(data) + else: + num_values = 1 + status = _au.AudioUnitSetProperty(self.ptr[0], + property, scope, element, + data, _ffi.sizeof(_ffi.typeof(data).item.cname)*num_values) + if status != 0: + raise RuntimeError(_cac.error_number_to_string(status)) + + def _get_property(self, property, scope, element, type): + datasize = _ffi.new("UInt32*") + status = _au.AudioUnitGetPropertyInfo(self.ptr[0], + property, scope, element, + datasize, _ffi.NULL) + num_values = datasize[0]//_ffi.sizeof(type) + data = _ffi.new(type + '[{}]'.format(num_values)) + status = _au.AudioUnitGetProperty(self.ptr[0], + property, scope, element, + data, datasize) + if status != 0: + raise RuntimeError(_cac.error_number_to_string(status)) + # return trivial data trivially + if num_values == 1 and (type == "UInt32" or type == "Float64"): + return data[0] + else: # everything else, return the cdata, to keep it alive + return data + + @property + def device(self): + return self._get_property( + _cac.kAudioOutputUnitProperty_CurrentDevice, + _cac.kAudioUnitScope_Global, 0, "UInt32") + + @device.setter + def device(self, dev): + data = _ffi.new("UInt32*", dev) + self._set_property( + _cac.kAudioOutputUnitProperty_CurrentDevice, + _cac.kAudioUnitScope_Global, 0, data) + + @property + def enableinput(self): + return self._get_property( + _cac.kAudioOutputUnitProperty_EnableIO, + _cac.kAudioUnitScope_Input, 1, "UInt32") + + @enableinput.setter + def enableinput(self, yesno): + data = _ffi.new("UInt32*", yesno) + self._set_property( + _cac.kAudioOutputUnitProperty_EnableIO, + _cac.kAudioUnitScope_Input, 1, data) + + @property + def enableoutput(self): + return self._get_property( + _cac.kAudioOutputUnitProperty_EnableIO, + _cac.kAudioUnitScope_Output, 0, "UInt32") + + @enableoutput.setter + def enableoutput(self, yesno): + data = _ffi.new("UInt32*", yesno) + self._set_property( + _cac.kAudioOutputUnitProperty_EnableIO, + _cac.kAudioUnitScope_Output, 0, data) + + @property + def samplerate(self): + return self._get_property( + _cac.kAudioUnitProperty_SampleRate, + self._au_scope, self._au_element, "Float64") + + @samplerate.setter + def samplerate(self, samplerate): + data = _ffi.new("Float64*", samplerate) + self._set_property( + _cac.kAudioUnitProperty_SampleRate, + self._au_scope, self._au_element, data) + + def _set_channels(self, channels): + streamformat = _ffi.new( + "AudioStreamBasicDescription*", + dict(mSampleRate=self.samplerate, + mFormatID=_cac.kAudioFormatLinearPCM, + mFormatFlags=_cac.kAudioFormatFlagIsFloat, + mFramesPerPacket=1, + mChannelsPerFrame=channels, + mBitsPerChannel=32, + mBytesPerPacket=channels * 4, + mBytesPerFrame=channels * 4)) + self._set_property( + _cac.kAudioUnitProperty_StreamFormat, + self._au_scope, self._au_element, streamformat) + + @property + def maxblocksize(self): + maxblocksize = self._get_property( + _cac.kAudioUnitProperty_MaximumFramesPerSlice, + _cac.kAudioUnitScope_Global, 0, "UInt32") + assert maxblocksize + return maxblocksize + + @property + def channelmap(self): + scope = {2: 1, 1: 2}[self._au_scope] + map = self._get_property( + _cac.kAudioOutputUnitProperty_ChannelMap, + scope, self._au_element, + "SInt32") + last_meaningful = max(idx for idx, c in enumerate(map) if c != -1) + return list(map[0:last_meaningful+1]) + + @channelmap.setter + def channelmap(self, map): + scope = {2: 1, 1: 2}[self._au_scope] + cmap = _ffi.new("SInt32[]", map) + self._set_property( + _cac.kAudioOutputUnitProperty_ChannelMap, + scope, self._au_element, + cmap) + + @property + def blocksizerange(self): + framesizerange = _CoreAudio.get_property( + self.device, + _cac.kAudioDevicePropertyBufferFrameSizeRange, + 'AudioValueRange', scope=_cac.kAudioObjectPropertyScopeOutput) + assert framesizerange + return framesizerange[0].mMinimum, framesizerange[0].mMaximum + + @property + def blocksize(self): + framesize = _CoreAudio.get_property( + self.device, + _cac.kAudioDevicePropertyBufferFrameSize, + 'UInt32', scope=_cac.kAudioObjectPropertyScopeOutput) + assert framesize + return framesize[0] + + @blocksize.setter + def blocksize(self, blocksize): + framesize = _ffi.new("UInt32*", blocksize) + status = _CoreAudio.set_property( + self.device, + _cac.kAudioDevicePropertyBufferFrameSize, + framesize, scope=_cac.kAudioObjectPropertyScopeOutput) + + def set_callback(self, callback): + """Set a callback function for the AudioUnit. """ + + if self._iotype == 'input': + callbacktype = _cac.kAudioOutputUnitProperty_SetInputCallback + elif self._iotype == 'output': + callbacktype = _cac.kAudioUnitProperty_SetRenderCallback + + self._callback = callback + callbackstruct = _ffi.new( + "AURenderCallbackStruct*", + dict(inputProc=callback, + inputProcRefCon=_ffi.NULL)) + self._set_property( + callbacktype, + _cac.kAudioUnitScope_Global, 0, callbackstruct) + + def start(self): + """Start processing audio, and start calling the callback.""" + + status = _au.AudioUnitInitialize(self.ptr[0]) + if status: + raise RuntimeError(_cac.error_number_to_string(status)) + status = _au.AudioOutputUnitStart(self.ptr[0]) + if status: + raise RuntimeError(_cac.error_number_to_string(status)) + + def close(self): + """Stop processing audio, and stop calling the callback.""" + + status = _au.AudioOutputUnitStop(self.ptr[0]) + if status: + raise RuntimeError(_cac.error_number_to_string(status)) + status = _au.AudioComponentInstanceDispose(self.ptr[0]) + if status: + raise RuntimeError(_cac.error_number_to_string(status)) + del self.ptr + + +# Here's how to do it: http://atastypixel.com/blog/using-remoteio-audio-unit/ +# https://developer.apple.com/library/content/technotes/tn2091/_index.html + + +class _Resampler: + def __init__(self, fromsamplerate, tosamplerate, channels): + self.fromsamplerate = fromsamplerate + self.tosamplerate = tosamplerate + self.channels = channels + + fromstreamformat = _ffi.new( + "AudioStreamBasicDescription*", + dict(mSampleRate=self.fromsamplerate, + mFormatID=_cac.kAudioFormatLinearPCM, + mFormatFlags=_cac.kAudioFormatFlagIsFloat, + mFramesPerPacket=1, + mChannelsPerFrame=self.channels, + mBitsPerChannel=32, + mBytesPerPacket=self.channels * 4, + mBytesPerFrame=self.channels * 4)) + + tostreamformat = _ffi.new( + "AudioStreamBasicDescription*", + dict(mSampleRate=self.tosamplerate, + mFormatID=_cac.kAudioFormatLinearPCM, + mFormatFlags=_cac.kAudioFormatFlagIsFloat, + mFramesPerPacket=1, + mChannelsPerFrame=self.channels, + mBitsPerChannel=32, + mBytesPerPacket=self.channels * 4, + mBytesPerFrame=self.channels * 4)) + + self.audioconverter = _ffi.new("AudioConverterRef*") + _au.AudioConverterNew(fromstreamformat, tostreamformat, self.audioconverter) + + @_ffi.callback("AudioConverterComplexInputDataProc") + def converter_callback(converter, numberpackets, bufferlist, desc, userdata): + return self.converter_callback(converter, numberpackets, bufferlist, desc, userdata) + self._converter_callback = converter_callback + + self.queue = [] + + self.blocksize = 512 + self.outbuffer = _ffi.new("AudioBufferList*", [1, 1]) + self.outbuffer.mNumberBuffers = 1 + self.outbuffer.mBuffers[0].mNumberChannels = self.channels + self.outbuffer.mBuffers[0].mDataByteSize = self.blocksize*4*self.channels + self.outdata = _ffi.new("Float32[]", self.blocksize*self.channels) + self.outbuffer.mBuffers[0].mData = self.outdata + self.outsize = _ffi.new("UInt32*") + + def converter_callback(self, converter, numberpackets, bufferlist, desc, userdata): + numframes = min(numberpackets[0], len(self.todo), self.blocksize) + raw_data = self.todo[:numframes].tobytes() + _ffi.memmove(self.outdata, raw_data, len(raw_data)) + bufferlist[0].mBuffers[0].mDataByteSize = len(raw_data) + bufferlist[0].mBuffers[0].mData = self.outdata + numberpackets[0] = numframes + self.todo = self.todo[numframes:] + + if len(self.todo) == 0 and numframes == 0: + return -1 + return 0 + + def resample(self, data): + self.todo = numpy.array(data, dtype='float32') + while len(self.todo) > 0: + self.outsize[0] = self.blocksize + # Set outbuffer each iteration to avoid mDataByteSize decreasing over time + self.outbuffer.mNumberBuffers = 1 + self.outbuffer.mBuffers[0].mNumberChannels = self.channels + self.outbuffer.mBuffers[0].mDataByteSize = self.blocksize*4*self.channels + self.outbuffer.mBuffers[0].mData = self.outdata + + status = _au.AudioConverterFillComplexBuffer(self.audioconverter[0], + self._converter_callback, + _ffi.NULL, + self.outsize, + self.outbuffer, + _ffi.NULL) + + if status != 0 and status != -1: + raise RuntimeError('error during sample rate conversion:', status) + + array = numpy.frombuffer(_ffi.buffer(self.outdata), dtype='float32').copy() + + self.queue.append(array[:self.outsize[0]*self.channels]) + + converted_data = numpy.concatenate(self.queue) + self.queue.clear() + + return converted_data.reshape([-1, self.channels]) + + def __del__(self): + _au.AudioConverterDispose(self.audioconverter[0]) + + +class _Recorder: + """A context manager for an active input stream. + + Audio recording is available as soon as the context manager is + entered. Recorded audio data can be read using the `record` + method. If no audio data is available, `record` will block until + the requested amount of audio data has been recorded. + + This context manager can only be entered once, and can not be used + after it is closed. + + """ + + def __init__(self, id, samplerate, channels, blocksize=None): + self._au = _AudioUnit("input", id, samplerate, channels, blocksize) + self._resampler = _Resampler(self._au.samplerate, samplerate, self._au.channels) + self._record_event = threading.Event() + + def __enter__(self): + self._queue = collections.deque() + self._pending_chunk = numpy.zeros([0, self._au.channels], dtype='float32') + + channels = self._au.channels + au = self._au.ptr[0] + + @_ffi.callback("AURenderCallback") + def input_callback(userdata, actionflags, timestamp, + busnumber, numframes, bufferlist): + bufferlist = _ffi.new("AudioBufferList*", [1, 1]) + bufferlist.mNumberBuffers = 1 + bufferlist.mBuffers[0].mNumberChannels = channels + bufferlist.mBuffers[0].mDataByteSize = numframes * 4 * channels + bufferlist.mBuffers[0].mData = _ffi.NULL + + status = _au.AudioUnitRender(au, + actionflags, + timestamp, + busnumber, + numframes, + bufferlist) + + # special case if output is silence: + if (actionflags[0] == _cac.kAudioUnitRenderAction_OutputIsSilence + and status == _cac.kAudioUnitErr_CannotDoInCurrentContext): + actionflags[0] = 0 # reset actionflags + status = 0 # reset error code + data = numpy.zeros([numframes, channels], 'float32') + else: + data = numpy.frombuffer(_ffi.buffer(bufferlist.mBuffers[0].mData, + bufferlist.mBuffers[0].mDataByteSize), + dtype='float32') + data = data.reshape([-1, bufferlist.mBuffers[0].mNumberChannels]).copy() + + if status != 0: + print('error during recording:', status) + + self._queue.append(data) + self._record_event.set() + return status + + self._au.set_callback(input_callback) + self._au.start() + + return self + + def __exit__(self, exc_type, exc_value, traceback): + self._au.close() + + def _record_chunk(self): + """Record one chunk of audio data, as returned by core audio + + The data will be returned as a 1D numpy array, which will be used by + the `record` method. This function is the interface of the `_Recorder` + object with core audio. + """ + while not self._queue: + self._record_event.wait() + self._record_event.clear() + block = self._queue.popleft() + + # perform sample rate conversion: + if self._au.resample != 1: + block = self._resampler.resample(block) + return block + + def record(self, numframes=None): + """Record a block of audio data. + + The data will be returned as a frames × channels float32 numpy array. + This function will wait until numframes frames have been recorded. + If numframes is given, it will return exactly `numframes` frames, + and buffer the rest for later. + + If numframes is None, it will return whatever the audio backend + has available right now. + Use this if latency must be kept to a minimum, but be aware that + block sizes can change at the whims of the audio backend. + + If using `record` with `numframes=None` after using `record` with a + required `numframes`, the last buffered frame will be returned along + with the new recorded block. + (If you want to empty the last buffered frame instead, use `flush`) + + """ + + if numframes is None: + blocks = [self._pending_chunk, self._record_chunk()] + self._pending_chunk = numpy.zeros([0, self._au.channels], dtype='float32') + else: + blocks = [self._pending_chunk] + self._pending_chunk = numpy.zeros([0, self._au.channels], dtype='float32') + recorded_frames = len(blocks[0]) + while recorded_frames < numframes: + block = self._record_chunk() + blocks.append(block) + recorded_frames += len(block) + if recorded_frames > numframes: + to_split = -(recorded_frames-numframes) + blocks[-1], self._pending_chunk = numpy.split(blocks[-1], [to_split]) + data = numpy.concatenate(blocks, axis=0) + + return data + + def flush(self): + """Return the last pending chunk + After using the record method, this will return the last incomplete + chunk and delete it. + + """ + last_chunk = numpy.reshape(self._pending_chunk, [-1, self._au.channels]) + self._pending_chunk = numpy.zeros([0, self._au.channels], dtype='float32') + return last_chunk diff --git a/LXST/Platforms/linux/__init__.py b/LXST/Platforms/linux/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/LXST/Platforms/linux/pulseaudio.h b/LXST/Platforms/linux/pulseaudio.h new file mode 100644 index 0000000..2d82ee9 --- /dev/null +++ b/LXST/Platforms/linux/pulseaudio.h @@ -0,0 +1,419 @@ + +typedef enum pa_stream_direction { + PA_STREAM_NODIRECTION, + PA_STREAM_PLAYBACK, + PA_STREAM_RECORD, + PA_STREAM_UPLOAD +} pa_stream_direction_t; + +typedef enum pa_sample_format { + PA_SAMPLE_U8, + PA_SAMPLE_ALAW, + PA_SAMPLE_ULAW, + PA_SAMPLE_S16LE, + PA_SAMPLE_S16BE, + PA_SAMPLE_FLOAT32LE, + PA_SAMPLE_FLOAT32BE, + PA_SAMPLE_S32LE, + PA_SAMPLE_S32BE, + PA_SAMPLE_S24LE, + PA_SAMPLE_S24BE, + PA_SAMPLE_S24_32LE, + PA_SAMPLE_S24_32BE, + PA_SAMPLE_MAX, + PA_SAMPLE_INVALID = -1 +} pa_sample_format_t; + +typedef struct pa_sample_spec { + pa_sample_format_t format; + uint32_t rate; + uint8_t channels; +} pa_sample_spec; + +typedef enum pa_channel_position { + PA_CHANNEL_POSITION_INVALID = -1, + PA_CHANNEL_POSITION_MONO = 0, + + PA_CHANNEL_POSITION_FRONT_LEFT, + PA_CHANNEL_POSITION_FRONT_RIGHT, + PA_CHANNEL_POSITION_FRONT_CENTER, + PA_CHANNEL_POSITION_LEFT = PA_CHANNEL_POSITION_FRONT_LEFT, + PA_CHANNEL_POSITION_RIGHT = PA_CHANNEL_POSITION_FRONT_RIGHT, + PA_CHANNEL_POSITION_CENTER = PA_CHANNEL_POSITION_FRONT_CENTER, + PA_CHANNEL_POSITION_REAR_CENTER, + PA_CHANNEL_POSITION_REAR_LEFT, + PA_CHANNEL_POSITION_REAR_RIGHT, + PA_CHANNEL_POSITION_LFE, + PA_CHANNEL_POSITION_SUBWOOFER = PA_CHANNEL_POSITION_LFE, + PA_CHANNEL_POSITION_FRONT_LEFT_OF_CENTER, + PA_CHANNEL_POSITION_FRONT_RIGHT_OF_CENTER, + PA_CHANNEL_POSITION_SIDE_LEFT, + PA_CHANNEL_POSITION_SIDE_RIGHT, + PA_CHANNEL_POSITION_AUX0, + PA_CHANNEL_POSITION_AUX1, + PA_CHANNEL_POSITION_AUX2, + PA_CHANNEL_POSITION_AUX3, + PA_CHANNEL_POSITION_AUX4, + PA_CHANNEL_POSITION_AUX5, + PA_CHANNEL_POSITION_AUX6, + PA_CHANNEL_POSITION_AUX7, + PA_CHANNEL_POSITION_AUX8, + PA_CHANNEL_POSITION_AUX9, + PA_CHANNEL_POSITION_AUX10, + PA_CHANNEL_POSITION_AUX11, + PA_CHANNEL_POSITION_AUX12, + PA_CHANNEL_POSITION_AUX13, + PA_CHANNEL_POSITION_AUX14, + PA_CHANNEL_POSITION_AUX15, + PA_CHANNEL_POSITION_AUX16, + PA_CHANNEL_POSITION_AUX17, + PA_CHANNEL_POSITION_AUX18, + PA_CHANNEL_POSITION_AUX19, + PA_CHANNEL_POSITION_AUX20, + PA_CHANNEL_POSITION_AUX21, + PA_CHANNEL_POSITION_AUX22, + PA_CHANNEL_POSITION_AUX23, + PA_CHANNEL_POSITION_AUX24, + PA_CHANNEL_POSITION_AUX25, + PA_CHANNEL_POSITION_AUX26, + PA_CHANNEL_POSITION_AUX27, + PA_CHANNEL_POSITION_AUX28, + PA_CHANNEL_POSITION_AUX29, + PA_CHANNEL_POSITION_AUX30, + PA_CHANNEL_POSITION_AUX31, + + PA_CHANNEL_POSITION_TOP_CENTER, + PA_CHANNEL_POSITION_TOP_FRONT_LEFT, + PA_CHANNEL_POSITION_TOP_FRONT_RIGHT, + PA_CHANNEL_POSITION_TOP_FRONT_CENTER, + PA_CHANNEL_POSITION_TOP_REAR_LEFT, + PA_CHANNEL_POSITION_TOP_REAR_RIGHT, + PA_CHANNEL_POSITION_TOP_REAR_CENTER, + PA_CHANNEL_POSITION_MAX +} pa_channel_position_t; + +#define PA_CHANNELS_MAX 32U + +typedef struct pa_channel_map { + uint8_t channels; + pa_channel_position_t map[PA_CHANNELS_MAX]; +} pa_channel_map; + +typedef enum pa_channel_map_def { + PA_CHANNEL_MAP_AIFF, + PA_CHANNEL_MAP_ALSA, + PA_CHANNEL_MAP_AUX, + PA_CHANNEL_MAP_WAVEEX, + PA_CHANNEL_MAP_OSS, + PA_CHANNEL_MAP_DEF_MAX, + PA_CHANNEL_MAP_DEFAULT = PA_CHANNEL_MAP_AIFF +} pa_channel_map_def_t; + +pa_channel_map* pa_channel_map_init_extend(pa_channel_map *m, unsigned channels, pa_channel_map_def_t def); +int pa_channel_map_valid(const pa_channel_map *map); +const char* pa_channel_position_to_string(pa_channel_position_t pos); + +typedef struct pa_buffer_attr { + uint32_t maxlength; + uint32_t tlength; + uint32_t prebuf; + uint32_t minreq; + uint32_t fragsize; +} pa_buffer_attr; + +typedef struct pa_simple pa_simple; + +pa_simple* pa_simple_new( + const char *server, + const char *name, + pa_stream_direction_t dir, + const char *dev, + const char *stream_name, + const pa_sample_spec *ss, + const pa_channel_map *map, + const pa_buffer_attr *attr, + int *error + ); + +typedef struct pa_mainloop pa_mainloop; +pa_mainloop *pa_mainloop_new(void); +void pa_mainloop_free(pa_mainloop* m); +int pa_mainloop_run(pa_mainloop *m, int *retval); +void pa_mainloop_quit(pa_mainloop *m, int retval); + +typedef struct pa_threaded_mainloop pa_threaded_mainloop; +pa_threaded_mainloop *pa_threaded_mainloop_new(void); +int pa_threaded_mainloop_start(pa_threaded_mainloop *m); +void pa_threaded_mainloop_stop(pa_threaded_mainloop *m); +void pa_threaded_mainloop_free(pa_threaded_mainloop *m); +void pa_threaded_mainloop_lock(pa_threaded_mainloop *m); +void pa_threaded_mainloop_unlock(pa_threaded_mainloop *m); + +typedef struct pa_mainloop_api pa_mainloop_api; +pa_mainloop_api* pa_mainloop_get_api(pa_mainloop*m); +pa_mainloop_api *pa_threaded_mainloop_get_api(pa_threaded_mainloop *m); + +typedef struct pa_context pa_context; +pa_context *pa_context_new(pa_mainloop_api *mainloop, const char *name); +void pa_context_unref(pa_context *c); +typedef enum pa_context_flags {PA_CONTEXT_NOFLAGS = 0} pa_context_flags_t; +typedef struct pa_spawn_api pa_spawn_api; +int pa_context_connect(pa_context *c, const char *server, pa_context_flags_t flags, const pa_spawn_api *api); +void pa_context_disconnect(pa_context *c); +int pa_context_errno(const pa_context *c); +typedef enum pa_context_state { + PA_CONTEXT_UNCONNECTED, + PA_CONTEXT_CONNECTING, + PA_CONTEXT_AUTHORIZING, + PA_CONTEXT_SETTING_NAME, + PA_CONTEXT_READY, + PA_CONTEXT_FAILED, + PA_CONTEXT_TERMINATED +} pa_context_state_t; +pa_context_state_t pa_context_get_state(pa_context *c); + +typedef struct pa_operation pa_operation; +pa_operation *pa_operation_ref(pa_operation *o); +void pa_operation_unref(pa_operation *o); +typedef enum pa_operation_state { + PA_OPERATION_RUNNING, + PA_OPERATION_DONE, + PA_OPERATION_CANCELLED +} pa_operation_state_t; +pa_operation_state_t pa_operation_get_state(pa_operation *o); + +typedef enum pa_sink_state { /* enum serialized in u8 */ + PA_SINK_INVALID_STATE = -1, + PA_SINK_RUNNING = 0, + PA_SINK_IDLE = 1, + PA_SINK_SUSPENDED = 2 +} pa_sink_state_t; + +typedef struct pa_proplist pa_proplist; +const char *pa_proplist_gets(pa_proplist *p, const char *key); + +typedef enum pa_encoding { + PA_ENCODING_ANY, + PA_ENCODING_PCM, + PA_ENCODING_AC3_IEC61937, + PA_ENCODING_EAC3_IEC61937, + PA_ENCODING_MPEG_IEC61937, + PA_ENCODING_DTS_IEC61937, + PA_ENCODING_MPEG2_AAC_IEC61937, + PA_ENCODING_MAX, + PA_ENCODING_INVALID = -1, +} pa_encoding_t; + +typedef struct pa_format_info { + pa_encoding_t encoding; + pa_proplist *plist; +} pa_format_info; + +typedef struct pa_sink_port_info { + const char *name; + const char *description; + uint32_t priority; + int available; +} pa_sink_port_info; + +typedef uint32_t pa_volume_t; +typedef struct pa_cvolume { + uint8_t channels; + pa_volume_t values[PA_CHANNELS_MAX]; +} pa_cvolume; + +typedef uint64_t pa_usec_t; + +typedef enum pa_sink_flags { + PA_SINK_NOFLAGS = 0x0000, + PA_SINK_HW_VOLUME_CTRL = 0x0001, + PA_SINK_LATENCY = 0x0002, + PA_SINK_HARDWARE = 0x0004, + PA_SINK_NETWORK = 0x0008, + PA_SINK_HW_MUTE_CTRL = 0x0010, + PA_SINK_DECIBEL_VOLUME = 0x0020, + PA_SINK_FLAT_VOLUME = 0x0040, + PA_SINK_DYNAMIC_LATENCY = 0x0080, + PA_SINK_SET_FORMATS = 0x0100 +} pa_sink_flags_t; + +typedef struct pa_sink_info { + const char *name; + uint32_t index; + const char *description; + pa_sample_spec sample_spec; + pa_channel_map channel_map; + uint32_t owner_module; + pa_cvolume volume; + int mute; + uint32_t monitor_source; + const char *monitor_source_name; + pa_usec_t latency; + const char *driver; + pa_sink_flags_t flags; + pa_proplist *proplist; + pa_usec_t configured_latency; + pa_volume_t base_volume; + pa_sink_state_t state; + uint32_t n_volume_steps; + uint32_t card; + uint32_t n_ports; + pa_sink_port_info** ports; + pa_sink_port_info* active_port; + uint8_t n_formats; + pa_format_info **formats; +} pa_sink_info; + +typedef struct pa_source_port_info { + const char *name; + const char *description; + uint32_t priority; + int available; +} pa_source_port_info; + +typedef enum pa_source_flags { + PA_SOURCE_NOFLAGS = 0x0000, + PA_SOURCE_HW_VOLUME_CTRL = 0x0001, + PA_SOURCE_LATENCY = 0x0002, + PA_SOURCE_HARDWARE = 0x0004, + PA_SOURCE_NETWORK = 0x0008, + PA_SOURCE_HW_MUTE_CTRL = 0x0010, + PA_SOURCE_DECIBEL_VOLUME = 0x0020, + PA_SOURCE_DYNAMIC_LATENCY = 0x0040, + PA_SOURCE_FLAT_VOLUME = 0x0080 +} pa_source_flags_t; + +typedef enum pa_source_state { + PA_SOURCE_INVALID_STATE = -1, + PA_SOURCE_RUNNING = 0, + PA_SOURCE_IDLE = 1, + PA_SOURCE_SUSPENDED = 2 +} pa_source_state_t; + +typedef struct pa_source_info { + const char *name; + uint32_t index; + const char *description; + pa_sample_spec sample_spec; + pa_channel_map channel_map; + uint32_t owner_module; + pa_cvolume volume; + int mute; + uint32_t monitor_of_sink; + const char *monitor_of_sink_name; + pa_usec_t latency; + const char *driver; + pa_source_flags_t flags; // + pa_proplist *proplist; + pa_usec_t configured_latency; + pa_volume_t base_volume; + pa_source_state_t state; // + uint32_t n_volume_steps; + uint32_t card; + uint32_t n_ports; + pa_source_port_info** ports; + pa_source_port_info* active_port; + uint8_t n_formats; + pa_format_info **formats; +} pa_source_info; + +typedef void (*pa_sink_info_cb_t)(pa_context *c, const pa_sink_info *i, int eol, void *userdata); +pa_operation* pa_context_get_sink_info_list(pa_context *c, pa_sink_info_cb_t cb, void *userdata); +pa_operation* pa_context_get_sink_info_by_name(pa_context *c, const char *name, pa_sink_info_cb_t cb, void *userdata); +typedef void (*pa_source_info_cb_t)(pa_context *c, const pa_source_info *i, int eol, void *userdata); +pa_operation* pa_context_get_source_info_list(pa_context *c, pa_source_info_cb_t cb, void *userdata); +pa_operation* pa_context_get_source_info_by_name(pa_context *c, const char *name, pa_source_info_cb_t cb, void *userdata); +typedef void (*pa_context_notify_cb)(pa_context *c, void *userdata); +pa_operation* pa_context_drain(pa_context *c, pa_context_notify_cb cb, void *userdata); +typedef void (*pa_context_success_cb_t)(pa_context *c, int success, void *userdata); +pa_operation* pa_context_set_name(pa_context *c, const char *name, pa_context_success_cb_t cb, void *userdata); +uint32_t pa_context_get_index(const pa_context *s); + +typedef struct pa_client_info { + uint32_t index; + const char *name; + uint32_t owner_module; + const char *driver; + pa_proplist *proplist; +} pa_client_info; +typedef void (*pa_client_info_cb_t) (pa_context *c, const pa_client_info*i, int eol, void *userdata); +pa_operation* pa_context_get_client_info(pa_context *c, uint32_t idx, pa_client_info_cb_t cb, void *userdata); + +typedef struct pa_server_info { + const char *user_name; + const char *host_name; + const char *server_version; + const char *server_name; + pa_sample_spec sample_spec; + const char *default_sink_name; + const char *default_source_name; + uint32_t cookie; + pa_channel_map channel_map; +} pa_server_info; +typedef void (*pa_server_info_cb_t) (pa_context *c, const pa_server_info*i, void *userdata); +pa_operation* pa_context_get_server_info(pa_context *c, pa_server_info_cb_t cb, void *userdata); + +int pa_sample_spec_valid(const pa_sample_spec *spec); + +typedef struct pa_stream pa_stream; +pa_stream* pa_stream_new(pa_context *c, const char *name, const pa_sample_spec *ss, const pa_channel_map *map); +void pa_stream_unref(pa_stream *s); + +typedef enum pa_stream_flags { + PA_STREAM_NOFLAGS = 0x0000, + PA_STREAM_START_CORKED = 0x0001, + PA_STREAM_INTERPOLATE_TIMING = 0x0002, + PA_STREAM_NOT_MONOTONIC = 0x0004, + PA_STREAM_AUTO_TIMING_UPDATE = 0x0008, + PA_STREAM_NO_REMAP_CHANNELS = 0x0010, + PA_STREAM_NO_REMIX_CHANNELS = 0x0020, + PA_STREAM_FIX_FORMAT = 0x0040, + PA_STREAM_FIX_RATE = 0x0080, + PA_STREAM_FIX_CHANNELS = 0x0100, + PA_STREAM_DONT_MOVE = 0x0200, + PA_STREAM_VARIABLE_RATE = 0x0400, + PA_STREAM_PEAK_DETECT = 0x0800, + PA_STREAM_START_MUTED = 0x1000, + PA_STREAM_ADJUST_LATENCY = 0x2000, + PA_STREAM_EARLY_REQUESTS = 0x4000, + PA_STREAM_DONT_INHIBIT_AUTO_SUSPEND = 0x8000, + PA_STREAM_START_UNMUTED = 0x10000, + PA_STREAM_FAIL_ON_SUSPEND = 0x20000, + PA_STREAM_RELATIVE_VOLUME = 0x40000, + PA_STREAM_PASSTHROUGH = 0x80000 +} pa_stream_flags_t; +int pa_stream_connect_playback(pa_stream *s, const char *dev, const pa_buffer_attr *attr, pa_stream_flags_t flags, const pa_cvolume *volume, pa_stream *sync_stream); +int pa_stream_connect_record(pa_stream *s, const char *dev, const pa_buffer_attr *attr, pa_stream_flags_t flags); +int pa_stream_disconnect(pa_stream *s); +typedef void (*pa_stream_success_cb_t) (pa_stream*s, int success, void *userdata); +pa_operation* pa_stream_cork(pa_stream *s, int b, pa_stream_success_cb_t cb, void *userdata); +pa_operation* pa_stream_drain(pa_stream *s, pa_stream_success_cb_t cb, void *userdata); +size_t pa_stream_writable_size(pa_stream *p); +size_t pa_stream_readable_size(pa_stream *p); +typedef void (*pa_free_cb_t)(void *p); +typedef enum pa_seek_mode { + PA_SEEK_RELATIVE = 0, + PA_SEEK_ABSOLUTE = 1, + PA_SEEK_RELATIVE_ON_READ = 2, + PA_SEEK_RELATIVE_END = 3 +} pa_seek_mode_t; +int pa_stream_write(pa_stream *p, const void *data, size_t nbytes, pa_free_cb_t free_cb, int64_t offset, pa_seek_mode_t seek); +int pa_stream_peek(pa_stream *p, const void **data, size_t *nbytes); +int pa_stream_drop(pa_stream *p); +int pa_stream_get_latency(pa_stream *s, pa_usec_t *r_usec, int *negative); +const pa_channel_map* pa_stream_get_channel_map(pa_stream *s); +const pa_buffer_attr* pa_stream_get_buffer_attr(pa_stream *s); + +typedef enum pa_stream_state { + PA_STREAM_UNCONNECTED, + PA_STREAM_CREATING, + PA_STREAM_READY, + PA_STREAM_FAILED, + PA_STREAM_TERMINATED +} pa_stream_state_t; +pa_stream_state_t pa_stream_get_state(pa_stream *p); + +typedef void(*pa_stream_request_cb_t)(pa_stream *p, size_t nbytes, void *userdata); +void pa_stream_set_read_callback(pa_stream *p, pa_stream_request_cb_t cb, void *userdata); + +pa_operation* pa_stream_update_timing_info(pa_stream *s, pa_stream_success_cb_t cb, void *userdata); diff --git a/LXST/Platforms/linux/soundcard.py b/LXST/Platforms/linux/soundcard.py new file mode 100644 index 0000000..d628e69 --- /dev/null +++ b/LXST/Platforms/linux/soundcard.py @@ -0,0 +1,944 @@ +# Adapted from Bastian Bechtold's soundcard library, originally released +# under the BSD 3-Clause License +# +# https://github.com/bastibe/SoundCard +# +# Copyright (c) 2016 Bastian Bechtold +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the +# distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Modifications and improvements Copyright 2025 Mark Qvist, and released +# under the same BSD 3-Clause License. + +import os +import atexit +import collections.abc +import time +import re +import threading +import warnings +import numpy +import cffi + +_ffi = cffi.FFI() +_package_dir, _ = os.path.split(__file__) +with open(os.path.join(_package_dir, "pulseaudio.h"), "rt") as f: _ffi.cdef(f.read()) + +# Try explicit file name, if the general does not work (e.g. on nixos) +try: _pa = _ffi.dlopen("pulse") +except OSError: _pa = _ffi.dlopen("libpulse.so") + +# First, we need to define a global _PulseAudio proxy for interacting +# with the C API: + +def _lock(func): + """Call a pulseaudio function while holding the mainloop lock.""" + def func_with_lock(*args, **kwargs): + self = args[0] + with self._lock_mainloop(): + return func(*args[1:], **kwargs) + return func_with_lock + + +def _lock_and_block(func): + """Call a pulseaudio function while holding the mainloop lock, and + block until the operation has finished. + + Use this for pulseaudio functions that return a `pa_operation *`. + + """ + def func_with_lock(*args, **kwargs): + self = args[0] + with self._lock_mainloop(): + operation = func(*args[1:], **kwargs) + self._block_operation(operation) + self._pa_operation_unref(operation) + return func_with_lock + + +def channel_name_map(): + """ + Return a dict containing the channel position index for every channel position name string. + """ + + channel_indices = { + _ffi.string(_pa.pa_channel_position_to_string(idx)).decode("utf-8"): idx for idx in + range(_pa.PA_CHANNEL_POSITION_MAX) + } + + # Append alternative names for front-left, front-right, front-center and lfe according to + # the PulseAudio definitions. + channel_indices.update({"left": _pa.PA_CHANNEL_POSITION_LEFT, + "right": _pa.PA_CHANNEL_POSITION_RIGHT, + "center": _pa.PA_CHANNEL_POSITION_CENTER, + "subwoofer": _pa.PA_CHANNEL_POSITION_SUBWOOFER}) + + # The values returned from Pulseaudio contain 1 for "left", 2 for "right" and so on. + # SoundCard"s channel indices for "left" start at 0. Therefore, we have to decrement all values. + channel_indices = {key: value - 1 for (key, value) in channel_indices.items()} + + return channel_indices + + +class _PulseAudio: + """Proxy for communcation with Pulseaudio. + + This holds the pulseaudio main loop, and a pulseaudio context. + Together, these provide the building blocks for interacting with + pulseaudio. + + This can be used to query the pulseaudio server for sources, + sinks, and server information, and provides thread-safe access to + the main pulseaudio functions. + + Any function that would return a `pa_operation *` in pulseaudio + will block until the operation has finished. + + """ + + def __init__(self): + # these functions are called before the mainloop starts, so we + # don't need to hold the lock: + self.mainloop = _pa.pa_threaded_mainloop_new() + self.mainloop_api = _pa.pa_threaded_mainloop_get_api(self.mainloop) + self.context = _pa.pa_context_new(self.mainloop_api, self._infer_program_name().encode()) + _pa.pa_context_connect(self.context, _ffi.NULL, _pa.PA_CONTEXT_NOFLAGS, _ffi.NULL) + _pa.pa_threaded_mainloop_start(self.mainloop) + + while self._pa_context_get_state(self.context) in (_pa.PA_CONTEXT_UNCONNECTED, _pa.PA_CONTEXT_CONNECTING, _pa.PA_CONTEXT_AUTHORIZING, _pa.PA_CONTEXT_SETTING_NAME): + time.sleep(0.001) + assert self._pa_context_get_state(self.context)==_pa.PA_CONTEXT_READY + + @staticmethod + def _infer_program_name(): + """Get current progam name. + + Will handle `./script.py`, `python path/to/script.py`, + `python -m module.submodule` and `python -c "code(x=y)"`. + See https://docs.python.org/3/using/cmdline.html#interface-options + """ + import sys + prog_name = sys.argv[0] + if prog_name == "-c": + return sys.argv[1][:30] + "..." + if prog_name == "-m": + prog_name = sys.argv[1] + # Usually even with -m, sys.argv[0] will already be a path, + # so do the following outside the above check + main_str = "/__main__.py" + if prog_name.endswith(main_str): + prog_name = prog_name[:-len(main_str)] + # Not handled: sys.argv[0] == "-" + return os.path.basename(prog_name) + + def _shutdown(self): + operation = self._pa_context_drain(self.context, _ffi.NULL, _ffi.NULL) + self._block_operation(operation) + self._pa_context_disconnect(self.context) + self._pa_context_unref(self.context) + # no more mainloop locking necessary from here on: + _pa.pa_threaded_mainloop_stop(self.mainloop) + _pa.pa_threaded_mainloop_free(self.mainloop) + + def _block_operation(self, operation): + """Wait until the operation has finished.""" + if operation == _ffi.NULL: + return + while self._pa_operation_get_state(operation) == _pa.PA_OPERATION_RUNNING: + time.sleep(0.001) + + @property + def name(self): + """Return application name stored in client proplist""" + idx = self._pa_context_get_index(self.context) + if idx < 0: # PA_INVALID_INDEX == -1 + raise RuntimeError("Could not get client index of PulseAudio context.") + name = None + @_ffi.callback("pa_client_info_cb_t") + def callback(context, client_info, eol, userdata): + nonlocal name + if not eol: + name = _ffi.string(client_info.name).decode("utf-8") + self._pa_context_get_client_info(self.context, idx, callback, _ffi.NULL) + assert name is not None + return name + + @name.setter + def name(self, name): + rv = None + @_ffi.callback("pa_context_success_cb_t") + def callback(context, success, userdata): + nonlocal rv + rv = success + self._pa_context_set_name(self.context, name.encode(), callback, _ffi.NULL) + assert rv is not None + if rv == 0: + raise RuntimeError("Setting PulseAudio context name failed") + + @property + def source_list(self): + """Return a list of dicts of information about available sources.""" + info = [] + @_ffi.callback("pa_source_info_cb_t") + def callback(context, source_info, eol, userdata): + if not eol: + info.append(dict(name=_ffi.string(source_info.description).decode("utf-8"), + id=_ffi.string(source_info.name).decode("utf-8"))) + self._pa_context_get_source_info_list(self.context, callback, _ffi.NULL) + return info + + def source_info(self, id): + """Return a dictionary of information about a specific source.""" + info = [] + @_ffi.callback("pa_source_info_cb_t") + def callback(context, source_info, eol, userdata): + if not eol: + info_dict = dict(latency=source_info.latency, + configured_latency=source_info.configured_latency, + channels=source_info.sample_spec.channels, + name=_ffi.string(source_info.description).decode("utf-8")) + for prop in ["device.class", "device.api", "device.bus"]: + data = _pa.pa_proplist_gets(source_info.proplist, prop.encode()) + info_dict[prop] = _ffi.string(data).decode("utf-8") if data else None + info.append(info_dict) + + self._pa_context_get_source_info_by_name(self.context, id.encode(), callback, _ffi.NULL) + return info[0] + + @property + def sink_list(self): + """Return a list of dicts of information about available sinks.""" + info = [] + @_ffi.callback("pa_sink_info_cb_t") + def callback(context, sink_info, eol, userdata): + if not eol: + info.append((dict(name=_ffi.string(sink_info.description).decode("utf-8"), + id=_ffi.string(sink_info.name).decode("utf-8")))) + self._pa_context_get_sink_info_list(self.context, callback, _ffi.NULL) + return info + + def sink_info(self, id): + """Return a dictionary of information about a specific sink.""" + info = [] + @_ffi.callback("pa_sink_info_cb_t") + def callback(context, sink_info, eol, userdata): + if not eol: + info_dict = dict(latency=sink_info.latency, + configured_latency=sink_info.configured_latency, + channels=sink_info.sample_spec.channels, + name=_ffi.string(sink_info.description).decode("utf-8")) + for prop in ["device.class", "device.api", "device.bus"]: + data = _pa.pa_proplist_gets(sink_info.proplist, prop.encode()) + info_dict[prop] = _ffi.string(data).decode("utf-8") if data else None + info.append(info_dict) + self._pa_context_get_sink_info_by_name(self.context, id.encode(), callback, _ffi.NULL) + return info[0] + + @property + def server_info(self): + """Return a dictionary of information about the server.""" + info = {} + @_ffi.callback("pa_server_info_cb_t") + def callback(context, server_info, userdata): + info["server version"] = _ffi.string(server_info.server_version).decode("utf-8") + info["server name"] = _ffi.string(server_info.server_name).decode("utf-8") + info["default sink id"] = _ffi.string(server_info.default_sink_name).decode("utf-8") + info["default source id"] = _ffi.string(server_info.default_source_name).decode("utf-8") + self._pa_context_get_server_info(self.context, callback, _ffi.NULL) + return info + + def _lock_mainloop(self): + """Context manager for locking the mainloop. + + Hold this lock before calling any pulseaudio function while + the mainloop is running. + + """ + + class Lock(): + def __enter__(self_): + _pa.pa_threaded_mainloop_lock(self.mainloop) + def __exit__(self_, exc_type, exc_value, traceback): + _pa.pa_threaded_mainloop_unlock(self.mainloop) + return Lock() + + # create thread-safe versions of all used pulseaudio functions: + _pa_context_get_source_info_list = _lock_and_block(_pa.pa_context_get_source_info_list) + _pa_context_get_source_info_by_name = _lock_and_block(_pa.pa_context_get_source_info_by_name) + _pa_context_get_sink_info_list = _lock_and_block(_pa.pa_context_get_sink_info_list) + _pa_context_get_sink_info_by_name = _lock_and_block(_pa.pa_context_get_sink_info_by_name) + _pa_context_get_client_info = _lock_and_block(_pa.pa_context_get_client_info) + _pa_context_get_server_info = _lock_and_block(_pa.pa_context_get_server_info) + _pa_context_get_index = _lock(_pa.pa_context_get_index) + _pa_context_get_state = _lock(_pa.pa_context_get_state) + _pa_context_set_name = _lock_and_block(_pa.pa_context_set_name) + _pa_context_drain = _lock(_pa.pa_context_drain) + _pa_context_disconnect = _lock(_pa.pa_context_disconnect) + _pa_context_unref = _lock(_pa.pa_context_unref) + _pa_context_errno = _lock(_pa.pa_context_errno) + _pa_operation_get_state = _lock(_pa.pa_operation_get_state) + _pa_operation_unref = _lock(_pa.pa_operation_unref) + _pa_stream_get_state = _lock(_pa.pa_stream_get_state) + _pa_sample_spec_valid = _lock(_pa.pa_sample_spec_valid) + _pa_stream_new = _lock(_pa.pa_stream_new) + _pa_stream_get_channel_map = _lock(_pa.pa_stream_get_channel_map) + _pa_stream_drain = _lock_and_block(_pa.pa_stream_drain) + _pa_stream_disconnect = _lock(_pa.pa_stream_disconnect) + _pa_stream_unref = _lock(_pa.pa_stream_unref) + _pa_stream_connect_record = _lock(_pa.pa_stream_connect_record) + _pa_stream_readable_size = _lock(_pa.pa_stream_readable_size) + _pa_stream_peek = _lock(_pa.pa_stream_peek) + _pa_stream_drop = _lock(_pa.pa_stream_drop) + _pa_stream_connect_playback = _lock(_pa.pa_stream_connect_playback) + _pa_stream_update_timing_info = _lock_and_block(_pa.pa_stream_update_timing_info) + _pa_stream_get_latency = _lock(_pa.pa_stream_get_latency) + _pa_stream_writable_size = _lock(_pa.pa_stream_writable_size) + _pa_stream_write = _lock(_pa.pa_stream_write) + _pa_stream_set_read_callback = _pa.pa_stream_set_read_callback + +_pulse = _PulseAudio() +atexit.register(_pulse._shutdown) + +def all_speakers(): + """A list of all connected speakers. + + Returns + ------- + speakers : list(_Speaker) + + """ + return [_Speaker(id=s["id"]) for s in _pulse.sink_list] + + +def default_speaker(): + """The default speaker of the system. + + Returns + ------- + speaker : _Speaker + + """ + name = _pulse.server_info["default sink id"] + return get_speaker(name) + + +def get_speaker(id): + """Get a specific speaker by a variety of means. + + Parameters + ---------- + id : int or str + can be a backend id string (Windows, Linux) or a device id int (MacOS), a substring of the + speaker name, or a fuzzy-matched pattern for the speaker name. + + Returns + ------- + speaker : _Speaker + + """ + speakers = _pulse.sink_list + return _Speaker(id=_match_soundcard(id, speakers)["id"]) + + +def all_microphones(include_loopback=False, exclude_monitors=True): + """A list of all connected microphones. + + By default, this does not include loopbacks (virtual microphones + that record the output of a speaker). + + Parameters + ---------- + include_loopback : bool + allow recording of speaker outputs + exclude_monitors : bool + deprecated version of ``include_loopback`` + + Returns + ------- + microphones : list(_Microphone) + + """ + + if not exclude_monitors: + warnings.warn("The exclude_monitors flag is being replaced by the include_loopback flag", DeprecationWarning) + include_loopback = not exclude_monitors + + mics = [_Microphone(id=m["id"]) for m in _pulse.source_list] + if not include_loopback: + return [m for m in mics if m._get_info()["device.class"] != "monitor"] + else: + return mics + + +def default_microphone(): + """The default microphone of the system. + + Returns + ------- + microphone : _Microphone + """ + name = _pulse.server_info["default source id"] + return get_microphone(name, include_loopback=True) + + +def get_microphone(id, include_loopback=False, exclude_monitors=True): + """Get a specific microphone by a variety of means. + + By default, this does not include loopbacks (virtual microphones + that record the output of a speaker). + + Parameters + ---------- + id : int or str + can be a backend id string (Windows, Linux) or a device id int (MacOS), a substring of the + speaker name, or a fuzzy-matched pattern for the speaker name. + include_loopback : bool + allow recording of speaker outputs + exclude_monitors : bool + deprecated version of ``include_loopback`` + + Returns + ------- + microphone : _Microphone + """ + + if not exclude_monitors: + warnings.warn("The exclude_monitors flag is being replaced by the include_loopback flag", DeprecationWarning) + include_loopback = not exclude_monitors + + microphones = _pulse.source_list + return _Microphone(id=_match_soundcard(id, microphones, include_loopback)["id"]) + + +def _match_soundcard(id, soundcards, include_loopback=False): + """Find id in a list of soundcards. + + id can be a pulseaudio id, a substring of the microphone name, or + a fuzzy-matched pattern for the microphone name. + """ + if not include_loopback: + soundcards_by_id = {soundcard["id"]: soundcard for soundcard in soundcards + if not "monitor" in soundcard["id"]} + soundcards_by_name = {soundcard["name"]: soundcard for soundcard in soundcards + if not "monitor" in soundcard["id"]} + else: + soundcards_by_id = {soundcard["id"]: soundcard for soundcard in soundcards} + soundcards_by_name = {soundcard["name"]: soundcard for soundcard in soundcards} + if id in soundcards_by_id: + return soundcards_by_id[id] + # try substring match: + for name, soundcard in soundcards_by_name.items(): + if id in name: + return soundcard + # try fuzzy match: + pattern = ".*".join(id) + for name, soundcard in soundcards_by_name.items(): + if re.match(pattern, name): + return soundcard + raise IndexError("no soundcard with id {}".format(id)) + + +def get_name(): + """Get application name. + + .. note:: + Currently only works on Linux. + + Returns + ------- + name : str + """ + return _pulse.name + + +def set_name(name): + """Set application name. + + .. note:: + Currently only works on Linux. + + Parameters + ---------- + name : str + The application using the soundcard + will be identified by the OS using this name. + """ + _pulse.name = name + + +class _SoundCard: + def __init__(self, *, id): + self._id = id + + @property + def channels(self): + """int or list(int): Either the number of channels, or a list of + channel indices. Index -1 is the mono mixture of all channels, + and subsequent numbers are channel numbers (left, right, + center, ...) + + """ + return self._get_info()["channels"] + + @property + def id(self): + """object: A backend-dependent unique ID.""" + return self._id + + @property + def name(self): + """str: The human-readable name of the soundcard.""" + return self._get_info()["name"] + + def _get_info(self): + return _pulse.source_info(self._id) + + +class _Speaker(_SoundCard): + """A soundcard output. Can be used to play audio. + + Use the :func:`play` method to play one piece of audio, or use the + :func:`player` method to get a context manager for playing continuous + audio. + + Multiple calls to :func:`play` play immediately and concurrently, + while the :func:`player` schedules multiple pieces of audio one + after another. + + """ + + def __repr__(self): + return "".format(self.name, self.channels) + + def player(self, samplerate, channels=None, blocksize=None): + """Create Player for playing audio. + + Parameters + ---------- + samplerate : int + The desired sampling rate in Hz + channels : {int, list(int)}, optional + Play on these channels. For example, ``[0, 3]`` will play + stereo data on the physical channels one and four. + Defaults to use all available channels. + On Linux, channel ``-1`` is the mono mix of all channels. + On macOS, channel ``-1`` is silence. + blocksize : int + Will play this many samples at a time. Choose a lower + block size for lower latency and more CPU usage. + exclusive_mode : bool, optional + Windows only: open sound card in exclusive mode, which + might be necessary for short block lengths or high + sample rates or optimal performance. Default is ``False``. + + Returns + ------- + player : _Player + """ + if channels is None: + channels = self.channels + return _Player(self._id, samplerate, channels, blocksize) + + def play(self, data, samplerate, channels=None, blocksize=None): + """Play some audio data. + + Parameters + ---------- + data : numpy array + The audio data to play. Must be a *frames x channels* Numpy array. + samplerate : int + The desired sampling rate in Hz + channels : {int, list(int)}, optional + Play on these channels. For example, ``[0, 3]`` will play + stereo data on the physical channels one and four. + Defaults to use all available channels. + On Linux, channel ``-1`` is the mono mix of all channels. + On macOS, channel ``-1`` is silence. + blocksize : int + Will play this many samples at a time. Choose a lower + block size for lower latency and more CPU usage. + """ + if channels is None: + channels = self.channels + with _Player(self._id, samplerate, channels, blocksize) as s: + s.play(data) + + def _get_info(self): + return _pulse.sink_info(self._id) + + +class _Microphone(_SoundCard): + """A soundcard input. Can be used to record audio. + + Use the :func:`record` method to record one piece of audio, or use + the :func:`recorder` method to get a context manager for recording + continuous audio. + + Multiple calls to :func:`record` record immediately and + concurrently, while the :func:`recorder` schedules multiple pieces + of audio to be recorded one after another. + + """ + + def __repr__(self): + if self.isloopback: + return "".format(self.name, self.channels) + else: + return "".format(self.name, self.channels) + + @property + def isloopback(self): + """bool : Whether this microphone is recording a speaker.""" + return self._get_info()["device.class"] == "monitor" + + def recorder(self, samplerate, channels=None, blocksize=None): + """Create Recorder for recording audio. + + Parameters + ---------- + samplerate : int + The desired sampling rate in Hz + channels : {int, list(int)}, optional + Record on these channels. For example, ``[0, 3]`` will record + stereo data from the physical channels one and four. + Defaults to use all available channels. + On Linux, channel ``-1`` is the mono mix of all channels. + On macOS, channel ``-1`` is silence. + blocksize : int + Will record this many samples at a time. Choose a lower + block size for lower latency and more CPU usage. + exclusive_mode : bool, optional + Windows only: open sound card in exclusive mode, which + might be necessary for short block lengths or high + sample rates or optimal performance. Default is ``False``. + + Returns + ------- + recorder : _Recorder + """ + if channels is None: + channels = self.channels + return _Recorder(self._id, samplerate, channels, blocksize) + + def record(self, numframes, samplerate, channels=None, blocksize=None): + """Record some audio data. + + Parameters + ---------- + numframes: int + The number of frames to record. + samplerate : int + The desired sampling rate in Hz + channels : {int, list(int)}, optional + Record on these channels. For example, ``[0, 3]`` will record + stereo data from the physical channels one and four. + Defaults to use all available channels. + On Linux, channel ``-1`` is the mono mix of all channels. + On macOS, channel ``-1`` is silence. + blocksize : int + Will record this many samples at a time. Choose a lower + block size for lower latency and more CPU usage. + + Returns + ------- + data : numpy array + The recorded audio data. Will be a *frames x channels* Numpy array. + """ + if channels is None: + channels = self.channels + with _Recorder(self._id, samplerate, channels, blocksize) as r: + return r.record(numframes) + + +class _Stream: + """A context manager for an active audio stream. + + This class is meant to be subclassed. Children must implement the + `_connect_stream` method which takes a `pa_buffer_attr*` struct, + and connects an appropriate stream. + + This context manager can only be entered once, and can not be used + after it is closed. + + """ + + def __init__(self, id, samplerate, channels, blocksize=None, name="outputstream"): + self._id = id + self._samplerate = samplerate + self._name = name + self._blocksize = blocksize + self.channels = channels + + def __enter__(self): + samplespec = _ffi.new("pa_sample_spec*") + samplespec.format = _pa.PA_SAMPLE_FLOAT32LE + samplespec.rate = self._samplerate + if isinstance(self.channels, collections.abc.Iterable): + samplespec.channels = len(self.channels) + elif isinstance(self.channels, int): + samplespec.channels = self.channels + else: + raise TypeError("channels must be iterable or integer") + if not _pulse._pa_sample_spec_valid(samplespec): + raise RuntimeError("invalid sample spec") + + # pam and channelmap refer to the same object, but need different + # names to avoid garbage collection trouble on the Python/C boundary + pam = _ffi.new("pa_channel_map*") + channelmap = _pa.pa_channel_map_init_extend(pam, samplespec.channels, _pa.PA_CHANNEL_MAP_DEFAULT) + if isinstance(self.channels, collections.abc.Iterable): + for idx, ch in enumerate(self.channels): + if isinstance(ch, int): + channelmap.map[idx] = ch + 1 + else: + channel_name_to_index = channel_name_map() + channelmap.map[idx] = channel_name_to_index[ch] + 1 + + if not _pa.pa_channel_map_valid(channelmap): + raise RuntimeError("invalid channel map") + + self.stream = _pulse._pa_stream_new(_pulse.context, self._name.encode(), samplespec, channelmap) + if not self.stream: + errno = _pulse._pa_context_errno(_pulse.context) + raise RuntimeError("stream creation failed with error ", errno) + bufattr = _ffi.new("pa_buffer_attr*") + bufattr.maxlength = 2**32-1 # max buffer length + numchannels = self.channels if isinstance(self.channels, int) else len(self.channels) + bufattr.fragsize = self._blocksize*numchannels*4 if self._blocksize else 2**32-1 # recording block sys.getsizeof() + bufattr.minreq = 2**32-1 # start requesting more data at this bytes + bufattr.prebuf = 2**32-1 # start playback after this bytes are available + bufattr.tlength = self._blocksize*numchannels*4 if self._blocksize else 2**32-1 # buffer length in bytes on server + self._connect_stream(bufattr) + while _pulse._pa_stream_get_state(self.stream) not in [_pa.PA_STREAM_READY, _pa.PA_STREAM_FAILED]: + time.sleep(0.01) + if _pulse._pa_stream_get_state(self.stream) == _pa.PA_STREAM_FAILED: + raise RuntimeError("Stream creation failed. Stream is in status {}" + .format(_pulse._pa_stream_get_state(self.stream))) + channel_map = _pulse._pa_stream_get_channel_map(self.stream) + self.channels = int(channel_map.channels) + return self + + def __exit__(self, exc_type, exc_value, traceback): + if isinstance(self, _Player): # only playback streams need to drain + _pulse._pa_stream_drain(self.stream, _ffi.NULL, _ffi.NULL) + _pulse._pa_stream_disconnect(self.stream) + while _pulse._pa_stream_get_state(self.stream) not in (_pa.PA_STREAM_TERMINATED, _pa.PA_STREAM_FAILED): + time.sleep(0.01) + _pulse._pa_stream_unref(self.stream) + + @property + def latency(self): + """float : Latency of the stream in seconds (only available on Linux)""" + _pulse._pa_stream_update_timing_info(self.stream, _ffi.NULL, _ffi.NULL) + microseconds = _ffi.new("pa_usec_t*") + _pulse._pa_stream_get_latency(self.stream, microseconds, _ffi.NULL) + return microseconds[0] / 1000000 # 1_000_000 (3.5 compat) + + +class _Player(_Stream): + """A context manager for an active output stream. + + Audio playback is available as soon as the context manager is + entered. Audio data can be played using the :func:`play` method. + Successive calls to :func:`play` will queue up the audio one piece + after another. If no audio is queued up, this will play silence. + + This context manager can only be entered once, and can not be used + after it is closed. + + """ + + def _connect_stream(self, bufattr): + _pulse._pa_stream_connect_playback(self.stream, self._id.encode(), bufattr, _pa.PA_STREAM_ADJUST_LATENCY, + _ffi.NULL, _ffi.NULL) + + def play(self, data): + """Play some audio data. + + Internally, all data is handled as ``float32`` and with the + appropriate number of channels. For maximum performance, + provide data as a *frames × channels* float32 numpy array. + + If single-channel or one-dimensional data is given, this data + will be played on all available channels. + + This function will return *before* all data has been played, + so that additional data can be provided for gapless playback. + The amount of buffering can be controlled through the + blocksize of the player object. + + If data is provided faster than it is played, later pieces + will be queued up and played one after another. + + Parameters + ---------- + data : numpy array + The audio data to play. Must be a *frames x channels* Numpy array. + + """ + + data = numpy.array(data, dtype="float32", order="C") + if data.ndim == 1: + data = data[:, None] # force 2d + if data.ndim != 2: + raise TypeError("data must be 1d or 2d, not {}d".format(data.ndim)) + if data.shape[1] == 1 and self.channels != 1: + data = numpy.tile(data, [1, self.channels]) + if data.shape[1] != self.channels: + raise TypeError("second dimension of data must be equal to the number of channels, not {}".format(data.shape[1])) + while data.nbytes > 0: + nwrite = _pulse._pa_stream_writable_size(self.stream) // (4 * self.channels) # 4 bytes per sample + + if nwrite == 0: + time.sleep(0.001) + continue + bytes = data[:nwrite].ravel().tobytes() + _pulse._pa_stream_write(self.stream, bytes, len(bytes), _ffi.NULL, 0, _pa.PA_SEEK_RELATIVE) + data = data[nwrite:] + +class _Recorder(_Stream): + """A context manager for an active input stream. + + Audio recording is available as soon as the context manager is + entered. Recorded audio data can be read using the :func:`record` + method. If no audio data is available, :func:`record` will block until + the requested amount of audio data has been recorded. + + This context manager can only be entered once, and can not be used + after it is closed. + + """ + + def __init__(self, *args, **kwargs): + super(_Recorder, self).__init__(*args, **kwargs) + self._pending_chunk = numpy.zeros((0, ), dtype="float32") + self._record_event = threading.Event() + + def _connect_stream(self, bufattr): + _pulse._pa_stream_connect_record(self.stream, self._id.encode(), bufattr, _pa.PA_STREAM_ADJUST_LATENCY) + @_ffi.callback("pa_stream_request_cb_t") + def read_callback(stream, nbytes, userdata): + self._record_event.set() + self._callback = read_callback + _pulse._pa_stream_set_read_callback(self.stream, read_callback, _ffi.NULL) + + def _record_chunk(self): + '''Record one chunk of audio data, as returned by pulseaudio + + The data will be returned as a 1D numpy array, which will be used by + the `record` method. This function is the interface of the `_Recorder` + object with pulseaudio + ''' + data_ptr = _ffi.new("void**") + nbytes_ptr = _ffi.new("size_t*") + readable_bytes = _pulse._pa_stream_readable_size(self.stream) + while not readable_bytes: + if not self._record_event.wait(timeout=1): + if _pulse._pa_stream_get_state(self.stream) == _pa.PA_STREAM_FAILED: + raise RuntimeError("Recording failed, stream is in status FAILED") + self._record_event.clear() + readable_bytes = _pulse._pa_stream_readable_size(self.stream) + data_ptr[0] = _ffi.NULL + nbytes_ptr[0] = 0 + _pulse._pa_stream_peek(self.stream, data_ptr, nbytes_ptr) + if data_ptr[0] != _ffi.NULL: + buffer = _ffi.buffer(data_ptr[0], nbytes_ptr[0]) + chunk = numpy.frombuffer(buffer, dtype="float32").copy() + if data_ptr[0] == _ffi.NULL and nbytes_ptr[0] != 0: + chunk = numpy.zeros(nbytes_ptr[0]//4, dtype="float32") + if nbytes_ptr[0] > 0: + _pulse._pa_stream_drop(self.stream) + return chunk + + def record(self, numframes=None): + """Record a block of audio data. + + The data will be returned as a *frames × channels* float32 + numpy array. This function will wait until ``numframes`` + frames have been recorded. If numframes is given, it will + return exactly ``numframes`` frames, and buffer the rest for + later. + + If ``numframes`` is None, it will return whatever the audio + backend has available right now. Use this if latency must be + kept to a minimum, but be aware that block sizes can change at + the whims of the audio backend. + + If using :func:`record` with ``numframes=None`` after using + :func:`record` with a required ``numframes``, the last + buffered frame will be returned along with the new recorded + block. (If you want to empty the last buffered frame instead, + use :func:`flush`) + + Parameters + ---------- + numframes : int, optional + The number of frames to record. + + Returns + ------- + data : numpy array + The recorded audio data. Will be a *frames x channels* Numpy array. + + """ + if numframes is None: + return numpy.reshape(numpy.concatenate([self.flush().ravel(), self._record_chunk()]), + [-1, self.channels]) + else: + captured_data = [self._pending_chunk] + captured_frames = self._pending_chunk.shape[0] / self.channels + if captured_frames >= numframes: + keep, self._pending_chunk = numpy.split(self._pending_chunk, + [int(numframes * self.channels)]) + return numpy.reshape(keep, [-1, self.channels]) + else: + while captured_frames < numframes: + chunk = self._record_chunk() + captured_data.append(chunk) + captured_frames += len(chunk)/self.channels + to_split = int(len(chunk) - (captured_frames - numframes) * self.channels) + captured_data[-1], self._pending_chunk = numpy.split(captured_data[-1], [to_split]) + return numpy.reshape(numpy.concatenate(captured_data), [-1, self.channels]) + + def flush(self): + """Return the last pending chunk. + + After using the :func:`record` method, this will return the + last incomplete chunk and delete it. + + Returns + ------- + data : numpy array + The recorded audio data. Will be a *frames x channels* Numpy array. + + """ + last_chunk = numpy.reshape(self._pending_chunk, [-1, self.channels]) + self._pending_chunk = numpy.zeros((0, ), dtype="float32") + return last_chunk diff --git a/LXST/Platforms/windows/__init__.py b/LXST/Platforms/windows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/LXST/Platforms/windows/mediafoundation.h b/LXST/Platforms/windows/mediafoundation.h new file mode 100644 index 0000000..7f18cdd --- /dev/null +++ b/LXST/Platforms/windows/mediafoundation.h @@ -0,0 +1,256 @@ +// see um/winnt.h: +typedef long HRESULT; +typedef wchar_t *LPWSTR; +typedef long long LONGLONG; + +// originally, struct=interface, see um/combaseapi.h + +// see shared/rpcndr.h: +typedef unsigned char byte; + +// see shared/guiddef.h: +typedef struct { + unsigned long Data1; + unsigned short Data2; + unsigned short Data3; + byte Data4[ 8 ]; +} GUID; +typedef GUID IID; +typedef IID *LPIID; + +// see um/mmdeviceapi.h: +typedef struct IMMDeviceEnumerator IMMDeviceEnumerator; +typedef struct IMMDeviceCollection IMMDeviceCollection; +typedef struct IMMDevice IMMDevice; +typedef struct IMMNotificationClient IMMNotificationClient; + +// see um/mfidl.h: +typedef struct IMFMediaSink IMFMediaSink; + +// see um/mfobjects.h: +typedef struct IMFAttributes IMFAttributes; + +// see um/Unknwn.h: +typedef struct IUnknown IUnknown; +typedef IUnknown *LPUNKNOWN; + +// see shared/wtypes.h: +typedef unsigned long DWORD; +typedef const char *LPCSTR; + +// see shared/WTypesbase.h: +typedef void *LPVOID; +typedef LPCSTR LPCOLESTR; +typedef IID *REFIID; + +// see um/combaseapi.h: +HRESULT CoCreateInstance(const GUID* rclsid, LPUNKNOWN pUnkOuter, DWORD dwClsContext, const GUID* riid, LPVOID * ppv); +HRESULT IIDFromString(LPCOLESTR lpsz, LPIID lpiid); +HRESULT CoInitializeEx(LPVOID pvReserved, DWORD dwCoInit); +void CoTaskMemFree(LPVOID pv); +LPVOID CoTaskMemAlloc(size_t cb); +void CoUninitialize(void); + +// see um/mmdeviceapi.h: +typedef enum EDataFlow {eRender, eCapture, eAll, EDataFlow_enum_count} EDataFlow; + +typedef enum ERole {eConsole, eMultimedia, eCommunications, ERole_enum_count} ERole; + +typedef struct IMMDeviceEnumeratorVtbl +{ + HRESULT ( __stdcall *QueryInterface )(IMMDeviceEnumerator * This, const GUID *riid, void **ppvObject); + ULONG ( __stdcall *AddRef )(IMMDeviceEnumerator * This); + ULONG ( __stdcall *Release )(IMMDeviceEnumerator * This); + HRESULT ( __stdcall *EnumAudioEndpoints )(IMMDeviceEnumerator * This, EDataFlow dataFlow, DWORD dwStateMask, IMMDeviceCollection **ppDevices); + HRESULT ( __stdcall *GetDefaultAudioEndpoint )(IMMDeviceEnumerator * This, EDataFlow dataFlow, ERole role, IMMDevice **ppEndpoint); + HRESULT ( __stdcall *GetDevice )(IMMDeviceEnumerator * This, LPCWSTR pwstrId, IMMDevice **ppDevice); +/* I hope I won't need these + HRESULT ( __stdcall *RegisterEndpointNotificationCallback )(IMMDeviceEnumerator * This, IMMNotificationClient *pClient); + HRESULT ( __stdcall *UnregisterEndpointNotificationCallback )(IMMDeviceEnumerator * This, IMMNotificationClient *pClient); +*/ +} IMMDeviceEnumeratorVtbl; + +struct IMMDeviceEnumerator +{ + const struct IMMDeviceEnumeratorVtbl *lpVtbl; +}; + +typedef struct IMMDeviceCollectionVtbl +{ + HRESULT ( __stdcall *QueryInterface )(IMMDeviceCollection * This, REFIID riid, void **ppvObject); + ULONG ( __stdcall *AddRef )(IMMDeviceCollection * This); + ULONG ( __stdcall *Release )(IMMDeviceCollection * This); + HRESULT ( __stdcall *GetCount )(IMMDeviceCollection * This, UINT *pcDevices); + HRESULT ( __stdcall *Item )(IMMDeviceCollection * This, UINT nDevice, IMMDevice **ppDevice); +} IMMDeviceCollectionVtbl; + +struct IMMDeviceCollection +{ + const struct IMMDeviceCollectionVtbl *lpVtbl; +}; + +// um/propsys.h +typedef struct IPropertyStore IPropertyStore; +// um/combaseapi.h +typedef struct tag_inner_PROPVARIANT PROPVARIANT; +// shared/wtypes.h +typedef unsigned short VARTYPE; +// um/propidl.h +struct tag_inner_PROPVARIANT { + VARTYPE vt; + WORD wReserved1; + WORD wReserved2; + WORD wReserved3; + void * data; +}; +void PropVariantInit(PROPVARIANT *p); +HRESULT PropVariantClear(PROPVARIANT *p); + +typedef struct IMMDeviceVtbl { + HRESULT ( __stdcall *QueryInterface )(IMMDevice * This, REFIID riid, void **ppvObject); + ULONG ( __stdcall *AddRef )(IMMDevice * This); + ULONG ( __stdcall *Release )(IMMDevice * This); + HRESULT ( __stdcall *Activate )(IMMDevice * This, REFIID iid, DWORD dwClsCtx, PROPVARIANT *pActivationParams, void **ppInterface); + HRESULT ( __stdcall *OpenPropertyStore )(IMMDevice * This, DWORD stgmAccess, IPropertyStore **ppProperties); + HRESULT ( __stdcall *GetId )(IMMDevice * This, LPWSTR *ppstrId); + HRESULT ( __stdcall *GetState )(IMMDevice * This, DWORD *pdwState); +} IMMDeviceVtbl; + +struct IMMDevice { + const struct IMMDeviceVtbl *lpVtbl; +}; + +// um/propkeydef.h +typedef struct { + GUID fmtid; + DWORD pid; +} PROPERTYKEY; + +const PROPERTYKEY PKEY_Device_FriendlyName = {{0xa45c254e, 0xdf1c, 0x4efd, {0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0}}, 14}; +const PROPERTYKEY PKEY_AudioEngine_DeviceFormat = {{0xf19f064d, 0x82c, 0x4e27, {0xbc, 0x73, 0x68, 0x82, 0xa1, 0xbb, 0x8e, 0x4c}}, 0}; + +typedef struct IPropertyStoreVtbl { + HRESULT ( __stdcall *QueryInterface )(IPropertyStore * This, REFIID riid, void **ppvObject); + ULONG ( __stdcall *AddRef )(IPropertyStore * This); + ULONG ( __stdcall *Release )(IPropertyStore * This); + HRESULT ( __stdcall *GetCount )(IPropertyStore * This, DWORD *cProps); + HRESULT ( __stdcall *GetAt )(IPropertyStore * This, DWORD iProp, PROPERTYKEY *pkey); + HRESULT ( __stdcall *GetValue )(IPropertyStore * This, const PROPERTYKEY *key, PROPVARIANT *pv); + HRESULT ( __stdcall *SetValue )(IPropertyStore * This, const PROPERTYKEY *key, const PROPVARIANT *propvar); + HRESULT ( __stdcall *Commit )(IPropertyStore * This); +} IPropertyStoreVtbl; + +struct IPropertyStore { + const struct IPropertyStoreVtbl *lpVtbl; +}; + +// shared/WTypesbase.h +typedef struct tagBLOB { + ULONG cbSize; + BYTE *pBlobData; +} BLOB; + + +typedef struct tag_inner_BLOB_PROPVARIANT BLOB_PROPVARIANT; +struct tag_inner_BLOB_PROPVARIANT { + VARTYPE vt; + WORD wReserved1; + WORD wReserved2; + WORD wReserved3; + BLOB blob; +}; + +typedef struct WAVEFORMATEX { + WORD wFormatTag; /* format type */ + WORD nChannels; /* number of channels (i.e. mono, stereo...) */ + DWORD nSamplesPerSec; /* sample rate */ + DWORD nAvgBytesPerSec; /* for buffer estimation */ + WORD nBlockAlign; /* block size of data */ + WORD wBitsPerSample; /* Number of bits per sample of mono data */ + WORD cbSize; /* The count in bytes of the size of + extra information (after cbSize) */ +} WAVEFORMATEX; + +typedef struct { + WAVEFORMATEX Format; + union { + WORD wValidBitsPerSample; /* bits of precision */ + WORD wSamplesPerBlock; /* valid if wBitsPerSample==0 */ + WORD wReserved; /* If neither applies, set to zero. */ + } Samples; + DWORD dwChannelMask; /* which channels are */ + /* present in stream */ + GUID SubFormat; +} WAVEFORMATEXTENSIBLE, *PWAVEFORMATEXTENSIBLE; + +// um/AudioSessionTypes.h +typedef enum _AUDCLNT_SHAREMODE +{ + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_SHAREMODE_EXCLUSIVE +} AUDCLNT_SHAREMODE; + +// um/dsound.h +typedef const GUID *LPCGUID; + +// um/Audioclient.h +typedef LONGLONG REFERENCE_TIME; + +typedef struct IAudioClient IAudioClient; + +typedef struct IAudioClientVtbl { + HRESULT ( __stdcall *QueryInterface )(IAudioClient * This, REFIID riid, void **ppvObject); + ULONG ( __stdcall *AddRef )(IAudioClient * This); + ULONG ( __stdcall *Release )(IAudioClient * This); + HRESULT ( __stdcall *Initialize )(IAudioClient * This, AUDCLNT_SHAREMODE ShareMode, DWORD StreamFlags, REFERENCE_TIME hnsBufferDuration, REFERENCE_TIME hnsPeriodicity, const WAVEFORMATEXTENSIBLE *pFormat, LPCGUID AudioSessionGuid); + HRESULT ( __stdcall *GetBufferSize )(IAudioClient * This, UINT32 *pNumBufferFrames); + HRESULT ( __stdcall *GetStreamLatency )(IAudioClient * This, REFERENCE_TIME *phnsLatency); + HRESULT ( __stdcall *GetCurrentPadding )(IAudioClient * This, UINT32 *pNumPaddingFrames); + HRESULT ( __stdcall *IsFormatSupported )(IAudioClient * This, AUDCLNT_SHAREMODE ShareMode, const WAVEFORMATEXTENSIBLE *pFormat, WAVEFORMATEXTENSIBLE **ppClosestMatch); + HRESULT ( __stdcall *GetMixFormat )(IAudioClient * This, WAVEFORMATEXTENSIBLE **ppDeviceFormat); + HRESULT ( __stdcall *GetDevicePeriod )(IAudioClient * This, REFERENCE_TIME *phnsDefaultDevicePeriod, REFERENCE_TIME *phnsMinimumDevicePeriod); + HRESULT ( __stdcall *Start )(IAudioClient * This); + HRESULT ( __stdcall *Stop )(IAudioClient * This); + HRESULT ( __stdcall *Reset )(IAudioClient * This); + HRESULT ( __stdcall *SetEventHandle )(IAudioClient * This, HANDLE eventHandle); + HRESULT ( __stdcall *GetService )(IAudioClient * This, REFIID riid, void **ppv); +} IAudioClientVtbl; + +struct IAudioClient { + const struct IAudioClientVtbl *lpVtbl; +}; + +typedef struct IAudioRenderClient IAudioRenderClient; + +typedef struct IAudioRenderClientVtbl { + HRESULT ( __stdcall *QueryInterface )(IAudioRenderClient * This, REFIID riid, void **ppvObject); + ULONG ( __stdcall *AddRef )(IAudioRenderClient * This); + ULONG ( __stdcall *Release )(IAudioRenderClient * This); + HRESULT ( __stdcall *GetBuffer )(IAudioRenderClient * This, UINT32 NumFramesRequested, BYTE **ppData); + HRESULT ( __stdcall *ReleaseBuffer )(IAudioRenderClient * This, UINT32 NumFramesWritten, DWORD dwFlags); +} IAudioRenderClientVtbl; + +struct IAudioRenderClient { + const struct IAudioRenderClientVtbl *lpVtbl; +}; + +typedef enum _AUDCLNT_BUFFERFLAGS { + AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY = 0x1, + AUDCLNT_BUFFERFLAGS_SILENT = 0x2, + AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR = 0x4 +}; + +typedef struct IAudioCaptureClient IAudioCaptureClient; + +typedef struct IAudioCaptureClientVtbl { + HRESULT ( __stdcall *QueryInterface )(IAudioCaptureClient * This, REFIID riid, void **ppvObject); + ULONG ( __stdcall *AddRef )(IAudioCaptureClient * This); + ULONG ( __stdcall *Release )(IAudioCaptureClient * This); + HRESULT ( __stdcall *GetBuffer )(IAudioCaptureClient * This, BYTE **ppData, UINT32 *pNumFramesToRead, DWORD *pdwFlags, UINT64 *pu64DevicePosition, UINT64 *pu64QPCPosition); + HRESULT ( __stdcall *ReleaseBuffer )(IAudioCaptureClient * This, UINT32 NumFramesRead); + HRESULT ( __stdcall *GetNextPacketSize )(IAudioCaptureClient * This, UINT32 *pNumFramesInNextPacket); +} IAudioCaptureClientVtbl; + +struct IAudioCaptureClient { + const struct IAudioCaptureClientVtbl *lpVtbl; +}; diff --git a/LXST/Platforms/windows/soundcard.py b/LXST/Platforms/windows/soundcard.py new file mode 100644 index 0000000..7c7497a --- /dev/null +++ b/LXST/Platforms/windows/soundcard.py @@ -0,0 +1,641 @@ +# Adapted from Bastian Bechtold's soundcard library, originally released +# under the BSD 3-Clause License +# +# https://github.com/bastibe/SoundCard +# +# Copyright (c) 2016 Bastian Bechtold +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the +# distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Modifications and improvements Copyright 2025 Mark Qvist, and released +# under the same BSD 3-Clause License. + +import os +import cffi +import re +import time +import struct +import collections +import platform +import warnings +import threading +import numpy +import RNS + +_ffi = cffi.FFI() +_package_dir, _ = os.path.split(__file__) +with open(os.path.join(_package_dir, 'mediafoundation.h'), 'rt') as f: _ffi.cdef(f.read()) +try: _ole32 = _ffi.dlopen('ole32') +except OSError: + try: _ole32 = _ffi.dlopen('ole32.dll') + except: raise SystemError("LXST Could not load OLE32 DLL for WASAPI integration") + +def tid(): return threading.get_native_id() +com_thread_ids = [] +class _COMLibrary: + def __init__(self): + self._lock = threading.Lock() + self.init_com() + + def init_com(self): + with self._lock: + if tid() in com_thread_ids: return + else: + com_thread_ids.append(tid()) + COINIT_MULTITHREADED = 0x0 + RNS.log(f"COM init from thread {tid()}", RNS.LOG_EXTREME) + if platform.win32_ver()[0] == "8": raise OSError("Unsupported Windows version") + else: hr = _ole32.CoInitializeEx(_ffi.NULL, COINIT_MULTITHREADED) + + try: + self.check_error(hr) + self.com_loaded = True + except RuntimeError as e: + # Error 0x80010106 - COM already initialized + RPC_E_CHANGED_MODE = 0x80010106 + if hr + 2 ** 32 == RPC_E_CHANGED_MODE: self.com_loaded = False + else: raise e + + def release_com(self): + with self._lock: + if tid() in com_thread_ids: + com_thread_ids.remove(tid()) + RNS.log(f"COM release from thread {tid()}", RNS.LOG_EXTREME) + if _ole32 != None: _ole32.CoUninitialize() + else: RNS.log(f"OLE32 instance was None at de-init for thread {tid()}", RNS.LOG_DEBUG) + + def __del__(self): self.release_com() + + @staticmethod + def check_error(hresult): + S_OK = 0 + E_NOINTERFACE = 0x80004002 + E_POINTER = 0x80004003 + E_OUTOFMEMORY = 0x8007000e + E_INVALIDARG = 0x80070057 + CO_E_NOTINITIALIZED = 0x800401f0 + AUDCLNT_E_UNSUPPORTED_FORMAT = 0x88890008 + if hresult == S_OK: return + elif hresult+2**32 == E_NOINTERFACE: raise RuntimeError("The specified class does not implement the requested interface, or the controlling IUnknown does not expose the requested interface.") + elif hresult+2**32 == E_POINTER: raise RuntimeError("An argument is NULL") + elif hresult+2**32 == E_INVALIDARG: raise RuntimeError("Invalid argument") + elif hresult+2**32 == E_OUTOFMEMORY: raise RuntimeError("Out of memory") + elif hresult+2**32 == AUDCLNT_E_UNSUPPORTED_FORMAT: raise RuntimeError("Unsupported format") + elif hresult+2**32 == CO_E_NOTINITIALIZED: raise RuntimeError(f"Windows COM context not initialized in {tid()}") + else: raise RuntimeError("Error {}".format(hex(hresult+2**32))) + + @staticmethod + def release(ppObject): + if ppObject[0] != _ffi.NULL: + ppObject[0][0].lpVtbl.Release(ppObject[0]) + ppObject[0] = _ffi.NULL + +_com = _COMLibrary() + +def all_speakers(): + with _DeviceEnumerator() as enum: + return [_Speaker(dev) for dev in enum.all_devices('speaker')] + +def default_speaker(): + with _DeviceEnumerator() as enum: + return _Speaker(enum.default_device('speaker')) + +def get_speaker(id): + return _match_device(id, all_speakers()) + +def all_microphones(include_loopback=False): + with _DeviceEnumerator() as enum: + if include_loopback: + return [_Microphone(dev, isloopback=True) for dev in enum.all_devices('speaker')] + [_Microphone(dev) for dev in enum.all_devices('microphone')] + else: + return [_Microphone(dev) for dev in enum.all_devices('microphone')] + +def default_microphone(): + with _DeviceEnumerator() as enum: + return _Microphone(enum.default_device('microphone')) + +def get_microphone(id, include_loopback=False): + return _match_device(id, all_microphones(include_loopback)) + +def _match_device(id, devices): + devices_by_id = {device.id: device for device in devices} + devices_by_name = {device.name: device for device in devices} + if id in devices_by_id: return devices_by_id[id] + + # Try substring match: + for name, device in devices_by_name.items(): + if id in name: return device + + # Try fuzzy match: + pattern = '.*'.join(id) + for name, device in devices_by_name.items(): + if re.match(pattern, name): return device + + raise IndexError('No device with id {}'.format(id)) + +def _str2wstr(string): + return _ffi.new('int16_t[]', [ord(s) for s in string]+[0]) + +def _guidof(uuid_str): + IID = _ffi.new('LPIID') + uuid = _str2wstr(uuid_str) + hr = _ole32.IIDFromString(_ffi.cast("char*", uuid), IID) + _com.check_error(hr) + return IID + +def get_name(): raise NotImplementedError() +def set_name(name): raise NotImplementedError() + +class _DeviceEnumerator: + # See shared/WTypesbase.h and um/combaseapi.h: + def __init__(self): + _com.init_com() + self._ptr = _ffi.new('IMMDeviceEnumerator **') + IID_MMDeviceEnumerator = _guidof("{BCDE0395-E52F-467C-8E3D-C4579291692E}") + IID_IMMDeviceEnumerator = _guidof("{A95664D2-9614-4F35-A746-DE8DB63617E6}") + CLSCTX_ALL = 23 + hr = _ole32.CoCreateInstance(IID_MMDeviceEnumerator, _ffi.NULL, CLSCTX_ALL, IID_IMMDeviceEnumerator, _ffi.cast("void **", self._ptr)) + _com.check_error(hr) + + def __enter__(self): + _com.init_com() + return self + + def __exit__(self, exc_type, exc_value, traceback): _com.release(self._ptr) + def __del__(self): _com.release(self._ptr) + + def _device_id(self, device_ptr): + ppId = _ffi.new('LPWSTR *') + hr = device_ptr[0][0].lpVtbl.GetId(device_ptr[0], ppId) + _com.check_error(hr) + return _ffi.string(ppId[0]) + + def all_devices(self, kind): + if kind == 'speaker': data_flow = 0 # render + elif kind == 'microphone': data_flow = 1 # capture + else: raise TypeError('Invalid kind: {}'.format(kind)) + + DEVICE_STATE_ACTIVE = 0x1 + ppDevices = _ffi.new('IMMDeviceCollection **') + hr = self._ptr[0][0].lpVtbl.EnumAudioEndpoints(self._ptr[0], data_flow, DEVICE_STATE_ACTIVE, ppDevices); + _com.check_error(hr) + + for ppDevice in _DeviceCollection(ppDevices): + device = _Device(self._device_id(ppDevice)) + _com.release(ppDevice) + yield device + + def default_device(self, kind): + if kind == 'speaker': data_flow = 0 # render + elif kind == 'microphone': data_flow = 1 # capture + else: raise TypeError('Invalid kind: {}'.format(kind)) + + ppDevice = _ffi.new('IMMDevice **') + eConsole = 0 + hr = self._ptr[0][0].lpVtbl.GetDefaultAudioEndpoint(self._ptr[0], data_flow, eConsole, ppDevice); + _com.check_error(hr) + device = _Device(self._device_id(ppDevice)) + _com.release(ppDevice) + return device + + def device_ptr(self, devid): + ppDevice = _ffi.new('IMMDevice **') + devid = _str2wstr(devid) + hr = self._ptr[0][0].lpVtbl.GetDevice(self._ptr[0], _ffi.cast('wchar_t *', devid), ppDevice); + _com.check_error(hr) + return ppDevice + +class _DeviceCollection: + def __init__(self, ptr): + _com.init_com() + self._ptr = ptr + + def __del__(self): _com.release(self._ptr) + + def __len__(self): + pCount = _ffi.new('UINT *') + hr = self._ptr[0][0].lpVtbl.GetCount(self._ptr[0], pCount) + _com.check_error(hr) + return pCount[0] + + def __getitem__(self, idx): + if idx >= len(self): + raise StopIteration() + ppDevice = _ffi.new('IMMDevice **') + hr = self._ptr[0][0].lpVtbl.Item(self._ptr[0], idx, ppDevice) + _com.check_error(hr) + return ppDevice + +class _PropVariant: + def __init__(self): + _com.init_com() + self.ptr = _ole32.CoTaskMemAlloc(_ffi.sizeof('PROPVARIANT')) + self.ptr = _ffi.cast("PROPVARIANT *", self.ptr) + + def __del__(self): + hr = _ole32.PropVariantClear(self.ptr) + _com.check_error(hr) + +class _Device: + def __init__(self, id): + _com.init_com() + self._id = id + + def _device_ptr(self): + with _DeviceEnumerator() as enum: + return enum.device_ptr(self._id) + + @property + def id(self): return self._id + + @property + def name(self): + # um/coml2api.h: + ppPropertyStore = _ffi.new('IPropertyStore **') + ptr = self._device_ptr() + hr = ptr[0][0].lpVtbl.OpenPropertyStore(ptr[0], 0, ppPropertyStore) + _com.release(ptr) + _com.check_error(hr) + propvariant = _PropVariant() + # um/functiondiscoverykeys_devpkey.h and https://msdn.microsoft.com/en-us/library/windows/desktop/dd370812(v=vs.85).aspx + PKEY_Device_FriendlyName = _ffi.new("PROPERTYKEY *", + [[0xa45c254e, 0xdf1c, 0x4efd, [0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0]], + 14]) + hr = ppPropertyStore[0][0].lpVtbl.GetValue(ppPropertyStore[0], PKEY_Device_FriendlyName, propvariant.ptr) + _com.check_error(hr) + if propvariant.ptr[0].vt != 31: + raise RuntimeError('Property was expected to be a string, but is not a string') + data = _ffi.cast("short*", propvariant.ptr[0].data) + for idx in range(256): + if data[idx] == 0: break + devicename = struct.pack('h' * idx, *data[0:idx]).decode('utf-16') + _com.release(ppPropertyStore) + return devicename + + @property + def channels(self): + # um/coml2api.h: + ppPropertyStore = _ffi.new('IPropertyStore **') + ptr = self._device_ptr() + hr = ptr[0][0].lpVtbl.OpenPropertyStore(ptr[0], 0, ppPropertyStore) + _com.release(ptr) + _com.check_error(hr) + propvariant = _PropVariant() + # um/functiondiscoverykeys_devpkey.h and https://msdn.microsoft.com/en-us/library/windows/desktop/dd370812(v=vs.85).aspx + PKEY_AudioEngine_DeviceFormat = _ffi.new("PROPERTYKEY *", + [[0xf19f064d, 0x82c, 0x4e27, [0xbc, 0x73, 0x68, 0x82, 0xa1, 0xbb, 0x8e, 0x4c]], + 0]) + hr = ppPropertyStore[0][0].lpVtbl.GetValue(ppPropertyStore[0], PKEY_AudioEngine_DeviceFormat, propvariant.ptr) + _com.release(ppPropertyStore) + _com.check_error(hr) + if propvariant.ptr[0].vt != 65: + raise RuntimeError('Property was expected to be a blob, but is not a blob') + pPropVariantBlob = _ffi.cast("BLOB_PROPVARIANT *", propvariant.ptr) + assert pPropVariantBlob[0].blob.cbSize == 40 + waveformat = _ffi.cast("WAVEFORMATEX *", pPropVariantBlob[0].blob.pBlobData) + channels = waveformat[0].nChannels + return channels + + def _audio_client(self): + CLSCTX_ALL = 23 + ppAudioClient = _ffi.new("IAudioClient **") + IID_IAudioClient = _guidof("{1CB9AD4C-DBFA-4C32-B178-C2F568A703B2}") + ptr = self._device_ptr() + hr = ptr[0][0].lpVtbl.Activate(ptr[0], IID_IAudioClient, CLSCTX_ALL, _ffi.NULL, _ffi.cast("void**", ppAudioClient)) + _com.release(ptr) + _com.check_error(hr) + return ppAudioClient + +class _Speaker(_Device): + def __init__(self, device): self._id = device._id + + def __repr__(self): return ''.format(self.name,self.channels) + + def player(self, samplerate, channels=None, blocksize=None, exclusive_mode=False): + if channels is None: channels = self.channels + return _Player(self._audio_client(), samplerate, channels, blocksize, False, exclusive_mode) + + def play(self, data, samplerate, channels=None, blocksize=None): + with self.player(samplerate, channels, blocksize) as p: p.play(data) + + +class _Microphone(_Device): + def __init__(self, device, isloopback=False): + self._id = device._id + self.isloopback = isloopback + + def __repr__(self): + if self.isloopback: return ''.format(self.name,self.channels) + else: return ''.format(self.name,self.channels) + + def recorder(self, samplerate, channels=None, blocksize=None, exclusive_mode=False): + if channels is None: channels = self.channels + return _Recorder(self._audio_client(), samplerate, channels, blocksize, self.isloopback, exclusive_mode) + + def record(self, numframes, samplerate, channels=None, blocksize=None): + with self.recorder(samplerate, channels, blocksize) as r: return r.record(numframes) + +class _AudioClient: + def __init__(self, ptr, samplerate, channels, blocksize, isloopback, exclusive_mode=False): + self._ptr = ptr + + if isinstance(channels, int): self.channelmap = list(range(channels)) + elif isinstance(channels, collections.abc.Iterable): self.channelmap = channels + else: raise TypeError('Channels must be iterable or integer') + + if list(range(len(set(self.channelmap)))) != sorted(list(set(self.channelmap))): + raise TypeError('Due to limitations of WASAPI, channel maps on Windows must be a combination of `range(0, x)`.') + + if blocksize is None: blocksize = self.deviceperiod[0]*samplerate + + ppMixFormat = _ffi.new('WAVEFORMATEXTENSIBLE**') # See: https://docs.microsoft.com/en-us/windows/win32/api/mmreg/ns-mmreg-waveformatextensible + hr = self._ptr[0][0].lpVtbl.GetMixFormat(self._ptr[0], ppMixFormat) + _com.check_error(hr) + + # It's a WAVEFORMATEXTENSIBLE with room for KSDATAFORMAT_SUBTYPE_IEEE_FLOAT: + # Note: Some devices may not return 0xFFFE format, but WASAPI should handle conversion + if ppMixFormat[0][0].Format.wFormatTag == 0xFFFE: + assert ppMixFormat[0][0].Format.cbSize == 22 + + # The data format is float32: + # These values were found empirically, and I don't know why they work. + # The program crashes if these values are different + assert ppMixFormat[0][0].SubFormat.Data1 == 0x100000 + assert ppMixFormat[0][0].SubFormat.Data2 == 0x0080 + assert ppMixFormat[0][0].SubFormat.Data3 == 0xaa00 + assert [int(x) for x in ppMixFormat[0][0].SubFormat.Data4[0:4]] == [0, 56, 155, 113] + # the last four bytes seem to vary randomly + else: + # Device doesn't return WAVEFORMATEXTENSIBLE, but WASAPI will handle conversion + # Just skip the assertions and let WASAPI convert + pass + + channels = len(set(self.channelmap)) + channelmask = 0 + for ch in self.channelmap: channelmask |= 1< 0: + towrite = self._render_available_frames() + if towrite == 0: + time.sleep(0.001) + continue + + bytes = data[:towrite].ravel().tobytes() + buffer = self._render_buffer(towrite) + _ffi.memmove(buffer[0], bytes, len(bytes)) + self._render_release(towrite) + data = data[towrite:] + +class _Recorder(_AudioClient): + # https://msdn.microsoft.com/en-us/library/windows/desktop/dd370800(v=vs.85).aspx + def _capture_client(self): + iid = _guidof("{C8ADBD64-E71E-48a0-A4DE-185C395CD317}") + ppCaptureClient = _ffi.new("IAudioCaptureClient**") + hr = self._ptr[0][0].lpVtbl.GetService(self._ptr[0], iid, _ffi.cast("void**", ppCaptureClient)) + _com.check_error(hr) + return ppCaptureClient + + def _capture_buffer(self): + data = _ffi.new("BYTE**") + toread = _ffi.new('UINT32*') + flags = _ffi.new('DWORD*') + hr = self._ppCaptureClient[0][0].lpVtbl.GetBuffer(self._ppCaptureClient[0], data, toread, flags, _ffi.NULL, _ffi.NULL) + _com.check_error(hr) + return data[0], toread[0], flags[0] + + def _capture_release(self, numframes): + hr = self._ppCaptureClient[0][0].lpVtbl.ReleaseBuffer(self._ppCaptureClient[0], numframes) + _com.check_error(hr) + + def _capture_available_frames(self): + pSize = _ffi.new("UINT32*") + hr = self._ppCaptureClient[0][0].lpVtbl.GetNextPacketSize(self._ppCaptureClient[0], pSize) + _com.check_error(hr) + return pSize[0] + + def __enter__(self): + _com.init_com() + self._ppCaptureClient = self._capture_client() + hr = self._ptr[0][0].lpVtbl.Start(self._ptr[0]) + _com.check_error(hr) + self._pending_chunk = numpy.zeros([0], dtype='float32') + self._is_first_frame = True + return self + + def __exit__(self, exc_type, exc_value, traceback): + hr = self._ptr[0][0].lpVtbl.Stop(self._ptr[0]) + _com.check_error(hr) + _com.release(self._ppCaptureClient) + _com.release(self._ptr) + _com.release_com() + + def _record_chunk(self): + while self._capture_available_frames() == 0: + # Some sound cards indicate silence by not making any + # frames available. If that is the case, we need to + # estimate the number of zeros to return, by measuring the + # silent time: + if self._idle_start_time is None: self._idle_start_time = time.perf_counter_ns() + + default_block_length, minimum_block_length = self.deviceperiod + time.sleep(minimum_block_length/4) + elapsed_time_ns = time.perf_counter_ns() - self._idle_start_time + + # Waiting times shorter than a block length or so are + # normal, and not indicative of a silent sound card. If + # the waiting times get longer however, we must assume + # that there is no audio data forthcoming, and return + # zeros instead: + if elapsed_time_ns / 1_000_000_000 > default_block_length * 4: + num_frames = int(self.samplerate * elapsed_time_ns / 1_000_000_000) + num_channels = len(set(self.channelmap)) + self._idle_start_time += elapsed_time_ns + return numpy.zeros([num_frames * num_channels], dtype='float32') + + self._idle_start_time = None + data_ptr, nframes, flags = self._capture_buffer() + if data_ptr != _ffi.NULL: + # Convert the raw CFFI buffer into a standard bytes object to ensure compatibility + # with modern NumPy versions (fromstring binary mode was removed). Using frombuffer + # on bytes plus .copy() guarantees a writable float32 array for downstream processing. + buf = bytes(_ffi.buffer(data_ptr, nframes * 4 * len(set(self.channelmap)))) + chunk = numpy.frombuffer(buf, dtype=numpy.float32).copy() + else: raise RuntimeError('Could not create capture buffer') + + # See https://learn.microsoft.com/en-us/windows/win32/api/audioclient/ne-audioclient-_audclnt_bufferflags + if flags & _ole32.AUDCLNT_BUFFERFLAGS_SILENT: chunk[:] = 0 + if self._is_first_frame: + # On first run, clear data discontinuity error, as it will always be set: + flags &= ~_ole32.AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY + self._is_first_frame = False + if flags & _ole32.AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY: pass + + # Ignore _ole32.AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR, since we don't use time stamps. + if nframes > 0: + self._capture_release(nframes) + return chunk + else: + return numpy.zeros([0], dtype='float32') + + def record(self, numframes=None): + if numframes is None: + recorded_data = [self._pending_chunk, self._record_chunk()] + self._pending_chunk = numpy.zeros([0], dtype='float32') + + else: + recorded_frames = len(self._pending_chunk) + recorded_data = [self._pending_chunk] + self._pending_chunk = numpy.zeros([0], dtype='float32') + required_frames = numframes*len(set(self.channelmap)) + + while recorded_frames < required_frames: + chunk = self._record_chunk() + if len(chunk) == 0: + # No data forthcoming: return zeros + chunk = numpy.zeros(required_frames-recorded_frames, dtype='float32') + recorded_data.append(chunk) + recorded_frames += len(chunk) + + if recorded_frames > required_frames: + to_split = -int(recorded_frames-required_frames) + recorded_data[-1], self._pending_chunk = numpy.split(recorded_data[-1], [to_split]) + + data = numpy.reshape(numpy.concatenate(recorded_data), [-1, len(set(self.channelmap))]) + return data[:, self.channelmap] + + def flush(self): + last_chunk = numpy.reshape(self._pending_chunk, [-1, len(set(self.channelmap))]) + self._pending_chunk = numpy.zeros([0], dtype='float32') + return last_chunk diff --git a/LXST/Primitives/Telephony.py b/LXST/Primitives/Telephony.py index 4de9bd6..7a48c51 100644 --- a/LXST/Primitives/Telephony.py +++ b/LXST/Primitives/Telephony.py @@ -124,10 +124,16 @@ class Telephone(SignallingReceiver): ALLOW_NONE = 0xFE @staticmethod - def available_outputs(): return LXST.Sources.Backend().soundcard.all_speakers() + def available_outputs(): return LXST.Sinks.Backend().all_speakers() @staticmethod - def available_inputs(): return LXST.Sinks.Backend().soundcard.all_microphones() + def available_inputs(): return LXST.Sources.Backend().all_microphones() + + @staticmethod + def default_output(): return LXST.Sinks.Backend().default_speaker() + + @staticmethod + def default_input(): return LXST.Sources.Backend().default_microphone() def __init__(self, identity, ring_time=RING_TIME, wait_time=WAIT_TIME, auto_answer=None, allowed=ALLOW_ALL, receive_gain=0.0, transmit_gain=0.0): super().__init__() @@ -163,6 +169,7 @@ class Telephone(SignallingReceiver): self.dial_tone = None self.dial_tone_frequency = self.DIAL_TONE_FREQUENCY self.dial_tone_ease_ms = self.DIAL_TONE_EASE_MS + self.busy_tone_seconds = 4.25 self.transmit_codec = None self.receive_codec = None self.receive_mixer = None @@ -245,6 +252,9 @@ class Telephone(SignallingReceiver): self.ringtone_gain = gain RNS.log(f"{self} ringtone set to {self.ringtone_path}", RNS.LOG_DEBUG) + def set_busy_tone_time(self, seconds=4.25): + self.busy_tone_seconds = seconds + def enable_agc(self, enable=True): if enable == True: self.use_agc = True else: self.use_agc = False @@ -521,15 +531,16 @@ class Telephone(SignallingReceiver): threading.Thread(target=job, daemon=True).start() def __play_busy_tone(self): - if self.audio_output == None or self.receive_mixer == None or self.dial_tone == None: self.__reset_dialling_pipelines() - with self.pipeline_lock: - window = 0.5; started = time.time() - while time.time()-started < 4.25: - elapsed = (time.time()-started)%window - if elapsed > 0.25: self.__enable_dial_tone() - else: self.__mute_dial_tone() - time.sleep(0.005) - time.sleep(0.5) + if self.busy_tone_seconds > 0: + if self.audio_output == None or self.receive_mixer == None or self.dial_tone == None: self.__reset_dialling_pipelines() + with self.pipeline_lock: + window = 0.5; started = time.time() + while time.time()-started < self.busy_tone_seconds: + elapsed = (time.time()-started)%window + if elapsed > 0.25: self.__enable_dial_tone() + else: self.__mute_dial_tone() + time.sleep(0.005) + time.sleep(0.5) def __activate_dial_tone(self): def job(): diff --git a/LXST/Sinks.py b/LXST/Sinks.py index be7ef09..a110a66 100644 --- a/LXST/Sinks.py +++ b/LXST/Sinks.py @@ -8,7 +8,7 @@ class LinuxBackend(): SAMPLERATE = 48000 def __init__(self, preferred_device=None, samplerate=SAMPLERATE): - import soundcard + from .Platforms.linux import soundcard self.samplerate = samplerate self.soundcard = soundcard if preferred_device: @@ -17,7 +17,10 @@ class LinuxBackend(): else: self.device = soundcard.default_speaker() RNS.log(f"Using output device {self.device}", RNS.LOG_DEBUG) - def flush(self): self.recorder.flush() + def all_speakers(self): return self.soundcard.all_speakers() + def default_speaker(self): return self.soundcard.default_speaker() + + def flush(self): self.device.flush() def get_player(self, samples_per_frame=None, low_latency=None): return self.device.player(samplerate=self.samplerate, blocksize=samples_per_frame) @@ -37,7 +40,10 @@ class AndroidBackend(): else: self.device = soundcard.default_speaker() RNS.log(f"Using output device {self.device}", RNS.LOG_DEBUG) - def flush(self): self.recorder.flush() + def all_speakers(self): return self.soundcard.all_speakers() + def default_speaker(self): return self.soundcard.default_speaker() + + def flush(self): self.device.flush() def get_player(self, samples_per_frame=None, low_latency=None): return self.device.player(samplerate=self.samplerate, blocksize=samples_per_frame, low_latency=low_latency) @@ -48,7 +54,7 @@ class DarwinBackend(): SAMPLERATE = 48000 def __init__(self, preferred_device=None, samplerate=SAMPLERATE): - import soundcard + from .Platforms.darwin import soundcard self.samplerate = samplerate self.soundcard = soundcard if preferred_device: @@ -57,7 +63,10 @@ class DarwinBackend(): else: self.device = soundcard.default_speaker() RNS.log(f"Using output device {self.device}", RNS.LOG_DEBUG) - def flush(self): self.recorder.flush() + def all_speakers(self): return self.soundcard.all_speakers() + def default_speaker(self): return self.soundcard.default_speaker() + + def flush(self): self.device.flush() def get_player(self, samples_per_frame=None, low_latency=None): return self.device.player(samplerate=self.samplerate, blocksize=samples_per_frame) @@ -68,25 +77,24 @@ class WindowsBackend(): SAMPLERATE = 48000 def __init__(self, preferred_device=None, samplerate=SAMPLERATE): - import soundcard - from pythoncom import CoInitializeEx, CoUninitialize - self.com_init = CoInitializeEx - self.com_release = CoUninitialize - self.samplerate = samplerate - self.soundcard = soundcard + from .Platforms.windows import soundcard + self.samplerate = samplerate + self.soundcard = soundcard if preferred_device: try: self.device = self.soundcard.get_speaker(preferred_device) except: self.device = soundcard.default_speaker() else: self.device = soundcard.default_speaker() RNS.log(f"Using output device {self.device}", RNS.LOG_DEBUG) - def flush(self): self.recorder.flush() + def all_speakers(self): return self.soundcard.all_speakers() + def default_speaker(self): return self.soundcard.default_speaker() + + def flush(self): self.device.flush() def get_player(self, samples_per_frame=None, low_latency=None): - self.com_init(0) return self.device.player(samplerate=self.samplerate, blocksize=samples_per_frame) - def release_player(self): self.com_release() + def release_player(self): pass def get_backend(): if RNS.vendor.platformutils.is_linux(): return LinuxBackend diff --git a/LXST/Sources.py b/LXST/Sources.py index d10aaea..4da546b 100644 --- a/LXST/Sources.py +++ b/LXST/Sources.py @@ -13,7 +13,7 @@ class LinuxBackend(): SAMPLERATE = 48000 def __init__(self, preferred_device=None, samplerate=SAMPLERATE): - import soundcard + from .Platforms.linux import soundcard self.samplerate = samplerate self.soundcard = soundcard if preferred_device: @@ -24,7 +24,10 @@ class LinuxBackend(): self.bitdepth = 32 RNS.log(f"Using input device {self.device}", RNS.LOG_DEBUG) - def flush(self): self.recorder.flush() + def all_microphones(self): return self.soundcard.all_microphones() + def default_microphone(self): return self.soundcard.default_microphone() + + def flush(self): self.device.flush() def get_recorder(self, samples_per_frame): return self.device.recorder(samplerate=self.SAMPLERATE, blocksize=samples_per_frame) @@ -46,7 +49,10 @@ class AndroidBackend(): self.bitdepth = 32 RNS.log(f"Using input device {self.device}", RNS.LOG_DEBUG) - def flush(self): self.recorder.flush() + def all_microphones(self): return self.soundcard.all_microphones() + def default_microphone(self): return self.soundcard.default_microphone() + + def flush(self): self.device.flush() def get_recorder(self, samples_per_frame): return self.device.recorder(samplerate=self.SAMPLERATE, blocksize=samples_per_frame) @@ -57,7 +63,7 @@ class DarwinBackend(): SAMPLERATE = 48000 def __init__(self, preferred_device=None, samplerate=SAMPLERATE): - import soundcard + from .Platforms.darwin import soundcard self.samplerate = samplerate self.soundcard = soundcard if preferred_device: @@ -68,7 +74,10 @@ class DarwinBackend(): self.bitdepth = 32 RNS.log(f"Using input device {self.device}", RNS.LOG_DEBUG) - def flush(self): self.recorder.flush() + def all_microphones(self): return self.soundcard.all_microphones() + def default_microphone(self): return self.soundcard.default_microphone() + + def flush(self): self.device.flush() def get_recorder(self, samples_per_frame): return self.device.recorder(samplerate=self.SAMPLERATE, blocksize=samples_per_frame) @@ -79,12 +88,9 @@ class WindowsBackend(): SAMPLERATE = 48000 def __init__(self, preferred_device=None, samplerate=SAMPLERATE): - import soundcard - from pythoncom import CoInitializeEx, CoUninitialize - self.com_init = CoInitializeEx - self.com_release = CoUninitialize - self.samplerate = samplerate - self.soundcard = soundcard + from .Platforms.windows import soundcard + self.samplerate = samplerate + self.soundcard = soundcard if preferred_device: try: self.device = self.soundcard.get_microphone(preferred_device) except: self.device = self.soundcard.default_microphone() @@ -93,13 +99,15 @@ class WindowsBackend(): self.bitdepth = 32 RNS.log(f"Using input device {self.device}", RNS.LOG_DEBUG) - def flush(self): self.recorder.flush() + def all_microphones(self): return self.soundcard.all_microphones() + def default_microphone(self): return self.soundcard.default_microphone() + + def flush(self): self.device.flush() def get_recorder(self, samples_per_frame): - self.com_init(0) return self.device.recorder(samplerate=self.SAMPLERATE, blocksize=samples_per_frame) - def release_recorder(self): self.com_release() + def release_recorder(self): pass def get_backend(): if RNS.vendor.platformutils.is_linux(): return LinuxBackend diff --git a/LXST/_version.py b/LXST/_version.py index df12433..f6b7e26 100644 --- a/LXST/_version.py +++ b/LXST/_version.py @@ -1 +1 @@ -__version__ = "0.4.2" +__version__ = "0.4.3" diff --git a/Makefile b/Makefile index 39f74ad..7eeb1c7 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,14 @@ build_wheel: python3 setup.py sdist bdist_wheel rm ./skip_extensions -@(rm ./LXST/*.so) - -@(rm ./LXST/*.pyd) + -@(rm ./LXST/*.dll) + +windll: + cl /LD LXST/Filters.c LXST/Filters.def /Fefilterlib.dll + mv ./filterlib.dll ./lib/dev/ + rm ./filterlib.exp + rm ./filterlib.lib + rm ./filterlib.obj native_libs: ./march_build.sh @@ -32,7 +39,6 @@ native_libs: persist_libs: -cp ./lib/dev/*.so ./lib/static/ -cp ./lib/dev/*.dll ./lib/static/ - -cp ./lib/dev/*.dylib ./lib/static/ release: remove_symlinks build_wheel create_symlinks diff --git a/README.md b/README.md index 382c65b..7ec7d17 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ LXST uses encryption provided by [Reticulum](https://reticulum.network), and thu This software is in a very early alpha state, and will change rapidly with ongoing development. Consider no APIs stable. Consider everything explosive. Not all features are implemented. Nothing is documented. For a fully functional LXST program, take a look at [Sideband](https://github.com/markqvist/Sideband) or the included `rnphone` program, which provides telephony service over Reticulum. Everything else will currently be a voyage of your own making. -While under early development, the project is kept under a `CC BY-NC-ND 4.0` license. +While under early development, and unless otherwise noted, the project is kept under a `CC BY-NC-ND 4.0` license. ## Installation diff --git a/lib/static/filterlib.dll b/lib/static/filterlib.dll new file mode 100644 index 0000000..a4ceefd Binary files /dev/null and b/lib/static/filterlib.dll differ diff --git a/setup.py b/setup.py index 30882e4..9151057 100644 --- a/setup.py +++ b/setup.py @@ -6,6 +6,7 @@ import platform if os.path.isfile("./skip_extensions"): BUILD_EXTENSIONS = False else: BUILD_EXTENSIONS = True +if os.name == "nt": BUILD_EXTENSIONS = False if BUILD_EXTENSIONS: print(f"Building LXST with native extensions...") else: print(f"Building LXST without native extensions...") @@ -14,7 +15,6 @@ with open("README.md", "r") as fh: long_description = fh.read() exec(open("LXST/_version.py", "r").read()) c_sources = ["LXST/Filters.c"] -if os.name == "nt": c_sources.append("LXST/Platforms/windows.c") if BUILD_EXTENSIONS: extensions = [ Extension("LXST.filterlib", sources=c_sources, include_dirs=["LXST"], language="c"), ] else: extensions = [] @@ -35,7 +35,10 @@ package_data = { "Filters.h", "Filters.c", "filterlib*.so", - "filterlib*.pyd", + "filterlib*.dll", + "Platforms/linux/pulseaudio.h", + "Platforms/darwin/coreaudio.h", + "Platforms/windows/mediafoundation.h", ] } @@ -64,10 +67,9 @@ setuptools.setup( }, install_requires=["rns>=1.0.4", "lxmf>=0.9.3", - "soundcard>=0.4.5", "numpy>=2.3.4", "pycodec2>=4.1.0", "audioop-lts>=0.2.1;python_version>='3.13'", - "cffi>=1.17.1"], + "cffi>=2.0.0"], python_requires=">=3.11", )