Раздели аудиопотоки и привяжи AEC к фактическому общему выходу

This commit is contained in:
evgeny committed 2026-10-02 10:22:34 +03:00
1 parent d7fb0c83f9
commit bd082a26bf
30 files changed
+1839 -412

No files matched your search

+30 -5
View File
@@ -130,6 +130,7 @@ add_executable(vibechat
src/nodespage.cpp
src/soundsettingspage.cpp
src/sound_manager.cpp
src/voice_audio_io.cpp
src/audio_device.cpp
src/audiorecorder.cpp
src/voicemessageencoder.cpp
@@ -241,11 +242,35 @@ target_include_directories(test_radio_audio PRIVATE ${CMAKE_SOURCE_DIR}/../../sr
target_link_libraries(test_radio_audio PRIVATE utun_voice utun pthread)
add_test(NAME test_radio_audio COMMAND test_radio_audio)
add_executable(test_audio_event_queue tests/test_audio_event_queue.cpp)
target_link_libraries(test_audio_event_queue PRIVATE pthread)
add_test(NAME test_audio_event_queue COMMAND test_audio_event_queue)
add_executable(test_voice_audio_io tests/test_voice_audio_io.cpp src/voice_audio_io.cpp)
target_include_directories(test_voice_audio_io PRIVATE ${UTUN_INCLUDE_DIRS})
target_link_libraries(test_voice_audio_io PRIVATE utun pthread)
add_test(NAME test_voice_audio_io COMMAND test_voice_audio_io)
set_tests_properties(test_audio_event_queue test_voice_audio_io PROPERTIES TIMEOUT 15)
if(UNIX AND NOT APPLE)
add_executable(test_audio_timing tests/test_audio_timing.c transport/audio_diagnostics.cpp)
target_link_libraries(test_audio_timing PRIVATE utun pthread dl m)
target_compile_options(test_audio_timing PRIVATE -UNDEBUG)
add_test(NAME test_audio_timing COMMAND test_audio_timing)
add_executable(test_voice_tx tests/test_voice_tx.cpp)
target_include_directories(test_voice_tx PRIVATE ${UTUN_INCLUDE_DIRS})
target_link_libraries(test_voice_tx PRIVATE utun_voice utun pthread)
target_link_options(test_voice_tx PRIVATE "-Wl,--wrap=radio_talk_begin" "-Wl,--wrap=radio_talk_end"
"-Wl,--wrap=radio_talk_send" "-Wl,--wrap=call_send_media" "-Wl,--wrap=chat_setting_get_int"
"-Wl,--wrap=silero_vad_create_default" "-Wl,--wrap=silero_vad_destroy" "-Wl,--wrap=silero_vad_reset"
"-Wl,--wrap=silero_vad_process")
add_test(NAME test_voice_tx COMMAND test_voice_tx)
endif()
# smoke-тест видео-декодера (FFmpeg). Ручной запуск: test_video_engine <mp4>
add_executable(test_video_engine
tests/test_video_engine.cpp
src/videoplayer_engine.cpp
src/sound_manager.cpp
src/voice_audio_io.cpp
src/audio_device.cpp
transport/miniaudio_impl.c
transport/audio_diagnostics.cpp
@@ -257,22 +282,22 @@ if(FFMPEG_FOUND)
endif()
set_source_files_properties(transport/miniaudio_impl.c PROPERTIES LANGUAGE C)
if(WIN32)
target_link_libraries(test_video_engine PRIVATE ${QT_LIBS} ${QT_CORE} utun pthread ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})
target_link_libraries(test_video_engine PRIVATE ${QT_LIBS} ${QT_CORE} utun_voice utun pthread ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})
else()
target_link_libraries(test_video_engine PRIVATE ${QT_LIBS} ${QT_CORE} utun pthread dl ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})
target_link_libraries(test_video_engine PRIVATE ${QT_LIBS} ${QT_CORE} utun_voice utun pthread dl ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})
endif()
add_executable(test_ptt_key_chord tests/test_ptt_key_chord.cpp)
target_link_libraries(test_ptt_key_chord PRIVATE ${QT_CORE})
add_test(NAME test_ptt_key_chord COMMAND test_ptt_key_chord)
add_executable(test_voice_file tests/test_voice_file.cpp src/voiceplayback.cpp src/sound_manager.cpp src/audio_device.cpp transport/miniaudio_impl.c transport/audio_diagnostics.cpp)
add_executable(test_voice_file tests/test_voice_file.cpp src/voiceplayback.cpp src/sound_manager.cpp src/voice_audio_io.cpp src/audio_device.cpp transport/miniaudio_impl.c transport/audio_diagnostics.cpp)
target_include_directories(test_voice_file PRIVATE ${UTUN_INCLUDE_DIRS})
target_link_libraries(test_voice_file PRIVATE ${QT_LIBS} utun pthread ${CMAKE_DL_LIBS})
target_link_libraries(test_voice_file PRIVATE ${QT_LIBS} utun_voice utun pthread ${CMAKE_DL_LIBS})
add_test(NAME test_voice_file COMMAND test_voice_file)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
add_executable(test_audio_recovery tests/test_audio_recovery.cpp src/audio_device.cpp src/sound_manager.cpp
add_executable(test_audio_recovery tests/test_audio_recovery.cpp src/audio_device.cpp src/sound_manager.cpp src/voice_audio_io.cpp
src/audiorecorder.cpp src/call_audio_engine.cpp src/radio_audio_engine.cpp
transport/miniaudio_impl.c transport/audio_diagnostics.cpp)
target_include_directories(test_audio_recovery PRIVATE ${UTUN_INCLUDE_DIRS})
+35
View File
@@ -3,6 +3,11 @@
#include "debug_config.h"
#include <algorithm>
#include <utility>
#include <chrono>
extern "C" int64_t audio_stream_frame_time(ma_device* device, unsigned int frames, int* valid);
extern "C" uint64_t audio_stream_route(ma_device* device, int capture);
extern "C" int audio_stream_capture_missing(ma_device* device);
extern "C" void audio_stream_log_route(ma_device* device, int capture);
AudioDevice::AudioDevice() { m_clock.start(); }
AudioDevice::~AudioDevice() { close(); }
@@ -14,6 +19,7 @@ bool AudioDevice::init(ma_context* context, ma_device_config config, const char*
if (!context) { retryLater(); return false; }
auto* device = new ma_device{};
config.dataCallback = data;
config.noFixedSizedCallback = MA_TRUE; // Аппаратные чанки не требуют скрытого накопителя miniaudio.
config.notificationCallback = notification;
config.pUserData = this;
ma_result result = ma_device_init(context, &config, device);
@@ -25,6 +31,8 @@ bool AudioDevice::init(ma_context* context, ma_device_config config, const char*
}
m_device = device;
m_frames = 0;
m_route = 0; m_reportedRoute = 0;
m_timingMeasured = false; m_reportedTiming = -1;
m_seenFrames = 0;
m_suspended = false;
audio_diag_label(device, session, role);
@@ -56,10 +64,30 @@ void AudioDevice::close() {
DEBUG_INFO(DEBUG_CATEGORY_CALL, "audio: %s closed", m_role);
}
m_callback = {};
m_route = 0;
}
void AudioDevice::pause() {
if (!m_device) return;
ma_result result = ma_device_stop(m_device);
if (result != MA_SUCCESS) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio: %s pause failed result=%d", m_role, result);
close();
}
}
void AudioDevice::data(ma_device* device, void* out, const void* in, ma_uint32 frames) {
auto* self = static_cast<AudioDevice*>(device->pUserData);
int valid = 0;
bool capture = device->type == ma_device_type_capture;
int64_t time = audio_stream_frame_time(device, frames, &valid);
int64_t now = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
if (!time) time = capture ? now - (int64_t)frames * 1000000 / device->sampleRate : now;
self->m_callbackTime.store(time, std::memory_order_relaxed);
self->m_timingMeasured.store(valid, std::memory_order_relaxed);
uint64_t route = audio_stream_route(device, capture);
if (route != self->m_route.exchange(route, std::memory_order_relaxed)) audio_stream_log_route(device, capture);
self->m_captureMissing.store(audio_stream_capture_missing(device), std::memory_order_relaxed);
if (self->m_callback) self->m_callback(in, out, frames);
self->m_frames.fetch_add(frames, std::memory_order_relaxed);
}
@@ -73,6 +101,13 @@ void AudioDevice::notification(const ma_device_notification* event) {
bool AudioDevice::needsRecovery() {
if (!m_device) return false;
uint64_t frames = m_frames.load(std::memory_order_relaxed);
uint64_t route = m_route.load();
int measured = m_timingMeasured.load();
if (frames && (route != m_reportedRoute || measured != m_reportedTiming)) {
DEBUG_INFO(DEBUG_CATEGORY_AEC, "audio: %s timing=%s physical_route=%016llx", m_role,
measured ? "backend-snapshot" : "callback-estimate", (unsigned long long)route);
m_reportedRoute = route; m_reportedTiming = measured;
}
qint64 now = m_clock.elapsed();
if (frames != m_seenFrames || m_suspended.load()) {
m_seenFrames = frames;
+11
View File
@@ -16,6 +16,7 @@ public:
AudioDevice& operator=(const AudioDevice&) = delete;
bool init(ma_context* context, ma_device_config config, const char* role, uint64_t session, Callback callback);
bool start();
void pause(); // GUI: завершает текущий callback, сохраняя backend stream и его физический route.
void close();
bool needsRecovery();
bool retryReady() const;
@@ -23,6 +24,10 @@ public:
void retryNow();
bool initialized() const { return m_device != nullptr; }
ma_device* device() const { return m_device; }
int64_t callbackTimeUs() const { return m_callbackTime.load(std::memory_order_relaxed); }
bool timingMeasured() const { return m_timingMeasured.load(std::memory_order_relaxed); }
uint64_t route() const { return m_route.load(std::memory_order_relaxed); }
bool captureMissing() const { return m_captureMissing.load(std::memory_order_relaxed); }
private:
static void data(ma_device* device, void* out, const void* in, ma_uint32 frames);
static void notification(const ma_device_notification* event);
@@ -31,6 +36,12 @@ private:
const char* m_role = "audio";
QElapsedTimer m_clock;
std::atomic<uint64_t> m_frames{0};
std::atomic<int64_t> m_callbackTime{0};
std::atomic<bool> m_timingMeasured{false};
std::atomic<uint64_t> m_route{0};
std::atomic<bool> m_captureMissing{false};
uint64_t m_reportedRoute = 0;
int m_reportedTiming = -1;
std::atomic<bool> m_suspended{false};
uint64_t m_seenFrames = 0;
qint64 m_progressAt = 0, m_retryAt = 0;
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <array>
#include <atomic>
#include <cstddef>
#include <cstdint>
/* Ограниченная очередь с несколькими producers. Producer не меняет позицию consumer.
* Резервирование и публикация разделены sequence каждого слота; callback не ждёт занятый слот.
* reset допустим только до запуска всех участников. Последние 8 слотов зарезервированы для команд. */
template<class T, size_t Capacity> class AudioEventQueue {
static_assert(Capacity > 8);
struct Slot { std::atomic<uint64_t> sequence{0}; T value{}; };
std::array<Slot, Capacity> m_slots;
alignas(64) std::atomic<uint64_t> m_write{0};
alignas(64) std::atomic<uint64_t> m_read{0};
public:
AudioEventQueue() { reset(); }
void reset() {
m_write = 0; m_read = 0;
for (size_t i = 0; i < Capacity; ++i) m_slots[i].sequence = i;
}
bool push(const T& value, bool control = false) {
uint64_t position = m_write.load(std::memory_order_relaxed);
for (int attempt = 0; attempt < 8; ++attempt) {
uint64_t read = m_read.load(std::memory_order_acquire);
if (read > position) { position = m_write.load(std::memory_order_relaxed); continue; }
if (position - read >= Capacity - (control ? 0 : 8)) return false;
auto& slot = m_slots[position % Capacity];
int64_t difference = (int64_t)(slot.sequence.load(std::memory_order_acquire) - position);
if (difference < 0) return false;
if (difference == 0 && m_write.compare_exchange_weak(position, position + 1, std::memory_order_relaxed)) {
slot.value = value;
slot.sequence.store(position + 1, std::memory_order_release);
return true;
}
position = m_write.load(std::memory_order_relaxed);
}
return false;
}
bool pop(T& value) {
uint64_t position = m_read.load(std::memory_order_relaxed);
auto& slot = m_slots[position % Capacity];
if (slot.sequence.load(std::memory_order_acquire) != position + 1) return false;
value = slot.value;
slot.sequence.store(position + Capacity, std::memory_order_release);
m_read.store(position + 1, std::memory_order_release);
return true;
}
size_t size() const {
uint64_t read = m_read.load(std::memory_order_acquire);
uint64_t count = m_write.load(std::memory_order_acquire) - read;
return (size_t)(count > Capacity ? Capacity : count); // Приблизительный снимок, включая ещё не опубликованные слоты.
}
};
@@ -363,7 +363,7 @@ void AudioDeviceSettingsPage::onTestMicToggled() {
m_testRecorder->init();
QString id = m_inputCombo->currentData().toString();
m_testRecorder->setCaptureDevice(id);
m_testRecorder->startRecording();
m_testRecorder->startRecording(true);
m_micTestActive = true;
m_levelTimer->start();
m_testMicBtn->setText("Stop Test");
+60 -24
View File
@@ -12,29 +12,50 @@ extern "C" {
#include <cstring>
#include <cstdlib>
#include <cmath>
#include <algorithm>
#include <chrono>
#include <exception>
#include <system_error>
void AudioRecorder::captureCallback(const void* pInput, unsigned int frameCount) {
if (!pInput || !frameCount || !m_recording.load()) return;
const int16_t* src = (const int16_t*)pInput;
size_t add = (size_t)frameCount * (size_t)m_channels;
if (m_compressor && m_captureCompressorEnabled) {
audio_compressor_push(m_compressor, src, add);
} else {
size_t cur = m_pcmBuffer.size();
m_pcmBuffer.resize(cur + add);
std::memcpy(m_pcmBuffer.data() + cur, src, add * sizeof(int16_t));
/* Аппаратный callback не выделяет память и не обрабатывает компрессор. */
void AudioRecorder::captureCallback(const void* input, unsigned frames) {
if (!input || !m_recording.load() || m_captureFailed.load()) return;
auto* pcm = static_cast<const int16_t*>(input);
for (unsigned offset = 0; offset < frames;) {
CaptureBlock block{}; block.frames = std::min(frames - offset, 960u);
memcpy(block.pcm, pcm + offset * m_channels, block.frames * m_channels * sizeof(int16_t));
if (!m_captureQueue.push(block)) { m_captureFailed = true; break; }
offset += block.frames;
}
m_wake.notify_one();
}
float sumSq = 0.0f;
for (unsigned int i = 0; i < frameCount * (unsigned int)m_channels; i++) {
float v = (float)src[i] / 32768.0f;
sumSq += v * v;
/* Только worker изменяет PCM и compressor; GUI получает атомарные метрики. */
void AudioRecorder::recordLoop() {
try {
while (m_workerRunning || m_captureQueue.size()) {
CaptureBlock block{};
if (!m_captureQueue.pop(block)) {
std::unique_lock lock(m_waitMutex);
m_wake.wait_for(lock, std::chrono::milliseconds(5));
continue;
}
if (m_captureFailed) continue;
size_t count = (size_t)block.frames * m_channels;
if (!m_meterOnly) {
if (m_compressor && m_captureCompressorEnabled) {
if (audio_compressor_push(m_compressor, block.pcm, count) != 0) { m_captureFailed = true; continue; }
} else m_pcmBuffer.insert(m_pcmBuffer.end(), block.pcm, block.pcm + count);
}
double sum = 0;
for (size_t i = 0; i < count; ++i) { double value = block.pcm[i] / 32768.0; sum += value * value; }
m_peakLevel = (float)std::sqrt(sum / count);
m_recordedFrames.fetch_add(block.frames, std::memory_order_relaxed);
}
} catch (const std::exception& error) {
m_captureFailed = true;
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "recorder: worker failed reason=%s", error.what());
}
float rms = sqrtf(sumSq / (float)(frameCount * m_channels));
m_peakLevel = rms;
m_recordedFrames.fetch_add(frameCount, std::memory_order_relaxed);
}
AudioRecorder::AudioRecorder(QObject* parent)
@@ -90,17 +111,26 @@ void AudioRecorder::shutdown() {
m_initialized = false;
}
void AudioRecorder::startRecording() {
void AudioRecorder::startRecording(bool meterOnly) {
if (m_recording) return;
if (!m_initialized) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "recorder: not initialized"); return; }
m_pcmBuffer.clear();
m_pcmBuffer.clear(); m_meterOnly = meterOnly;
m_captureQueue.reset(); m_captureFailed = false;
m_recordedFrames = 0; m_elapsedMs = 0; m_peakLevel = 0;
m_captureCompressorEnabled = m_compressorEnabled;
m_captureCompressorEnabled = !meterOnly && m_compressorEnabled;
if (m_compressor) {
audio_compressor_configure(m_compressor, &m_compressorConfig);
if (audio_compressor_configure(m_compressor, &m_compressorConfig) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "recorder: cannot configure compressor"); return;
}
audio_compressor_set_enabled(m_compressor, m_captureCompressorEnabled);
audio_compressor_reset(m_compressor);
}
m_workerRunning = true;
try { m_recordWorker = std::thread(&AudioRecorder::recordLoop, this); }
catch (const std::system_error& error) {
m_workerRunning = false;
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "recorder: worker start failed reason=%s", error.what()); return;
}
m_recording = true;
m_capture.retryNow();
recoverCapture();
@@ -111,6 +141,10 @@ void AudioRecorder::startRecording() {
void AudioRecorder::recoverCapture() {
if (!m_recording) return;
if (m_captureFailed) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "recorder: capture loss or processing failure; recording rejected");
stopRecording(); return;
}
if (m_capture.needsRecovery()) { m_capture.close(); m_durationTimer.stop(); m_capture.retryLater(); }
if (!m_capture.retryReady()) return;
ma_context* context = SoundManager::instance()->context();
@@ -137,6 +171,8 @@ void AudioRecorder::stopRecording() {
if (!m_recording.exchange(false)) return;
m_recoveryTimer.stop(); m_durationTimer.stop();
m_capture.close();
m_workerRunning = false; m_wake.notify_one();
if (m_recordWorker.joinable()) m_recordWorker.join();
m_elapsedMs = (int)(m_recordedFrames.load() * 1000 / m_sampleRate);
int duration = m_elapsedMs;
size_t samples = m_compressor && m_captureCompressorEnabled ? audio_compressor_output_size(m_compressor) : m_pcmBuffer.size();
@@ -151,7 +187,7 @@ int AudioRecorder::durationMs() const {
/* Worker получает владельца буфера; новая запись имеет отдельное состояние. */
bool AudioRecorder::takeRecording(struct attachment_send_req* request) {
if (!request || m_recording) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "recorder: cannot detach active recording"); return false; }
if (!request || m_recording || m_meterOnly || m_captureFailed) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "recorder: cannot detach active recording"); return false; }
if (m_compressor && m_captureCompressorEnabled) {
auto* replacement = audio_compressor_clone_config(m_compressor);
if (!replacement) return false;
+13 -1
View File
@@ -6,6 +6,10 @@
#include <vector>
#include <cstdint>
#include "audio_device.h"
#include "audio_event_queue.h"
#include <thread>
#include <mutex>
#include <condition_variable>
extern "C" {
#include "audio_compressor.h"
}
@@ -26,7 +30,7 @@ public:
void setCaptureDevice(const QString& id) { m_captureId = id; m_customDevice = true; }
void startRecording();
void startRecording(bool meterOnly = false);
void stopRecording();
bool isRecording() const { return m_recording; }
@@ -49,6 +53,14 @@ private:
QTimer m_recoveryTimer;
void recoverCapture();
void captureCallback(const void* input, unsigned int frames);
void recordLoop();
struct CaptureBlock { unsigned frames = 0; int16_t pcm[1920]{}; };
AudioEventQueue<CaptureBlock, 32> m_captureQueue;
std::thread m_recordWorker;
std::mutex m_waitMutex;
std::condition_variable m_wake;
std::atomic<bool> m_workerRunning{false}, m_captureFailed{false};
bool m_meterOnly = false;
bool m_initialized = false;
std::atomic<bool> m_recording{false};
int m_sampleRate = 48000;
+39 -64
View File
@@ -4,6 +4,7 @@
#include "../transport/audio_diagnostics.h"
extern "C" {
#include "call/call_audio.h"
#include "chat/chat_setting.h"
#include "debug_config.h"
}
#include <cstring>
@@ -24,7 +25,7 @@ CallAudioEngine::CallAudioEngine() {
connect(&m_recoveryTimer, &QTimer::timeout, this, &CallAudioEngine::recoverDevices);
connect(SoundManager::instance(), &SoundManager::contextAboutToReset, this, &CallAudioEngine::pauseDevices);
connect(SoundManager::instance(), &SoundManager::contextRestored, this, [this]() {
m_capture.retryNow(); m_playback.retryNow();
m_capture.retryNow();
if (m_active) recoverDevices();
});
}
@@ -32,100 +33,90 @@ CallAudioEngine::CallAudioEngine() {
int CallAudioEngine::start(quint64 callId) {
if (m_active && m_callId == callId && !m_ending) return 0;
stop();
if (!gui_bridge_get_inst() || call_audio_start(callId) != 0) {
if (!gui_bridge_get_inst() || call_audio_start_prepared(callId) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "CallAudio: cannot start call=%016llx", (unsigned long long)callId);
return -1;
}
++m_generation;
m_callId = callId;
m_captureBuf.assign(CALL_AUDIO_FRAME_SAMPLES, 0);
m_capturePos = 0;
if (!m_voice.start(VoiceAudioIo::Kind::Call, callId)) { call_audio_release(callId); m_callId = 0; return -1; }
m_muted = false;
m_ending = false;
m_active = true;
m_capture.retryNow(); m_playback.retryNow();
m_capture.retryNow();
recoverDevices();
m_recoveryTimer.start();
DEBUG_INFO(DEBUG_CATEGORY_CALL, "CallAudio: session started call=%016llx", (unsigned long long)callId);
return 0;
}
bool CallAudioEngine::openDevice(bool playback) {
AudioDevice& device = playback ? m_playback : m_capture;
ma_context* context = SoundManager::instance()->context();
bool CallAudioEngine::openCapture() {
auto* context = SoundManager::instance()->context();
if (!context) return false;
ma_device_config config = ma_device_config_init(playback ? ma_device_type_playback : ma_device_type_capture);
config.capture.format = config.playback.format = ma_format_s16;
config.capture.channels = config.playback.channels = 1;
config.sampleRate = CALL_AUDIO_SAMPLE_RATE;
ma_device_config config = ma_device_config_init(ma_device_type_capture);
config.capture.format = ma_format_s16; config.capture.channels = 1; config.sampleRate = CALL_AUDIO_SAMPLE_RATE;
ma_device_id id;
bool selected = SoundManager::deviceId(playback ? m_playbackId : m_captureId, &id);
if (selected) {
if (playback) config.playback.pDeviceID = &id;
else config.capture.pDeviceID = &id;
}
auto callback = [this](const void* in, void* out, unsigned int frames) { dataCallback(in, out, frames); };
const char* role = playback ? "call-playback" : "call-capture";
bool selected = SoundManager::deviceId(m_captureId, &id);
if (selected) config.capture.pDeviceID = &id;
auto callback = [this](const void* in, void*, unsigned frames) { dataCallback(in, nullptr, frames); };
auto attempt = [&]() {
if (!playback) m_capturePos = 0;
bool ready = device.init(context, config, role, m_callId.load(), callback) && device.start();
if (!ready) call_audio_reset_io(m_callId.load(), playback);
return ready;
if (!m_capture.init(context, config, "call-capture", m_callId.load(), callback)) return false;
bool aec = chat_setting_get_int(gui_bridge_get_inst(), "aec_enabled", 1) != 0;
if (!m_voice.startCapture(1, aec)) { m_capture.close(); return false; }
m_voice.setMuted(m_muted.load());
if (m_capture.start()) return true;
m_voice.stopCapture(); call_audio_reset_io(m_callId.load(), 0);
return false;
};
if (attempt()) return true;
if (!selected) return false;
DEBUG_WARN(DEBUG_CATEGORY_CALL, "CallAudio: %s selected device unavailable, trying default", role);
config.capture.pDeviceID = config.playback.pDeviceID = nullptr;
DEBUG_WARN(DEBUG_CATEGORY_CALL, "CallAudio: selected input unavailable, trying default");
config.capture.pDeviceID = nullptr;
return attempt();
}
void CallAudioEngine::recoverDevices() {
if (!m_active) return;
if (m_playback.needsRecovery()) {
m_playback.close();
call_audio_reset_io(m_callId.load(), 1);
m_playback.retryLater();
auto* manager = SoundManager::instance();
if (!m_outputAttached && manager->context()) m_outputAttached = manager->attachVoice(&m_voice, m_playbackId);
if (!m_ending && ((m_capture.initialized() && !m_voice.captureRunning()) || m_capture.needsRecovery())) {
m_capture.close(); m_voice.stopCapture();
call_audio_reset_io(m_callId.load(), 0); m_capture.retryLater();
}
if (!m_ending && m_capture.needsRecovery()) {
m_capture.close();
m_capturePos = 0;
call_audio_reset_io(m_callId.load(), 0);
m_capture.retryLater();
}
if (m_playback.retryReady()) openDevice(true);
if (!m_ending && m_capture.retryReady()) openDevice(false);
if (!m_ending && m_capture.retryReady()) openCapture();
if (!m_ending) m_voice.setAecEnabled(chat_setting_get_int(gui_bridge_get_inst(), "aec_enabled", 1) != 0);
}
void CallAudioEngine::pauseDevices() {
m_capture.close(); m_playback.close();
m_capturePos = 0;
if (m_active) call_audio_reset_io(m_callId.load(), 1);
m_capture.close(); m_voice.stopCapture();
SoundManager::instance()->detachVoice(&m_voice); m_outputAttached = false;
m_voice.resetPlayback();
}
void CallAudioEngine::setMuted(bool muted) {
if (m_muted.exchange(muted) == muted) return;
m_voice.setMuted(muted);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "CallAudio: mute=%d", muted);
}
void CallAudioEngine::setPlaybackDevice(const QString& id) {
if (id == m_playbackId) return;
m_playbackId = id;
m_playback.close(); m_playback.retryNow();
if (m_active) { call_audio_reset_io(m_callId.load(), 1); recoverDevices(); }
SoundManager::instance()->detachVoice(&m_voice); m_outputAttached = false;
if (m_active) recoverDevices();
}
void CallAudioEngine::setCaptureDevice(const QString& id) {
if (id == m_captureId) return;
m_captureId = id;
m_capture.close(); m_capture.retryNow();
m_capturePos = 0;
m_capture.close(); m_voice.stopCapture(); m_capture.retryNow();
if (m_active) { call_audio_reset_io(m_callId.load(), 0); recoverDevices(); }
}
void CallAudioEngine::beginEnd(quint64 callId) {
if (!m_active || m_callId != callId || m_ending) return;
m_ending = true;
m_capture.close();
m_capture.close(); m_voice.stopCapture();
call_audio_begin_end(callId);
uint64_t generation = m_generation;
QTimer::singleShot(500, this, [this, generation]() { if (m_generation == generation) stop(); });
@@ -135,7 +126,7 @@ void CallAudioEngine::stop() {
++m_generation;
m_recoveryTimer.stop();
m_active = false;
pauseDevices();
pauseDevices(); m_voice.stop();
uint64_t id = m_callId.exchange(0);
if (id) call_audio_release(id);
m_muted = false;
@@ -143,22 +134,6 @@ void CallAudioEngine::stop() {
if (id) DEBUG_INFO(DEBUG_CATEGORY_CALL, "CallAudio: session stopped call=%016llx", (unsigned long long)id);
}
void CallAudioEngine::dataCallback(const void* in, void* out, unsigned int frames) {
if (in) {
const auto* pcm = static_cast<const int16_t*>(in);
bool muted = m_muted.load();
for (unsigned int i = 0; i < frames; ++i) {
m_captureBuf[m_capturePos++] = pcm[i];
if (m_capturePos == CALL_AUDIO_FRAME_SAMPLES) {
int result = call_audio_feed_pcm_muted(m_callId.load(), m_captureBuf.data(), CALL_AUDIO_FRAME_SAMPLES, muted);
if (result != 0) audio_diag_event(m_capture.device(), AUDIO_DIAG_ERROR, AUDIO_DIAG_FEED, 1, result);
m_capturePos = 0;
}
}
}
if (out) {
auto* pcm = static_cast<int16_t*>(out);
int n = call_audio_pull_pcm(m_callId.load(), pcm, (int)frames);
if (n < (int)frames) std::memset(pcm + n, 0, (frames - n) * sizeof(*pcm));
}
void CallAudioEngine::dataCallback(const void* in, void*, unsigned int frames) {
if (in) m_voice.capture(static_cast<const int16_t*>(in), frames, m_capture.callbackTimeUs(), m_capture.route(), m_capture.captureMissing());
}
+12 -12
View File
@@ -6,16 +6,16 @@
#include <vector>
#include <QTimer>
#include "audio_device.h"
#include "voice_audio_io.h"
struct ma_device;
/* Аудио-контур звонка (desktop): только miniaudio I/O + захват/воспроизведение.
* Весь аудиопроцессинг (Opus, джиттер-буфер, time-stretch, тоны) — в сервисном
* слое src/call/call_audio.c; сюда приходит/отсюда уходит финальный PCM.
*
* Потоки:
* - независимые callbacks capture → call_audio_feed_pcm и call_audio_pull_pcm → playback;
* - GUI-поток: start()/beginEnd()/stop() по ACCEPTED/ENDED. */
/* Desktop-контур звонка: GUI владеет capture device и жизненным циклом.
* Hardware callback копирует PCM в ограниченную очередь VoiceAudioIo.
* Capture worker выполняет AEC и упорядочивает PCM/PTT/mute; независимый RX worker
* получает готовый голос из сервисного слоя. SoundManager смешивает его со всеми
* звуками приложения на том же выходе и возвращает итоговый stereo reference.
* Stop: close capture → join capture worker → detach output → join RX → release core. */
class CallAudioEngine : public QObject {
Q_OBJECT
public:
@@ -42,12 +42,12 @@ public:
QString playbackDevice() const { return m_playbackId; }
void setCaptureDevice(const QString& id);
/* аудио-поток: miniaudio data callback (захват + воспроизведение) */
/* Hardware capture callback: без DSP и растущих буферов. */
void dataCallback(const void* in, void* out, unsigned int frames);
private:
CallAudioEngine();
bool openDevice(bool playback);
bool openCapture();
void recoverDevices();
void pauseDevices();
@@ -58,11 +58,11 @@ private:
QString m_playbackId; /* только GUI-поток */
QString m_captureId;
AudioDevice m_capture, m_playback;
AudioDevice m_capture;
VoiceAudioIo m_voice;
bool m_outputAttached = false;
QTimer m_recoveryTimer;
uint64_t m_generation = 0;
bool m_ending = false;
std::vector<int16_t> m_captureBuf; /* 960 samples, аудио-поток */
int m_capturePos = 0;
};
+23 -72
View File
@@ -26,11 +26,11 @@ RadioAudioEngine::RadioAudioEngine() {
auto* manager = SoundManager::instance();
if (m_captureId != manager->captureDevice()) {
m_captureId = manager->captureDevice();
m_capture.close(); if (m_active) radio_audio_capture_stop(); m_capture.retryNow();
m_capture.close(); m_voice.stopCapture(); if (m_active) radio_audio_capture_stop(); m_capture.retryNow();
}
if (m_playbackId != manager->playbackDevice()) {
m_playbackId = manager->playbackDevice();
m_playback.close(); if (m_active) radio_audio_reset_playback(m_groupId.load()); m_playback.retryNow();
manager->detachVoice(&m_voice); m_outputAttached = false;
}
recoverDevices();
});
@@ -38,7 +38,7 @@ RadioAudioEngine::RadioAudioEngine() {
connect(&m_recoveryTimer, &QTimer::timeout, this, &RadioAudioEngine::recoverDevices);
connect(SoundManager::instance(), &SoundManager::contextAboutToReset, this, &RadioAudioEngine::pauseDevices);
connect(SoundManager::instance(), &SoundManager::contextRestored, this, [this]() {
m_capture.retryNow(); m_playback.retryNow();
m_capture.retryNow();
recoverDevices();
});
}
@@ -52,9 +52,10 @@ int RadioAudioEngine::start(quint64 groupId) {
return -1;
}
m_groupId = groupId;
if (!m_voice.start(VoiceAudioIo::Kind::Radio, groupId)) { radio_audio_stop(); m_groupId = 0; return -1; }
m_wantStereo = chat_setting_get_int(inst, "radio_capture_stereo", 0) != 0;
m_active = true;
m_capture.retryNow(); m_playback.retryNow();
m_capture.retryNow();
recoverDevices();
m_recoveryTimer.start();
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: listening group=%016llx", (unsigned long long)groupId);
@@ -77,65 +78,44 @@ void RadioAudioEngine::openCapture() {
if (attempt) DEBUG_WARN(DEBUG_CATEGORY_RADIO, "RadioAudio: selected input unavailable, trying default ch=%d", channels);
if (!m_capture.init(context, config, "radio-capture", m_groupId.load(), callback)) continue;
m_captureChannels = channels;
m_captureBuf.assign(RADIO_AUDIO_FRAME_SAMPLES * channels, 0);
m_capturePos = 0;
auto* inst = gui_bridge_get_inst();
int vad = chat_setting_get_int(inst, "radio_vad_enabled", 0);
if (radio_audio_capture_start(m_groupId.load(), channels, vad) != 0) {
m_capture.close(); m_capture.retryLater(); return;
}
m_vadMode = radio_audio_vad_mode() != 0;
if (!m_capture.start()) { radio_audio_capture_stop(); continue; }
if (m_talking) radio_audio_talk_begin(m_groupId.load());
if (!m_voice.startCapture(channels, chat_setting_get_int(inst, "aec_enabled", 1) != 0)) {
m_capture.close(); radio_audio_capture_stop(); return;
}
if (m_talking) m_voice.setPtt(true);
if (!m_capture.start()) { m_voice.stopCapture(); radio_audio_capture_stop(); continue; }
if (channels == 1 && m_wantStereo) DEBUG_WARN(DEBUG_CATEGORY_RADIO, "RadioAudio: stereo input unavailable, using mono");
return;
}
}
}
void RadioAudioEngine::openPlayback() {
auto* context = SoundManager::instance()->context();
if (!context) return;
ma_device_config config = ma_device_config_init(ma_device_type_playback);
config.playback.format = ma_format_s16;
config.playback.channels = 2;
config.sampleRate = RADIO_AUDIO_SAMPLE_RATE;
auto callback = [this](const void*, void* out, unsigned int frames) { playbackCallback(out, frames); };
ma_device_id id;
bool selected = SoundManager::deviceId(m_playbackId, &id);
if (selected) config.playback.pDeviceID = &id;
if (m_playback.init(context, config, "radio-playback", m_groupId.load(), callback) && m_playback.start()) return;
if (!selected) return;
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "RadioAudio: selected output unavailable, trying default");
config.playback.pDeviceID = nullptr;
if (m_playback.init(context, config, "radio-playback", m_groupId.load(), callback)) m_playback.start();
}
void RadioAudioEngine::recoverDevices() {
if (!m_active) return;
if (m_capture.needsRecovery()) {
m_capture.close(); radio_audio_capture_stop();
m_capturePos = 0; m_capture.retryLater();
auto* manager = SoundManager::instance();
if (!m_outputAttached && manager->context()) m_outputAttached = manager->attachVoice(&m_voice, m_playbackId);
if ((m_capture.initialized() && !m_voice.captureRunning()) || m_capture.needsRecovery()) {
m_capture.close(); m_voice.stopCapture(); radio_audio_capture_stop(); m_capture.retryLater();
}
if (m_playback.needsRecovery()) {
m_playback.close(); radio_audio_reset_playback(m_groupId.load()); m_playback.retryLater();
}
if (m_playback.retryReady()) openPlayback();
if (m_capture.retryReady()) openCapture();
m_voice.setAecEnabled(chat_setting_get_int(gui_bridge_get_inst(), "aec_enabled", 1) != 0);
}
void RadioAudioEngine::pauseDevices() {
m_capture.close(); m_playback.close();
m_capturePos = 0;
if (m_active) {
radio_audio_capture_stop();
radio_audio_reset_playback(m_groupId.load());
}
m_capture.close(); m_voice.stopCapture();
SoundManager::instance()->detachVoice(&m_voice); m_outputAttached = false;
if (m_active) radio_audio_capture_stop();
m_voice.resetPlayback();
}
void RadioAudioEngine::stop() {
m_recoveryTimer.stop();
m_capture.close(); m_playback.close();
pauseDevices(); m_voice.stop();
uint64_t group = m_groupId.exchange(0);
bool talking = m_talking.exchange(false);
m_buttonTalking = m_hotkeyTalking = false;
@@ -164,41 +144,12 @@ void RadioAudioEngine::setHotkeyTalking(bool pressed) {
void RadioAudioEngine::updateTalking() {
bool talking = m_active && (m_buttonTalking || m_hotkeyTalking);
if (m_talking.exchange(talking) == talking || !m_active) return;
if (talking) radio_audio_talk_begin(m_groupId.load());
else radio_audio_talk_end(m_groupId.load());
m_voice.setPtt(talking);
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "RadioAudio: PTT %s group=%016llx button=%d hotkey=%d",
talking ? "started" : "stopped", (unsigned long long)m_groupId.load(), m_buttonTalking, m_hotkeyTalking);
emit talkingChanged(talking);
}
void RadioAudioEngine::captureCallback(const void* in, unsigned int frames) {
const int16_t* in16 = (const int16_t*)in;
if (!in16) return;
/* VAD-режим: захват фидится постоянно (решение «говорить» — в radio_audio).
* Обычный PTT: только когда зажата кнопка. */
if (!m_vadMode.load() && !m_talking.load()) return;
/* захват: копим FRAME_SAMPLES * captureChannels → encode+send. */
int totalIn = (int)frames * m_captureChannels;
for (int i = 0; i < totalIn; i++) {
m_captureBuf[m_capturePos++] = in16[i];
if (m_capturePos >= (int)m_captureBuf.size()) {
int result = radio_audio_feed_pcm(m_groupId.load(), m_captureBuf.data(), (int)m_captureBuf.size());
if (result != 0) audio_diag_event(m_capture.device(), AUDIO_DIAG_ERROR, AUDIO_DIAG_FEED, 1, result);
m_capturePos = 0;
}
}
}
void RadioAudioEngine::playbackCallback(void* out, unsigned int frames) {
int16_t* out16 = (int16_t*)out;
/* воспроизведение: микшированный PCM из сервисного слоя (всегда стерео) */
unsigned int offset = 0;
while (offset < frames) {
unsigned int count = std::min(frames - offset, (unsigned int)RADIO_AUDIO_FRAME_SAMPLES);
int n = radio_audio_pull_pcm(m_groupId.load(), out16 + offset * 2, count * 2);
if (n < (int)count * 2) std::memset(out16 + offset * 2 + n, 0, (count * 2 - n) * sizeof(*out16));
offset += count;
}
if (in) m_voice.capture(static_cast<const int16_t*>(in), frames, m_capture.callbackTimeUs(), m_capture.route(), m_capture.captureMissing());
}
+13 -16
View File
@@ -5,18 +5,17 @@
#include <cstdint>
#include <vector>
#include "audio_device.h"
#include "voice_audio_io.h"
#include <QTimer>
struct ma_device;
/* Аудио-контур рации (desktop): только miniaudio I/O + захват/воспроизведение.
* Весь аудиопроцессинг (Opus, per-source декодеры, микшер) — в сервисном слое
* src/radio/radio_audio.c; сюда приходит/отсюда уходит финальный PCM.
*
* Потоки:
* - аудио-поток (miniaudio callback): capture → radio_audio_feed_pcm,
* radio_audio_pull_pcm → playback;
* - GUI-поток: start()/stop() по включению/выключению рации канала. */
/* Desktop-контур рации: GUI владеет capture device и жизненным циклом.
* Hardware callback копирует PCM в ограниченную очередь VoiceAudioIo.
* Capture worker выполняет AEC и упорядочивает PCM/PTT/mute; независимый RX worker
* получает готовый голос из сервисного слоя. SoundManager смешивает его со всеми
* звуками приложения на том же выходе и возвращает итоговый stereo reference.
* Stop: close capture → join capture worker → detach output → join RX → release core. */
class RadioAudioEngine : public QObject {
Q_OBJECT
public:
@@ -37,9 +36,8 @@ public:
bool isActive() const { return m_active.load(); }
quint64 groupId() const { return m_groupId.load(); }
/* аудио-поток: miniaudio callbacks (захват и воспроизведение раздельно) */
/* Hardware capture callback: без DSP и растущих буферов. */
void captureCallback(const void* in, unsigned int frames);
void playbackCallback(void* out, unsigned int frames);
signals:
void talkingChanged(bool talking); // GUI-поток, суммарное состояние кнопки и хоткея
@@ -51,19 +49,18 @@ private:
void pauseDevices();
void recoverDevices();
void openCapture();
void openPlayback();
std::atomic<bool> m_active{false};
std::atomic<bool> m_talking{false};
std::atomic<bool> m_vadMode{false}; /* VAD авто-PTT: фидим PCM постоянно */
std::atomic<uint64_t> m_groupId{0};
AudioDevice m_capture, m_playback;
AudioDevice m_capture;
VoiceAudioIo m_voice;
bool m_outputAttached = false;
QTimer m_recoveryTimer;
bool m_wantStereo = false;
QString m_captureId, m_playbackId;
std::vector<int16_t> m_captureBuf; /* FRAME_SAMPLES * m_captureChannels, аудио-поток */
int m_capturePos = 0;
int m_captureChannels = 1; /* 1=моно, 2=стерео — по настройке/возможности */
int m_captureChannels = 1;
};
+259 -45
View File
@@ -1,11 +1,13 @@
#include "miniaudio.h"
#include "sound_manager.h"
#include "voice_audio_io.h"
#include "../transport/audio_diagnostics.h"
#include <QFile>
#include <QDebug>
#include <algorithm>
#include <cstring>
#include <cmath>
#include "../../lib/debug_config.h"
SoundManager* SoundManager::instance() {
@@ -20,6 +22,14 @@ SoundManager::~SoundManager() {
extern "C" int audio_context_failed(ma_context* context);
static uint64_t pulse_output_key(const QString& key) {
ma_device_id id{};
if (!SoundManager::deviceId(key, &id)) return 0;
uint64_t hash = UINT64_C(14695981039346656037);
for (const char* name = id.pulse; *name; ++name) { hash ^= (unsigned char)*name; hash *= UINT64_C(1099511628211); }
return hash;
}
SoundManager::SoundManager() {
m_clock.start();
m_recoveryTimer.setInterval(200);
@@ -65,11 +75,15 @@ bool SoundManager::openContext() {
m_backendSelected = true;
m_contextRetryDelay = 250;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "SoundManager: context ready backend=%s", ma_get_backend_name(m_backend));
m_resetting = false;
emit contextRestored();
return true;
}
void SoundManager::openOutput() {
for (auto* voice : m_voices) if (voice) voice->resetPlayback();
if (m_output.initialized() && m_output.start()) return;
m_restoreSelections = true;
ma_device_config config = ma_device_config_init(ma_device_type_playback);
config.playback.format = ma_format_f32;
config.playback.channels = 2;
@@ -77,33 +91,213 @@ void SoundManager::openOutput() {
ma_device_id id;
bool selected = deviceId(m_playbackId, &id);
if (selected) config.playback.pDeviceID = &id;
auto callback = [this](const void*, void* out, unsigned int frames) {
ma_engine_read_pcm_frames(m_engine, out, frames, nullptr);
auto callback = [this, previousRoute = uint64_t(0)](const void*, void* out, unsigned int frames) mutable {
uint64_t route = m_output.route();
if (previousRoute && route != previousRoute) for (auto* voice : m_voices) if (voice) voice->resetPlayback();
previousRoute = route;
m_renderErrors.fetch_add(renderOutput(m_engine, m_voices, static_cast<float*>(out), frames, m_output.callbackTimeUs()));
};
if (m_output.init(m_context, config, "sound-engine", 0, callback) && m_output.start()) return;
if (m_output.init(m_context, config, "sound-engine", 0, callback) && m_output.start()) {
m_actualOutput = resolveOutput(m_playbackId); return;
}
if (!selected) return;
DEBUG_WARN(DEBUG_CATEGORY_CALL, "SoundManager: selected output unavailable, trying default");
config.playback.pDeviceID = nullptr;
if (m_output.init(m_context, config, "sound-engine", 0, callback)) m_output.start();
if (m_output.init(m_context, config, "sound-engine", 0, callback) && m_output.start()) m_actualOutput = resolveOutput({});
}
void SoundManager::recoverAudio() {
if (!m_initialized) return;
unsigned errors = m_renderErrors.exchange(0);
if (errors) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "audio: mixer read failures=%u; output replaced with silence", errors);
for (auto it = m_pcmSessions.begin(); it != m_pcmSessions.end();) {
if (it->second->transient && !isPcmPlaying(it->first)) it = m_pcmSessions.erase(it);
else ++it;
}
if (m_context && audio_context_failed(m_context)) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "SoundManager: context failed, closing dependent devices");
m_resetting = true;
emit contextAboutToReset();
m_output.close();
for (auto& [key, route] : m_voiceRoutes) route->device.close();
ma_context_uninit(m_context); delete m_context; m_context = nullptr;
m_contextRetryAt = m_clock.elapsed() + 250;
}
if (!m_context && m_clock.elapsed() >= m_contextRetryAt) openContext();
if (!m_context) return;
if (m_output.needsRecovery()) { m_output.close(); m_output.retryLater(); }
if (m_output.retryReady()) openOutput();
if (m_output.needsRecovery()) {
m_output.close();
for (auto* voice : m_voices) if (voice) voice->resetPlayback();
m_output.retryLater();
}
bool reopened = m_output.retryReady();
if (reopened) openOutput();
for (auto& [key, route] : m_voiceRoutes) {
if (route->device.needsRecovery()) {
route->device.close();
for (auto* voice : route->voices) if (voice) voice->resetPlayback();
route->device.retryLater();
}
if (route->device.retryReady()) openVoiceRoute(*route);
}
mergeVoiceRoutes();
if (m_restoreSelections && m_output.initialized() && (m_backend != ma_backend_pulseaudio || m_output.route())) {
m_restoreSelections = false;
auto voices = m_voices;
for (auto* voice : voices) if (voice) {
QString wanted = m_voiceSelections.at(voice);
bool same = m_backend == ma_backend_pulseaudio ? pulse_output_key(wanted) == m_output.route() : wanted == m_actualOutput;
if (!wanted.isEmpty() && wanted == resolveOutput(wanted) && !same) attachVoice(voice, wanted);
}
}
}
/* Последнее смешивание и ограничение: тот же PCM передаётся устройству и всем AEC этого выхода. */
unsigned SoundManager::renderOutput(ma_engine* engine, const std::array<VoiceAudioIo*, 2>& voices,
float* out, unsigned frames, int64_t firstTimeUs) {
unsigned errors = 0;
for (unsigned offset = 0; offset < frames;) {
unsigned count = std::min(frames - offset, 960u);
float* destination = out + offset * 2;
std::fill_n(destination, count * 2, 0.0f);
if (engine && ma_engine_read_pcm_frames(engine, destination, count, nullptr) != MA_SUCCESS) {
++errors; std::fill_n(destination, count * 2, 0.0f);
}
for (auto* voice : voices) if (voice) voice->mix(destination, count);
for (unsigned i = 0; i < count * 2; ++i) {
float value = destination[i];
destination[i] = std::isfinite(value) ? std::clamp(value, -1.0f, 1.0f) : 0.0f;
}
for (auto* voice : voices) if (voice)
voice->reference(destination, count, firstTimeUs + (int64_t)offset * 1000000 / 48000);
offset += count;
}
return errors;
}
/* Default и явный ID одного устройства получают один микшер. */
QString SoundManager::resolveOutput(const QString& requested) {
auto devices = enumeratePlaybackDevices();
for (const auto& device : devices) if (device.id == requested) return requested;
for (const auto& device : devices) if (device.isDefault) return device.id;
return {};
}
bool SoundManager::openVoiceRoute(VoiceRoute& route) {
if (!m_context || m_resetting) return false;
for (auto* voice : route.voices) if (voice) voice->resetPlayback();
if (route.device.initialized() && route.device.start()) return true;
ma_device_config config = ma_device_config_init(ma_device_type_playback);
config.playback.format = ma_format_f32; config.playback.channels = 2; config.sampleRate = 48000;
ma_device_id id;
bool selected = deviceId(route.wanted, &id);
if (selected) config.playback.pDeviceID = &id;
auto callback = [this, &route, previousRoute = uint64_t(0)](const void*, void* out, unsigned frames) mutable {
uint64_t physical = route.device.route();
if (previousRoute && physical != previousRoute) for (auto* voice : route.voices) if (voice) voice->resetPlayback();
previousRoute = physical;
if (physical && physical == m_output.route()) {
std::fill_n(static_cast<float*>(out), frames * 2, 0.0f); return; // GUI объединит совпавшие маршруты.
}
renderOutput(nullptr, route.voices, static_cast<float*>(out), frames, route.device.callbackTimeUs());
};
if (route.device.init(m_context, config, "voice-output", 0, callback) && route.device.start()) {
route.actual = resolveOutput(route.wanted); return true;
}
if (!selected) return false;
DEBUG_WARN(DEBUG_CATEGORY_CALL, "audio: voice output unavailable, trying default");
config.playback.pDeviceID = nullptr;
if (route.device.init(m_context, config, "voice-output", 0, callback) && route.device.start()) {
route.actual = resolveOutput({}); return true;
}
return false;
}
bool SoundManager::attachVoice(VoiceAudioIo* voice, const QString& output) {
detachVoice(voice);
if (!voice || !m_initialized || !m_context || m_resetting) return false;
size_t attached = std::count_if(m_voices.begin(), m_voices.end(), [](auto* v) { return v != nullptr; });
for (const auto& [key, route] : m_voiceRoutes)
attached += std::count_if(route->voices.begin(), route->voices.end(), [](auto* v) { return v != nullptr; });
if (attached >= 2) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio: only one call and one radio source are supported"); return false; }
QString key = resolveOutput(output);
QString root = m_actualOutput.isEmpty() ? resolveOutput(m_playbackId) : m_actualOutput;
bool same = key == root;
if (m_backend == ma_backend_pulseaudio && m_output.route()) same = pulse_output_key(key) == m_output.route();
if (same) {
m_output.pause();
auto slot = std::find(m_voices.begin(), m_voices.end(), nullptr);
if (slot == m_voices.end()) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio: too many voice sources on output"); openOutput(); return false; }
*slot = voice; voice->resetPlayback();
openOutput();
} else {
auto& route = m_voiceRoutes[key];
if (!route) { route = std::make_unique<VoiceRoute>(); route->wanted = key; }
route->device.pause();
auto slot = std::find(route->voices.begin(), route->voices.end(), nullptr);
if (slot == route->voices.end()) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio: too many voice sources on separate output"); openVoiceRoute(*route); return false; }
*slot = voice; voice->resetPlayback();
openVoiceRoute(*route);
mergeVoiceRoutes();
}
DEBUG_INFO(DEBUG_CATEGORY_CALL, "audio: voice attached to shared output default=%d", output.isEmpty());
m_voiceSelections[voice] = output;
return true;
}
void SoundManager::detachVoice(VoiceAudioIo* voice) {
if (!voice) return;
m_voiceSelections.erase(voice);
auto slot = std::find(m_voices.begin(), m_voices.end(), voice);
if (slot != m_voices.end()) {
m_output.pause(); *slot = nullptr;
if (m_initialized && m_context && !m_resetting) openOutput();
}
for (auto it = m_voiceRoutes.begin(); it != m_voiceRoutes.end();) {
auto& route = *it->second;
auto entry = std::find(route.voices.begin(), route.voices.end(), voice);
if (entry == route.voices.end()) { ++it; continue; }
route.device.pause(); *entry = nullptr;
if (std::all_of(route.voices.begin(), route.voices.end(), [](auto* v) { return !v; })) it = m_voiceRoutes.erase(it);
else { if (!m_resetting) openVoiceRoute(route); ++it; }
}
}
/* Fallback на уже используемый выход не оставляет два независимых референса одного динамика. */
void SoundManager::mergeVoiceRoutes() {
if (m_resetting || !m_output.initialized()) return;
for (auto first = m_voiceRoutes.begin(); first != m_voiceRoutes.end(); ++first) {
auto& target = *first->second;
for (auto next = std::next(first); next != m_voiceRoutes.end();) {
auto& source = *next->second;
uint64_t physical = target.device.route();
if (!physical || physical != source.device.route()) { ++next; continue; }
target.device.pause(); source.device.close();
for (auto* voice : source.voices) if (voice) {
auto slot = std::find(target.voices.begin(), target.voices.end(), nullptr);
if (slot == target.voices.end()) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio: voice route merge capacity exceeded"); return; }
*slot = voice;
}
next = m_voiceRoutes.erase(next);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "audio: merged voice streams on physical route=%016llx", (unsigned long long)physical);
openVoiceRoute(target);
}
}
for (auto it = m_voiceRoutes.begin(); it != m_voiceRoutes.end();) {
auto& route = *it->second;
bool same = m_backend == ma_backend_pulseaudio ? m_output.route() && m_output.route() == route.device.route()
: route.actual == m_actualOutput;
if (!route.device.initialized() || !same) { ++it; continue; }
m_output.pause(); route.device.close();
for (auto* voice : route.voices) if (voice) {
auto slot = std::find(m_voices.begin(), m_voices.end(), nullptr);
if (slot == m_voices.end()) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio: output merge capacity exceeded"); break; }
*slot = voice; voice->resetPlayback();
}
it = m_voiceRoutes.erase(it);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "audio: merged voice fallback with shared output");
openOutput();
}
}
void SoundManager::setDevices(const QString& output, const QString& input) {
@@ -111,7 +305,7 @@ void SoundManager::setDevices(const QString& output, const QString& input) {
bool outputChanged = output != m_playbackId;
m_playbackId = output; m_captureId = input;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "audio: device selection changed output_default=%d input_default=%d", output.isEmpty(), input.isEmpty());
if (outputChanged) { m_output.close(); m_output.retryNow(); }
if (outputChanged) { m_output.close(); m_actualOutput.clear(); m_output.retryNow(); }
emit devicesChanged();
recoverAudio();
}
@@ -137,6 +331,7 @@ bool SoundManager::createPcmNode(PcmSession& session, bool playing) {
return false;
}
session.sound = sound;
session.detached = false;
ma_sound_set_volume(sound, m_volume);
if (playing && ma_sound_start(sound) != MA_SUCCESS) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "playback: node start failed");
@@ -150,14 +345,11 @@ void SoundManager::shutdown() {
if (!m_initialized) return;
m_recoveryTimer.stop();
m_initialized = false;
m_resetting = true;
emit contextAboutToReset();
m_output.close();
m_output.close(); m_voiceRoutes.clear(); m_voices = {}; m_voiceSelections.clear();
m_pcmSessions.clear();
for (auto it = m_sounds.begin(); it != m_sounds.end(); ++it) {
if (it->sound) { ma_sound_uninit(it->sound); delete it->sound; }
if (it->decoder) { ma_decoder_uninit(it->decoder); delete it->decoder; }
}
m_sounds.clear();
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "SoundManager: before ma_engine_uninit engine=%p", (void*)m_engine);
@@ -197,47 +389,49 @@ SoundManager::EventSettings SoundManager::eventSettings(const QString& name) con
bool SoundManager::loadSound(const QString& name, const QString& filePath) {
if (!m_initialized) {
qWarning("SoundManager: not initialized, cannot load %s", qPrintable(name));
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "SoundManager: not initialized, cannot load '%s'", qPrintable(name));
return false;
}
if (m_sounds.contains(name)) {
qWarning("SoundManager: sound '%s' already loaded", qPrintable(name));
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "SoundManager: sound '%s' already loaded", qPrintable(name));
return false;
}
QFile f(filePath);
if (!f.open(QIODevice::ReadOnly)) {
qWarning("SoundManager: cannot open %s", qPrintable(filePath));
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "SoundManager: cannot open '%s'", qPrintable(filePath));
return false;
}
LoadedSound ls;
ls.mp3Data = f.readAll();
QByteArray encoded = f.readAll();
f.close();
ls.decoder = new ma_decoder;
ma_decoder_config decConfig = ma_decoder_config_init_default();
ma_result result = ma_decoder_init_memory(ls.mp3Data.constData(), ls.mp3Data.size(),
&decConfig, ls.decoder);
if (result != MA_SUCCESS) {
qWarning("SoundManager: ma_decoder_init_memory failed for %s (%d)", qPrintable(name), result);
delete ls.decoder;
/* Декодируем короткие сигналы при загрузке, до playback: hardware callback читает только PCM. */
ma_decoder_config decConfig = ma_decoder_config_init(ma_format_s16, 2, 48000);
ma_uint64 frames = 0; void* decoded = nullptr;
ma_result result = ma_decode_memory(encoded.constData(), encoded.size(), &decConfig, &frames, &decoded);
if (result != MA_SUCCESS || !frames) {
ma_free(decoded, &decConfig.allocationCallbacks);
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "SoundManager: decode '%s' failed result=%d frames=%llu",
qPrintable(name), result, (unsigned long long)frames);
return false;
}
ls.sound = new ma_sound;
result = ma_sound_init_from_data_source(m_engine, (ma_data_source*)&ls.decoder->ds,
0, NULL, ls.sound);
LoadedSound ls;
ls.pcm = std::make_shared<PcmSession>();
ls.pcm->frames = frames; ls.pcm->rate = 48000;
ma_audio_buffer_config bufferConfig = ma_audio_buffer_config_init(ma_format_s16, 2, frames, decoded, nullptr);
bufferConfig.sampleRate = 48000;
ls.pcm->buffer = new ma_audio_buffer{};
result = ma_audio_buffer_init_copy(&bufferConfig, ls.pcm->buffer);
ma_free(decoded, &decConfig.allocationCallbacks);
if (result != MA_SUCCESS) {
qWarning("SoundManager: sound init failed for %s (%d)", qPrintable(name), result);
ma_decoder_uninit(ls.decoder); delete ls.decoder;
delete ls.sound;
delete ls.pcm->buffer; ls.pcm->buffer = nullptr;
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "SoundManager: signal PCM init failed result=%d", result);
return false;
}
if (!createPcmNode(*ls.pcm, false)) return false;
m_sounds.insert(name, ls);
qDebug("SoundManager: loaded '%s' from %s (%lld bytes)",
qPrintable(name), qPrintable(filePath), (long long)ls.mp3Data.size());
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "SoundManager: predecoded '%s' frames=%llu rate=48000 ch=2",
qPrintable(name), (unsigned long long)frames);
return true;
}
@@ -254,12 +448,15 @@ void SoundManager::play(const QString& name) {
return;
}
ma_sound* s = it->sound;
auto& pcm = *it->pcm;
if (pcm.sound) { ma_sound_uninit(pcm.sound); delete pcm.sound; pcm.sound = nullptr; }
ma_audio_buffer_seek_to_pcm_frame(pcm.buffer, 0);
if (!createPcmNode(pcm, false)) return;
ma_sound* s = pcm.sound;
float effectiveVolume = m_volume * es.volume;
ma_sound_set_volume(s, effectiveVolume);
ma_sound_stop(s);
ma_sound_seek_to_pcm_frame(s, 0);
ma_sound_start(s);
if (ma_sound_start(s) != MA_SUCCESS) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "SoundManager: signal start failed '%s'", qPrintable(name));
}
void SoundManager::playLoop(const QString& name) {
@@ -278,12 +475,15 @@ void SoundManager::playLoop(const QString& name) {
return;
}
ma_sound* s = it->sound;
auto& pcm = *it->pcm;
if (pcm.sound) { ma_sound_uninit(pcm.sound); delete pcm.sound; pcm.sound = nullptr; }
ma_audio_buffer_seek_to_pcm_frame(pcm.buffer, 0);
if (!createPcmNode(pcm, false)) return;
ma_sound* s = pcm.sound;
ma_sound_stop(s);
ma_sound_seek_to_pcm_frame(s, 0);
ma_sound_set_volume(s, m_volume * es.volume);
ma_sound_set_looping(s, MA_TRUE);
ma_sound_start(s);
if (ma_sound_start(s) != MA_SUCCESS) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "SoundManager: loop start failed '%s'", qPrintable(name)); return; }
m_loopName = name;
}
@@ -292,7 +492,8 @@ void SoundManager::stopLoop() {
auto it = m_sounds.find(m_loopName);
m_loopName.clear();
if (it == m_sounds.end()) return;
ma_sound* s = it->sound;
ma_sound* s = it->pcm->sound;
if (!s) return;
ma_sound_stop(s);
ma_sound_set_looping(s, MA_FALSE);
ma_sound_seek_to_pcm_frame(s, 0);
@@ -336,13 +537,26 @@ SoundManager::PlaybackId SoundManager::playRawPcm(const int16_t* pcm, int frameC
void SoundManager::pausePcm(PlaybackId id) {
auto* session = pcmSession(id);
if (session && session->sound) ma_sound_stop(session->sound);
if (!session || !session->sound) return;
ma_result result = ma_sound_stop(session->sound);
/* Stop меняет флаг; detach также ждёт уже начатое чтение и фиксирует cursor. */
if (result == MA_SUCCESS && !session->detached) {
result = ma_node_detach_output_bus(session->sound, 0);
if (result == MA_SUCCESS) session->detached = true;
}
if (result != MA_SUCCESS) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "playback: pause failed id=%llu result=%d", (unsigned long long)id, result);
}
void SoundManager::resumePcm(PlaybackId id) {
auto* session = pcmSession(id);
if (session && session->sound && ma_sound_start(session->sound) != MA_SUCCESS)
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "playback: resume failed id=%llu", (unsigned long long)id);
if (!session || !session->sound) return;
ma_result result = MA_SUCCESS;
if (session->detached) {
result = ma_node_attach_output_bus(session->sound, 0, ma_engine_get_endpoint(m_engine), 0);
if (result == MA_SUCCESS) session->detached = false;
}
if (result == MA_SUCCESS) result = ma_sound_start(session->sound);
if (result != MA_SUCCESS) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "playback: resume failed id=%llu result=%d", (unsigned long long)id, result);
}
void SoundManager::stopPcm(PlaybackId id) {
+26 -3
View File
@@ -7,6 +7,7 @@
#include <vector>
#include <memory>
#include <map>
#include <array>
#include <cstdint>
#include <QTimer>
#include "audio_device.h"
@@ -15,6 +16,7 @@ struct ma_engine;
struct ma_context;
struct ma_decoder;
struct ma_sound;
class VoiceAudioIo;
class SoundManager : public QObject {
Q_OBJECT
@@ -48,6 +50,10 @@ public:
QString playbackDevice() const { return m_playbackId; }
QString captureDevice() const { return m_captureId; }
/* GUI: voice живёт до detachVoice; изменение списка выполняется при остановленном output. */
bool attachVoice(VoiceAudioIo* voice, const QString& output);
void detachVoice(VoiceAudioIo* voice);
using PlaybackId = uint64_t;
/* GUI: независимая сессия. owner удерживает PCM до detach; без owner данные копируются. */
PlaybackId playRawPcm(const int16_t* pcm, int frameCount, int sampleRate, int channels,
@@ -84,6 +90,7 @@ private:
uint64_t frames = 0, origin = 0;
int rate = 0;
bool transient = false;
bool detached = false;
~PcmSession();
};
PcmSession* pcmSession(PlaybackId id) const;
@@ -92,10 +99,26 @@ private:
bool openContext();
void openOutput();
struct VoiceRoute {
AudioDevice device;
QString wanted, actual;
std::array<VoiceAudioIo*, 2> voices{};
};
QString resolveOutput(const QString& requested);
bool openVoiceRoute(VoiceRoute& route);
void mergeVoiceRoutes();
static unsigned renderOutput(ma_engine* engine, const std::array<VoiceAudioIo*, 2>& voices,
float* out, unsigned frames, int64_t firstTimeUs);
std::atomic<unsigned> m_renderErrors{0};
std::array<VoiceAudioIo*, 2> m_voices{};
std::map<QString, std::unique_ptr<VoiceRoute>> m_voiceRoutes;
std::map<VoiceAudioIo*, QString> m_voiceSelections; // Desired выбор сохраняется при объединении physical outputs.
QString m_actualOutput;
bool m_resetting = false;
bool m_restoreSelections = false;
struct LoadedSound {
QByteArray mp3Data;
ma_decoder* decoder = nullptr;
ma_sound* sound = nullptr;
std::shared_ptr<PcmSession> pcm;
};
ma_engine* m_engine = nullptr;
+331
View File
@@ -0,0 +1,331 @@
#include "voice_audio_io.h"
extern "C" {
#include "call/call_audio.h"
#include "radio/radio_audio.h"
#include "speex_aec.h"
#include "debug_config.h"
}
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstring>
#include <system_error>
static int64_t voice_time_us() {
return std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
VoiceAudioIo::~VoiceAudioIo() { stop(); }
bool VoiceAudioIo::start(Kind kind, uint64_t session) {
stop();
m_kind = kind; m_session = session;
m_captureEvents.reset(); m_references.reset(); m_playback.reset();
m_playPosition = 960; m_playEpoch = 1;
m_captureDropped = m_referenceDropped = m_playbackUnderruns = 0;
m_running = true;
try { m_playbackThread = std::thread(&VoiceAudioIo::playbackLoop, this); }
catch (const std::system_error& error) {
m_running = false;
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "voice-io: RX worker start failed reason=%s", error.what()); return false;
}
DEBUG_INFO(DEBUG_CATEGORY_CALL, "voice-io: started session=%llu kind=%s PCM reserve<=40ms",
(unsigned long long)session, kind == Kind::Call ? "call" : "radio");
return true;
}
void VoiceAudioIo::stop() {
stopCapture();
m_running = false; m_wake.notify_all();
if (m_playbackThread.joinable()) m_playbackThread.join();
}
bool VoiceAudioIo::startCapture(int channels, bool aecEnabled) {
stopCapture();
if (!m_running || channels < 1 || channels > 2 || (m_kind == Kind::Call && channels != 1)) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "voice-io: invalid capture channels=%d", channels); return false;
}
m_captureEvents.reset(); m_channels = channels; m_captureSequence = 0;
m_aecRequested = aecEnabled; m_capturing = true;
try { m_captureThread = std::thread(&VoiceAudioIo::captureLoop, this); }
catch (const std::system_error& error) {
m_capturing = false;
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "voice-io: capture worker start failed reason=%s", error.what()); return false;
}
return true;
}
void VoiceAudioIo::stopCapture() {
if (m_captureThread.joinable()) {
if (m_capturing) command(EventKind::Stop);
m_captureThread.join();
}
m_capturing = false;
}
/* GUI-команда не теряется при заполнении PCM: зарезервированы слоты; отказ завершает capture. */
void VoiceAudioIo::command(EventKind kind, bool value) {
if (!m_capturing) return;
Event event{}; event.kind = kind; event.value = value;
for (int attempt = 0; attempt < 100; ++attempt) {
if (m_captureEvents.push(event, true)) { m_wake.notify_all(); return; }
m_wake.notify_all(); std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "voice-io: control queue full session=%llu command=%d; stopping capture",
(unsigned long long)m_session, (int)kind);
m_capturing = false; m_wake.notify_all();
}
void VoiceAudioIo::setPtt(bool pressed) { command(EventKind::Ptt, pressed); }
void VoiceAudioIo::setMuted(bool muted) { command(EventKind::Mute, muted); }
void VoiceAudioIo::setAecEnabled(bool enabled) {
if (enabled == m_aecRequested) return;
m_aecRequested = enabled; command(EventKind::Aec, enabled);
}
void VoiceAudioIo::resetPlayback() { ++m_playEpoch; m_wake.notify_all(); }
void VoiceAudioIo::capture(const int16_t* pcm, unsigned frames, int64_t firstTimeUs, uint64_t route, bool missing) {
if (!pcm || !m_capturing) return;
if (missing) { m_captureSequence += frames; m_captureDropped.fetch_add(frames); return; }
int64_t enqueued = voice_time_us();
if (!firstTimeUs) firstTimeUs = enqueued - (int64_t)frames * 1000000 / 48000;
unsigned offset = 0;
while (offset < frames) {
Event event{}; event.frames = std::min(frames - offset, 960u);
event.time = firstTimeUs + (int64_t)offset * 1000000 / 48000;
event.sequence = m_captureSequence; m_captureSequence += event.frames;
event.route = route;
event.enqueued = enqueued;
memcpy(event.pcm, pcm + offset * m_channels, event.frames * m_channels * sizeof(int16_t));
if (!m_captureEvents.push(event)) m_captureDropped.fetch_add(event.frames, std::memory_order_relaxed);
offset += event.frames;
}
m_wake.notify_all();
}
void VoiceAudioIo::reference(const float* pcm, unsigned frames, int64_t firstTimeUs) {
if (!m_capturing) return;
if (!firstTimeUs) firstTimeUs = voice_time_us();
for (unsigned offset = 0; offset < frames;) {
Event event{}; event.frames = std::min(frames - offset, 960u);
event.time = firstTimeUs + (int64_t)offset * 1000000 / 48000;
event.sequence = m_playEpoch.load();
for (unsigned i = 0; i < event.frames * 2; ++i) {
float sample = pcm[offset * 2 + i];
event.pcm[i] = (int16_t)std::clamp(sample * 32768.0f, -32768.0f, 32767.0f);
}
if (!m_references.push(event)) m_referenceDropped.fetch_add(event.frames, std::memory_order_relaxed);
offset += event.frames;
}
m_wake.notify_all();
}
/* Выход всегда обслуживается независимо от capture/VAD. Уже потреблённый PCM становится референсом у владельца микшера. */
void VoiceAudioIo::mix(float* stereo, unsigned frames) {
uint64_t epoch = m_playEpoch.load();
for (unsigned i = 0; i < frames; ++i) {
if (m_playCurrent.epoch != epoch) m_playPosition = 960;
if (m_playPosition == 960) {
bool available = false;
for (int attempt = 0; attempt < 12 && m_playback.pop(m_playCurrent); ++attempt) {
if (m_playCurrent.epoch == epoch) { available = true; m_playPosition = 0; break; }
}
if (!available) { m_playbackUnderruns.fetch_add(frames - i, std::memory_order_relaxed); break; }
}
stereo[2*i] += m_playCurrent.pcm[2*m_playPosition] / 32768.0f;
stereo[2*i+1] += m_playCurrent.pcm[2*m_playPosition+1] / 32768.0f;
++m_playPosition;
}
m_wake.notify_all();
}
void VoiceAudioIo::playbackLoop() {
uint64_t epoch = m_playEpoch.load();
while (m_running) {
uint64_t requested = m_playEpoch.load();
if (requested != epoch) {
if (m_kind == Kind::Call) call_audio_reset_io(m_session, 1);
else radio_audio_reset_playback(m_session);
epoch = requested;
}
if (m_playback.size() < 2) {
Playback block{}; block.epoch = epoch;
if (m_kind == Kind::Call) {
int16_t mono[960]{};
int count = std::clamp(call_audio_pull_pcm(m_session, mono, 960), 0, 960);
for (int i = 0; i < count; ++i) block.pcm[2*i] = block.pcm[2*i+1] = mono[i];
} else radio_audio_pull_pcm(m_session, block.pcm, 1920);
if (!m_playback.push(block)) DEBUG_WARN(DEBUG_CATEGORY_CALL, "voice-io: playback queue contention");
} else {
std::unique_lock lock(m_waitMutex);
m_wake.wait_for(lock, std::chrono::milliseconds(5));
}
}
}
/* Референс хранится как PCM + время, а не как фиксированный двухкадровый FIFO независимых часов. */
void VoiceAudioIo::captureLoop() {
std::array<Event, 96> history{};
size_t historyHead = 0, historyCount = 0;
uint64_t referenceEpoch = m_playEpoch.load(), referenceDrops = m_referenceDropped.load();
speex_aec_t* aec = nullptr;
auto configureAec = [&](bool enabled) {
speex_aec_destroy(aec); aec = nullptr;
if (enabled) aec = speex_aec_create_mc(48000, 960, 14400, 1, m_channels, 2);
if (enabled && !aec) DEBUG_ERROR(DEBUG_CATEGORY_AEC, "voice-io: AEC unavailable session=%llu", (unsigned long long)m_session);
DEBUG_INFO(DEBUG_CATEGORY_AEC, "voice-io: capture AEC=%d mic=%d render=2 session=%llu",
aec != nullptr, m_channels, (unsigned long long)m_session);
};
configureAec(m_aecRequested);
struct Marker { unsigned position; EventKind kind; bool value; };
std::array<Marker, 64> markers{};
size_t markerCount = 0;
int16_t raw[1920]{}, cleaned[1920]{}, referencePcm[1920]{};
uint8_t muteMask[960]{};
unsigned filled = 0;
int64_t frameTime = 0, lastReport = voice_time_us();
uint64_t expectedSequence = 0, missingReference = 0, reportedReferenceDrops = referenceDrops, captureRoute = 0;
int64_t expectedCaptureTime = 0;
bool referenceAvailable = false;
bool muted = false;
auto applyMarker = [&](EventKind kind, bool value) {
if (kind == EventKind::Mute) muted = value;
else if (kind == EventKind::Ptt && m_kind == Kind::Radio) {
if (value) radio_audio_talk_begin(m_session); else radio_audio_talk_end(m_session);
}
};
auto consumeReference = [&]() {
uint64_t epoch = m_playEpoch.load(), drops = m_referenceDropped.load();
if (epoch != referenceEpoch || drops != referenceDrops) {
DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: reference reset epoch=%llu->%llu dropped=%llu",
(unsigned long long)referenceEpoch, (unsigned long long)epoch, (unsigned long long)(drops - referenceDrops));
historyHead = historyCount = 0;
if (aec) speex_aec_reset(aec);
referenceEpoch = epoch; referenceDrops = drops;
}
Event event{};
for (unsigned drained = 0; drained < 32 && m_references.pop(event); ++drained) {
if (event.sequence != referenceEpoch) continue;
if (historyCount && event.time <= history[(historyHead + historyCount - 1) % history.size()].time) {
DEBUG_WARN(DEBUG_CATEGORY_AEC, "voice-io: non-monotonic render clock, resetting AEC");
historyHead = historyCount = 0;
if (aec) speex_aec_reset(aec);
}
if (historyCount == history.size()) { historyHead = (historyHead + 1) % history.size(); --historyCount; }
history[(historyHead + historyCount++) % history.size()] = event;
}
};
auto alignReference = [&]() {
if (!historyCount) return false;
size_t index = 0;
for (unsigned i = 0; i < 960; ++i) {
double time = frameTime + (double)i * 1000000.0 / 48000.0;
while (index + 1 < historyCount && history[(historyHead + index + 1) % history.size()].time <= time) ++index;
auto& block = history[(historyHead + index) % history.size()];
double interval = 1000000.0 / 48000.0;
if (index + 1 < historyCount) {
auto& next = history[(historyHead + index + 1) % history.size()];
double measured = (double)(next.time - block.time) / block.frames;
if (measured > interval * 0.95 && measured < interval * 1.05) interval = measured;
}
double position = (time - block.time) / interval;
if (position < 0 || position >= block.frames) return false;
unsigned sample = (unsigned)position;
double fraction = position - sample;
for (unsigned channel = 0; channel < 2; ++channel) {
int first = block.pcm[2*sample+channel];
int second = sample + 1 < block.frames ? block.pcm[2*(sample+1)+channel] : first;
referencePcm[2*i+channel] = (int16_t)(first + (second - first) * fraction);
}
}
return true;
};
auto deliver = [&](unsigned frames) {
unsigned begin = 0;
for (size_t mark = 0; mark <= markerCount; ++mark) {
unsigned end = mark < markerCount ? markers[mark].position : frames;
if (m_kind == Kind::Call) memset(muteMask + begin, muted, end - begin);
else if (end > begin) radio_audio_feed_pcm(m_session, cleaned + begin * m_channels, (end - begin) * m_channels);
if (mark < markerCount) applyMarker(markers[mark].kind, markers[mark].value);
begin = end;
}
if (m_kind == Kind::Call && frames == 960) call_audio_feed_prepared_pcm(m_session, cleaned, muteMask);
filled = 0; markerCount = 0;
};
bool stopping = false;
while (m_capturing && !stopping) {
consumeReference();
Event event{};
if (!m_captureEvents.pop(event)) {
std::unique_lock lock(m_waitMutex); m_wake.wait_for(lock, std::chrono::milliseconds(5)); continue;
}
if (event.kind == EventKind::Stop) { stopping = true; break; }
if (event.kind == EventKind::Aec) { configureAec(event.value); continue; }
if (event.kind != EventKind::Pcm) {
if (!filled) applyMarker(event.kind, event.value);
else if (markerCount < markers.size()) markers[markerCount++] = {filled, event.kind, event.value};
else { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "voice-io: too many controls within one capture frame"); stopping = true; }
continue;
}
if (voice_time_us() - event.enqueued > 100000) {
m_captureDropped.fetch_add(event.frames); continue;
}
bool clockJump = expectedCaptureTime && std::abs(event.time - expectedCaptureTime) > 20000;
if (event.sequence != expectedSequence || (captureRoute && event.route != captureRoute) || clockJump) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "voice-io: capture discontinuity expected=%llu actual=%llu pending=%u route=%016llx->%016llx",
(unsigned long long)expectedSequence, (unsigned long long)event.sequence, filled,
(unsigned long long)captureRoute, (unsigned long long)event.route);
if (clockJump) DEBUG_WARN(DEBUG_CATEGORY_AEC, "voice-io: capture clock jump=%lldus", (long long)(event.time - expectedCaptureTime));
for (size_t i = 0; i < markerCount; ++i) applyMarker(markers[i].kind, markers[i].value);
filled = 0; markerCount = 0;
if (aec) speex_aec_reset(aec);
if (m_kind == Kind::Radio) radio_audio_capture_discontinuity(m_session);
}
expectedSequence = event.sequence + event.frames;
captureRoute = event.route;
expectedCaptureTime = event.time + (int64_t)event.frames * 1000000 / 48000;
for (unsigned offset = 0; offset < event.frames;) {
if (!filled) frameTime = event.time + (int64_t)offset * 1000000 / 48000;
unsigned take = std::min(960 - filled, event.frames - offset);
memcpy(raw + filled * m_channels, event.pcm + offset * m_channels, take * m_channels * sizeof(int16_t));
filled += take; offset += take;
if (filled != 960) continue;
memcpy(cleaned, raw, 960 * m_channels * sizeof(int16_t));
consumeReference();
bool available = aec && alignReference();
if (available) {
speex_aec_feed_playback(aec, referencePcm, 1920);
speex_aec_process_capture(aec, raw, 960 * m_channels, cleaned);
} else if (aec) {
++missingReference;
if (referenceAvailable) speex_aec_reset(aec);
}
referenceAvailable = available;
deliver(960);
}
int64_t now = voice_time_us();
if (now - lastReport >= 1000000) {
uint64_t captureDrops = m_captureDropped.exchange(0), refDrops = m_referenceDropped.load();
uint64_t underruns = m_playbackUnderruns.exchange(0);
if (captureDrops || refDrops != reportedReferenceDrops || missingReference || underruns)
DEBUG_WARN(DEBUG_CATEGORY_AEC, "voice-io: session=%llu capture_drop=%llu reference_drop=%llu missing_ref=%llu output_missing=%llu",
(unsigned long long)m_session, (unsigned long long)captureDrops, (unsigned long long)refDrops,
(unsigned long long)missingReference, (unsigned long long)underruns);
else DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: session=%llu aligned history=%zu capture_queue=%zu",
(unsigned long long)m_session, historyCount, m_captureEvents.size());
missingReference = 0; reportedReferenceDrops = refDrops; lastReport = now;
}
}
if (filled) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "voice-io: capture tail=%u frames bypasses AEC; call tail discarded, radio tail preserved", filled);
memcpy(cleaned, raw, filled * m_channels * sizeof(int16_t));
deliver(filled);
}
uint64_t lostCapture = m_captureDropped.exchange(0), lostReference = m_referenceDropped.load() - reportedReferenceDrops;
if (lostCapture || lostReference || missingReference)
DEBUG_WARN(DEBUG_CATEGORY_AEC, "voice-io: capture stopped lost_capture=%llu lost_reference=%llu missing_reference=%llu",
(unsigned long long)lostCapture, (unsigned long long)lostReference, (unsigned long long)missingReference);
speex_aec_destroy(aec);
m_capturing = false;
}
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include "audio_event_queue.h"
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <thread>
#include <cstdint>
/* Desktop: аппаратные callbacks только копируют PCM, render также выполняет готовое смешивание.
* Capture worker — единственный владелец AEC и порядка PCM/PTT/mute. RX worker независим от VAD.
* Управляющий поток закрывает capture и отсоединяет output до stop/destruction.
* Финальный stereo render приходит с того же выходного устройства после всего микширования. */
class VoiceAudioIo {
public:
enum class Kind { Call, Radio };
VoiceAudioIo() = default;
~VoiceAudioIo();
bool start(Kind kind, uint64_t session);
void stop();
bool startCapture(int channels, bool aecEnabled);
void stopCapture();
bool captureRunning() const { return m_capturing.load(); }
void setPtt(bool pressed);
void setMuted(bool muted);
void setAecEnabled(bool enabled);
void resetPlayback();
/* firstTimeUs — монотонное время первого физического PCM-фрейма (backend либо оценка). */
void capture(const int16_t* pcm, unsigned frames, int64_t firstTimeUs, uint64_t route = 0, bool missing = false);
void reference(const float* pcm, unsigned frames, int64_t firstTimeUs);
void mix(float* stereo, unsigned frames);
private:
enum class EventKind { Pcm, Ptt, Mute, Aec, Stop };
struct Event {
EventKind kind = EventKind::Pcm;
unsigned frames = 0;
int64_t time = 0;
int64_t enqueued = 0;
uint64_t sequence = 0;
uint64_t route = 0;
bool value = false;
int16_t pcm[1920]{};
};
struct Playback { uint64_t epoch = 0; int16_t pcm[1920]{}; };
AudioEventQueue<Event, 32> m_captureEvents, m_references;
AudioEventQueue<Playback, 12> m_playback;
std::atomic<bool> m_running{false}, m_capturing{false};
std::atomic<uint64_t> m_playEpoch{0}, m_captureDropped{0}, m_referenceDropped{0}, m_playbackUnderruns{0};
uint64_t m_captureSequence = 0; // Только capture callback; reset после его остановки.
Kind m_kind = Kind::Call;
uint64_t m_session = 0;
int m_channels = 1;
std::atomic<bool> m_aecRequested{false};
std::thread m_captureThread, m_playbackThread;
std::mutex m_waitMutex;
std::condition_variable m_wake;
Playback m_playCurrent{};
unsigned m_playPosition = 960;
void command(EventKind kind, bool value = false);
void captureLoop();
void playbackLoop();
};
+24 -1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Изолированный PipeWire/PulseAudio с виртуальным sink; системный сервер не затрагивается."""
import os
import json
from pathlib import Path
import shutil
import signal
@@ -54,14 +55,35 @@ def main():
return subprocess.run(["pactl", "info"], env=env, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=2).returncode == 0
def pactl(*args):
return subprocess.check_output(["pactl", *args], env=env, text=True, timeout=5)
def create_sinks():
for name in ("utun_test_main", "utun_test_voice"):
pactl("load-module", "module-null-sink", f"sink_name={name}")
pactl("set-default-sink", "utun_test_main")
pactl("set-default-source", "utun_test_main.monitor")
try:
launch(["pipewire"], "pipewire")
wait_for(lambda: (directory / "pipewire-0").exists())
launch(["dbus-run-session", "--", "wireplumber"], "wireplumber")
pulse = launch(["pipewire-pulse"], "pulse-before")
wait_for(pulse_ready)
# module-always-sink создаёт виртуальный dummy sink и его monitor source.
create_sinks()
test = launch([str(binary), "--pulse-integration", root], "client")
wait_for(lambda: (directory / "routes_ready").exists() or test.poll() is not None)
if test.poll() is not None:
raise RuntimeError("client failed before route move")
sinks = json.loads(pactl("--format=json", "list", "sinks"))
main_sink = next(sink["index"] for sink in sinks if sink["name"] == "utun_test_main")
streams = json.loads(pactl("--format=json", "list", "sink-inputs"))
main_streams = [stream for stream in streams if stream["sink"] == main_sink]
if len(main_streams) != 1:
raise RuntimeError("expected one shared default stream")
# Явно выбранный stream имеет DONT_MOVE; default stream разрешено перемещать сервером.
pactl("move-sink-input", str(main_streams[0]["index"]), "utun_test_voice")
(directory / "moved").touch()
wait_for(lambda: (directory / "ready").exists() or test.poll() is not None)
if test.poll() is not None:
raise RuntimeError("client failed before server restart")
@@ -69,6 +91,7 @@ def main():
wait_for(lambda: (directory / "closed").exists() or test.poll() is not None)
launch(["pipewire-pulse"], "pulse-after")
wait_for(pulse_ready)
create_sinks()
if test.wait(timeout=15) != 0:
raise RuntimeError("client recovery failed")
logs[3].flush(); logs[3].seek(0)
@@ -0,0 +1,50 @@
/* MPSC: резерв управляющих сообщений, публикация слота и порядок каждого producer. */
#include "../src/audio_event_queue.h"
#include <thread>
#include <vector>
#include <cstdio>
#include <cstdlib>
#define REQUIRE(x) do { if (!(x)) { fprintf(stderr, "queue:%d %s\n", __LINE__, #x); abort(); } } while (0)
static std::atomic<bool> entered{false}, releaseCopy{false};
struct Item {
int producer = 0, sequence = 0;
bool block = false;
Item& operator=(const Item& other) {
if (other.block) { entered = true; while (!releaseCopy) std::this_thread::yield(); }
producer = other.producer; sequence = other.sequence; block = other.block; return *this;
}
};
int main() {
AudioEventQueue<Item, 16> queue;
Item item{};
for (int i = 0; i < 8; ++i) REQUIRE(queue.push(item));
REQUIRE(!queue.push(item));
for (int i = 0; i < 8; ++i) REQUIRE(queue.push(item, true));
REQUIRE(!queue.push(item, true));
for (int i = 0; i < 16; ++i) REQUIRE(queue.pop(item));
REQUIRE(!queue.pop(item));
std::thread first([&] { REQUIRE(queue.push({0, 123, true})); });
while (!entered) std::this_thread::yield();
REQUIRE(queue.push({1, 456, false}, true));
REQUIRE(!queue.pop(item)); // Второй слот опубликован, первый ещё принадлежит producer.
releaseCopy = true; first.join();
REQUIRE(queue.pop(item) && item.sequence == 123);
REQUIRE(queue.pop(item) && item.sequence == 456);
queue.reset();
constexpr int producers = 4, count = 20000;
std::vector<std::thread> threads;
for (int p = 0; p < producers; ++p) threads.emplace_back([&, p] {
for (int i = 0; i < count; ++i) while (!queue.push({p, i, false}, i % 7 == 0)) std::this_thread::yield();
});
int expected[producers]{};
for (int total = 0; total < producers * count;) {
if (!queue.pop(item)) { std::this_thread::yield(); continue; }
REQUIRE(queue.size() <= 16);
REQUIRE(item.producer >= 0 && item.producer < producers);
REQUIRE(item.sequence == expected[item.producer]++); ++total;
}
for (auto& thread : threads) thread.join();
REQUIRE(queue.size() == 0);
puts("audio queue: control reserve, unpublished slot, 80000 ordered messages PASS");
}
+53 -3
View File
@@ -82,11 +82,40 @@ static void pump(int milliseconds) {
static void pulse_recovery(SoundManager* manager, const QString& control) {
REQUIRE(manager->context()->backend == ma_backend_pulseaudio);
instance.ua = uasync_create(); REQUIRE(instance.ua);
REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0);
auto* call = CallAudioEngine::instance(); auto* radio = RadioAudioEngine::instance();
REQUIRE(call->start(42) == 0 && radio->start(7) == 0);
QString separate;
for (const auto& output : manager->enumeratePlaybackDevices()) {
ma_device_id id{};
if (manager->deviceId(output.id, &id) && !strcmp(id.pulse, "utun_test_voice")) separate = output.id;
}
REQUIRE(!separate.isEmpty()); call->setPlaybackDevice(separate);
AudioRecorder recorder; recorder.init(); recorder.startRecording();
std::vector<int16_t> pcm(48000 * 20, 1000);
auto pcmId = manager->playRawPcm(pcm.data(), (int)pcm.size(), 48000, 1);
for (int i = 0; i < 100 && recorder.durationMs() < 300; ++i) pump(50);
REQUIRE(recorder.durationMs() >= 300 && manager->pcmCursor(pcmId) > 0);
QFile routesReady(control + "/routes_ready"); REQUIRE(routesReady.open(QIODevice::WriteOnly)); routesReady.close();
for (int i = 0; i < 100 && !QFile::exists(control + "/moved"); ++i) pump(50);
REQUIRE(QFile::exists(control + "/moved"));
pump(500);
int outputs = 0;
for (auto* device : devices) if (device->type == ma_device_type_playback) ++outputs;
REQUIRE(outputs == 1); // Сервер переместил default stream на voice sink: получился один микшер.
qInfo("PulseAudio route move: call/radio/media merged into one output PASS");
for (auto* device : devices) if (device->type == ma_device_type_playback) REQUIRE(ma_device_stop(device) == MA_SUCCESS);
pump(1000); outputs = 0;
uint64_t wantedRoute = UINT64_C(14695981039346656037);
for (const char* name = "utun_test_voice"; *name; ++name) { wantedRoute ^= (unsigned char)*name; wantedRoute *= UINT64_C(1099511628211); }
bool foundWanted = false;
for (auto* device : devices) if (device->type == ma_device_type_playback) {
++outputs;
if (static_cast<AudioDevice*>(device->pUserData)->route() == wantedRoute) foundWanted = true;
}
REQUIRE(foundWanted && outputs >= 1 && outputs <= 2); // Сервер может восстановить прежний move для default stream.
qInfo("PulseAudio output recovery: desired call route preserved outputs=%d PASS", outputs);
int duration = recorder.durationMs();
auto cursor = manager->pcmCursor(pcmId);
QFile ready(control + "/ready"); REQUIRE(ready.open(QIODevice::WriteOnly)); ready.close();
@@ -95,9 +124,12 @@ static void pulse_recovery(SoundManager* manager, const QString& control) {
QFile closed(control + "/closed"); REQUIRE(closed.open(QIODevice::WriteOnly)); closed.close();
for (int i = 0; i < 150 && (!manager->context() || recorder.durationMs() <= duration + 200); ++i) pump(50);
REQUIRE(manager->context() && recorder.durationMs() > duration + 200 && manager->pcmCursor(pcmId) > cursor);
REQUIRE(call->isActive() && call->callId() == 42 && radio->isActive() && radio->groupId() == 7);
qInfo("PulseAudio progress: capture=%d -> %dms playback=%llu -> %llu frames", duration, recorder.durationMs(),
(unsigned long long)cursor, (unsigned long long)manager->pcmCursor(pcmId));
recorder.stopRecording(); manager->shutdown();
call->stop(); radio->stop(); recorder.stopRecording();
call_audio_destroy(&instance); radio_audio_destroy(&instance); uasync_destroy(instance.ua, 0); instance.ua = nullptr;
manager->shutdown();
REQUIRE(devices.empty());
qInfo("real PulseAudio: capture/playback progress restored after server restart PASS");
}
@@ -144,6 +176,12 @@ int main(int argc, char** argv) {
REQUIRE(a.pcm[0] == 1000 && b.pcm[0] == 1000); // pUserData маршрутизирует каждый callback своему recorder.
a.pcm_release(a.pcm_owner); b.pcm_release(b.pcm_owner);
}
{
AudioRecorder meter; meter.init(); meter.startRecording(true); pump(60); meter.stopRecording();
attachment_send_req request{};
REQUIRE(meter.durationMs() > 0 && !meter.takeRecording(&request));
REQUIRE(!request.pcm && !request.pcm_owner && !request.compressor);
}
instance.ua = uasync_create(); REQUIRE(instance.ua);
REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0);
auto* call = CallAudioEngine::instance();
@@ -151,7 +189,7 @@ int main(int argc, char** argv) {
pump(60);
ma_device* playback = nullptr;
for (auto* device : devices)
if (device->type == ma_device_type_playback && device->playback.format == ma_format_s16 && device->playback.channels == 1)
if (device->type == ma_device_type_playback && device->playback.format == ma_format_f32 && device->playback.channels == 2)
playback = device;
REQUIRE(playback);
int previousCapture = captureStarts, previousPlayback = playbackStarts;
@@ -203,10 +241,22 @@ int main(int argc, char** argv) {
manager->pausePcm(player24);
cursor24 = manager->pcmCursor(player24);
pump(80);
REQUIRE(manager->pcmCursor(player24) == cursor24 && manager->pcmCursor(player48) > cursor48);
uint64_t pausedAfter = manager->pcmCursor(player24), neighbourAfter = manager->pcmCursor(player48);
qInfo("PCM pause: paused=%llu->%llu neighbour=%llu->%llu", (unsigned long long)cursor24, (unsigned long long)pausedAfter,
(unsigned long long)cursor48, (unsigned long long)neighbourAfter);
REQUIRE(pausedAfter == cursor24 && neighbourAfter > cursor48);
REQUIRE(manager->seekPcm(player24, 12000));
REQUIRE(manager->pcmCursor(player24) == 12000 && !manager->isPcmPlaying(player24));
manager->resumePcm(player24);
// Пауза синхронизируется с уже начатым чтением графа, сосед продолжает играть.
for (int i = 0; i < 50; ++i) {
pump(1);
manager->pausePcm(player24);
uint64_t paused = manager->pcmCursor(player24);
pump(1);
REQUIRE(manager->pcmCursor(player24) == paused);
manager->resumePcm(player24);
}
manager->stopPcm(player48);
pump(60);
REQUIRE(manager->pcmCursor(player24) > 12000 && manager->pcmCursor(player48) == 0);
+57
View File
@@ -0,0 +1,57 @@
/* PA snapshot без сервера: несколько converter callbacks принадлежат одному physical PCM-блоку. */
#define MA_DATA_CONVERTER_STACK_BUFFER_SIZE 128
#include "../transport/miniaudio_impl.c"
#include <assert.h>
#include <stdio.h>
static int64_t times[64];
static unsigned counts[64], seen;
static int capture_missing;
static int fake_latency(const void* stream, uint64_t* latency, int* negative) {
(void)stream; *latency = 100000; *negative = 0; return 0;
}
static void observe(ma_device* device, void* output, const void* input, ma_uint32 frames) {
(void)output; (void)input;
int measured = 0;
assert(seen < 64);
times[seen] = audio_stream_frame_time(device, frames, &measured);
counts[seen++] = frames;
assert(measured && seen < 64);
assert(audio_stream_capture_missing(device) == capture_missing);
}
static int64_t time_us(void) {
struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now);
return now.tv_sec * INT64_C(1000000) + now.tv_nsec / 1000;
}
int main(void) {
/* Только dispatch/converter: backend union не подменяется у живого null-device. */
ma_context context = {0}; ma_device device = {0};
device.pContext = &context; device.type = ma_device_type_capture;
device.sampleRate = 48000; device.noFixedSizedCallback = MA_TRUE; device.onData = observe;
device.capture.format = ma_format_s16; device.capture.channels = 1;
device.capture.internalFormat = ma_format_f32; device.capture.internalChannels = 2;
assert(ma_device_set_master_volume(&device, 1) == MA_SUCCESS);
ma_data_converter_config converter = ma_data_converter_config_init(ma_format_f32, ma_format_s16, 2, 1, 48000, 48000);
assert(ma_data_converter_init(&converter, NULL, &device.capture.converter) == MA_SUCCESS);
device.capture.internalSampleRate = 48000;
context.backend = ma_backend_pulseaudio;
context.pulse.pa_stream_get_latency = (ma_proc)fake_latency;
device.pulse.pStreamCapture = (void*)1;
float input[180 * 2] = {0};
int64_t now = time_us();
assert(ma_device_handle_backend_data_callback(&device, NULL, input, 180) == MA_SUCCESS);
int64_t after = time_us();
assert(seen == 3 && counts[0] == 64 && counts[1] == 64 && counts[2] == 52);
assert(times[0] >= now - 100000 && times[0] <= after - 100000);
for (unsigned i = 1; i < seen; ++i) {
int64_t difference = times[i] - times[0] - (int64_t)i * 64 * 1000000 / 48000;
assert(difference >= -1 && difference <= 1);
}
capture_missing = 1; seen = 0; audio_capture_hole(120);
now = time_us();
assert(ma_device_handle_backend_data_callback(&device, NULL, input, 64) == MA_SUCCESS);
after = time_us();
assert(seen == 1 && times[0] >= now - 100000 + 2500 && times[0] <= after - 100000 + 2500);
assert(!audio_stream_capture_missing(&device)); // Hint действует только внутри dispatch.
ma_data_converter_uninit(&device.capture.converter, NULL);
puts("audio timing: capture read index, converter offsets, explicit hole PASS");
}
+106
View File
@@ -0,0 +1,106 @@
/* Настоящие workers/AEC; граница сервисного PCM заменена наблюдателями без сети/устройства. */
#include "../src/voice_audio_io.h"
extern "C" {
#include "call/call_audio.h"
#include "radio/radio_audio.h"
#include "debug_config.h"
}
#include <vector>
#include <array>
#include <chrono>
#include <thread>
#include <mutex>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <algorithm>
#define REQUIRE(x) do { if (!(x)) { fprintf(stderr, "voice-io:%d %s\n", __LINE__, #x); abort(); } } while (0)
static std::mutex mutex;
static std::vector<int16_t> capture, transmitted;
static std::vector<uint8_t> masks;
static bool talking;
static std::atomic<int> pulls{0}, blocks{0};
static std::atomic<bool> stall{false}, stalled{false};
extern "C" int call_audio_pull_pcm(uint64_t, int16_t* out, int count) { ++pulls; std::fill_n(out, count, 1000); return count; }
extern "C" int radio_audio_pull_pcm(uint64_t, int16_t* out, int count) {
++pulls; for (int i = 0; i < count; ++i) out[i] = i % 2 ? 2000 : 1000; return count;
}
extern "C" void call_audio_reset_io(uint64_t, int) {}
extern "C" void radio_audio_reset_playback(uint64_t) {}
extern "C" void radio_audio_capture_discontinuity(uint64_t) {}
extern "C" void radio_audio_talk_begin(uint64_t) { std::lock_guard lock(mutex); talking = true; }
extern "C" void radio_audio_talk_end(uint64_t) { std::lock_guard lock(mutex); talking = false; }
extern "C" int radio_audio_feed_pcm(uint64_t, const int16_t* pcm, int count) {
if (stall) { stalled = true; while (stall) std::this_thread::sleep_for(std::chrono::milliseconds(1)); }
std::lock_guard lock(mutex);
capture.insert(capture.end(), pcm, pcm + count);
if (talking) transmitted.insert(transmitted.end(), pcm, pcm + count);
++blocks; return 0;
}
extern "C" int call_audio_feed_prepared_pcm(uint64_t, const int16_t* pcm, const uint8_t* mask) {
std::lock_guard lock(mutex);
capture.insert(capture.end(), pcm, pcm + 960); masks.insert(masks.end(), mask, mask + 960);
++blocks; return 0;
}
static int64_t now_us() {
return std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
template<class F> static void await(F ready) {
for (int i = 0; i < 2000 && !ready(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(1));
REQUIRE(ready());
}
static void reset() { std::lock_guard lock(mutex); capture.clear(); transmitted.clear(); masks.clear(); talking = false; blocks = 0; }
int main() {
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
VoiceAudioIo voice;
std::array<int16_t, 960> pcm{}; pcm.fill(1234);
voice.start(VoiceAudioIo::Kind::Radio, 7); REQUIRE(voice.startCapture(1, false));
voice.capture(pcm.data(), 240, 0); voice.setPtt(true);
voice.capture(pcm.data(), 360, 0); voice.setPtt(false); voice.capture(pcm.data(), 360, 0);
await([] { return blocks.load() >= 3; }); voice.stopCapture();
REQUIRE(capture.size() == 960 && transmitted.size() == 360 && !talking);
reset(); REQUIRE(voice.startCapture(1, false));
voice.setPtt(true); voice.capture(pcm.data(), 120, 0); voice.setPtt(false); voice.stopCapture();
REQUIRE(capture.size() == 120 && transmitted.size() == 120 && !talking);
reset(); REQUIRE(voice.startCapture(1, false)); stall = true;
voice.capture(pcm.data(), 960, 0); await([] { return stalled.load(); });
int before = pulls;
for (int i = 0; i < 10; ++i) {
float output[1920]{}; voice.mix(output, 960);
std::this_thread::sleep_for(std::chrono::milliseconds(3));
}
REQUIRE(pulls > before + 3); stall = false; voice.stopCapture(); voice.stop();
reset(); voice.start(VoiceAudioIo::Kind::Call, 42); REQUIRE(voice.startCapture(1, false));
voice.capture(pcm.data(), 480, 0); voice.setMuted(true); voice.capture(pcm.data(), 480, 0);
await([] { return blocks.load() == 1; }); voice.stopCapture();
REQUIRE(masks.size() == 960);
for (int i = 0; i < 960; ++i) REQUIRE(masks[i] == (i >= 480) && capture[i] == 1234);
voice.stop(); reset();
voice.start(VoiceAudioIo::Kind::Call, 42); REQUIRE(voice.startCapture(1, false));
voice.capture(pcm.data(), 960, now_us() - 300000); // Backend latency не является возрастом worker-очереди.
await([] { return blocks.load() == 1; }); voice.stopCapture(); voice.stop();
REQUIRE(capture.size() == 960); reset();
// Референс противофазный: mono downmix дал бы ноль и не смог подавить эхо.
voice.start(VoiceAudioIo::Kind::Call, 43); REQUIRE(voice.startCapture(1, true));
uint32_t rng = 1234567;
double echoPower = 0, outputPower = 0;
int64_t time = now_us();
for (int frame = 0; frame < 240; ++frame) {
float reference[1920];
for (int i = 0; i < 960; ++i) {
rng = rng * 1664525u + 1013904223u;
int16_t sample = (int16_t)((rng >> 18) - 8192);
reference[2*i] = sample / 32768.0f; reference[2*i+1] = -reference[2*i];
pcm[i] = sample / 2;
if (frame >= 180) echoPower += (double)pcm[i] * pcm[i];
}
voice.reference(reference, 960, time + frame * 20000);
voice.capture(pcm.data(), 960, time + frame * 20000);
await([&] { return blocks.load() == frame + 1; });
}
voice.stopCapture(); voice.stop();
for (size_t i = 180 * 960; i < capture.size(); ++i) outputPower += (double)capture[i] * capture[i];
printf("stereo reference residual ratio=%.6f\n", outputPower / echoPower);
REQUIRE(outputPower < echoPower * 0.1);
puts("voice workers: exact PTT/mute boundaries, short tail, independent RX, stereo AEC PASS");
}
+143
View File
@@ -0,0 +1,143 @@
/* Реальные codec/AGC/автоматы/очередь uasync; наблюдаем сетевую границу вместо отправки пакетов. */
extern "C" {
#include "utun_instance.h"
#include "radio/radio_audio.h"
#include "call/call_audio.h"
#include "opus_codec.h"
#include "debug_config.h"
#include "u_async.h"
#include "silero_vad.h"
}
#include <vector>
#include <array>
#include <cstring>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <algorithm>
#include <atomic>
#include <thread>
#include <chrono>
#define REQUIRE(x) do { if (!(x)) { fprintf(stderr, "voice-tx:%d %s\n", __LINE__, #x); abort(); } } while (0)
static std::vector<char> events;
static std::vector<std::vector<uint8_t>> packets;
static float probability;
static int vad_windows;
static std::atomic<bool> stall_vad{false}, vad_stalled{false};
extern "C" silero_vad_t* __wrap_silero_vad_create_default() { return reinterpret_cast<silero_vad_t*>(1); }
extern "C" void __wrap_silero_vad_destroy(silero_vad_t*) {}
extern "C" void __wrap_silero_vad_reset(silero_vad_t*) {}
extern "C" int __wrap_silero_vad_process(silero_vad_t*, const float*, float* result) {
if (stall_vad) { vad_stalled = true; while (stall_vad) std::this_thread::sleep_for(std::chrono::milliseconds(1)); }
++vad_windows; *result = probability; return 0;
}
extern "C" int __wrap_radio_talk_begin(UTUN_INSTANCE*, uint64_t) { events.push_back('B'); return 0; }
extern "C" int __wrap_radio_talk_end(UTUN_INSTANCE*, uint64_t) { events.push_back('F'); return 0; }
extern "C" int __wrap_radio_talk_send(UTUN_INSTANCE*, uint64_t, const uint8_t* data, int len) {
events.push_back('M'); packets.emplace_back(data, data + len); return 0;
}
extern "C" int __wrap_call_send_media(UTUN_INSTANCE*, uint64_t, const uint8_t* data, int len) {
packets.emplace_back(data, data + len); return 0;
}
extern "C" int __real_chat_setting_get_int(UTUN_INSTANCE*, const char*, int);
extern "C" int __wrap_chat_setting_get_int(UTUN_INSTANCE* inst, const char* key, int fallback) {
if (!strcmp(key, "radio_compressor_enabled") || !strcmp(key, "call_compressor_enabled")) return 0;
if (!strcmp(key, "radio_vad_hangover_ms")) return 0;
return __real_chat_setting_get_int(inst, key, fallback);
}
static void check_packet(opus_codec_encoder_t* encoder, const int16_t* pcm, size_t index) {
uint8_t data[1024]; int size = opus_codec_encode(encoder, pcm, 960, data, sizeof(data));
REQUIRE(size > 0 && index < packets.size());
REQUIRE(packets[index].size() == (size_t)size && !memcmp(packets[index].data(), data, size));
}
int main() {
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
UTUN_INSTANCE instance{}; instance.ua = uasync_create(); REQUIRE(instance.ua);
REQUIRE(radio_audio_init(&instance) == 0 && call_audio_init(&instance) == 0);
REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, 1, 0) == 0);
std::array<int16_t, 1920> input{};
for (int i = 0; i < 1920; ++i) input[i] = (int16_t)(12000 * sin(i * 0.1));
radio_audio_talk_begin(7); radio_audio_talk_begin(7);
REQUIRE(radio_audio_feed_pcm(7, input.data(), 1200) == 0); radio_audio_talk_end(7);
radio_audio_talk_begin(7); REQUIRE(radio_audio_feed_pcm(7, input.data() + 1200, 120) == 0); radio_audio_talk_end(7);
uasync_poll(instance.ua, 0);
REQUIRE(events == std::vector<char>({'B','M','M','F','B','M','F'}));
auto* encoder = opus_codec_encoder_create(48000, 1); REQUIRE(encoder);
check_packet(encoder, input.data(), 0);
std::array<int16_t, 960> tail{}; memcpy(tail.data(), input.data() + 960, 240 * sizeof(int16_t));
check_packet(encoder, tail.data(), 1);
tail.fill(0); memcpy(tail.data(), input.data() + 1200, 120 * sizeof(int16_t)); check_packet(encoder, tail.data(), 2);
// Разрыв не склеивает отсчёты до и после потери внутри одного Opus-кадра.
packets.clear(); events.clear();
radio_audio_talk_begin(7); radio_audio_feed_pcm(7, input.data(), 100);
radio_audio_capture_discontinuity(7); radio_audio_feed_pcm(7, input.data() + 100, 100); radio_audio_talk_end(7);
uasync_poll(instance.ua, 0);
REQUIRE(events == std::vector<char>({'B','M','M','F'}));
tail.fill(0); memcpy(tail.data(), input.data(), 100 * sizeof(int16_t)); check_packet(encoder, tail.data(), 0);
tail.fill(0); memcpy(tail.data(), input.data() + 100, 100 * sizeof(int16_t)); check_packet(encoder, tail.data(), 1);
opus_codec_encoder_destroy(encoder);
// Capture teardown завершает принятую серию до FIN; следующее capture не получает старый хвост.
packets.clear(); events.clear();
radio_audio_talk_begin(7); radio_audio_feed_pcm(7, input.data(), 120); radio_audio_capture_stop();
REQUIRE(radio_audio_capture_start(7, 1, 0) == 0);
radio_audio_talk_begin(7); radio_audio_feed_pcm(7, input.data() + 120, 120); radio_audio_talk_end(7);
uasync_poll(instance.ua, 0);
REQUIRE(events == std::vector<char>({'B','M','F','B','M','F'}));
// Старое прослушивание того же group ID отменено: старые BEGIN/media не попадают в новую сессию.
packets.clear(); events.clear();
radio_audio_talk_begin(7); radio_audio_feed_pcm(7, input.data(), 120); radio_audio_stop();
REQUIRE(radio_audio_start(7, 1) == 0);
uasync_poll(instance.ua, 0);
REQUIRE(events.empty() && packets.empty());
// У остановленного core ограничены не только media, но и быстрые PTT-команды.
for (int i = 0; i < 1000; ++i) {
radio_audio_talk_begin(7); radio_audio_feed_pcm(7, input.data(), 120); radio_audio_talk_end(7);
}
int pending = 0;
for (auto* task = instance.ua->posted_tasks_head; task; task = task->next) ++pending;
REQUIRE(pending <= 24); // 16 control + 8 media, FIN каждой принятой серии уже зарезервирован.
uasync_poll(instance.ua, 0);
packets.clear(); events.clear(); radio_audio_capture_stop();
REQUIRE(radio_audio_capture_start(7, 1, 1) == 0 && radio_audio_vad_mode());
auto feed = [&](int frames) {
while (frames) { int count = std::min(frames, 1920); REQUIRE(radio_audio_feed_pcm(7, input.data(), count) == 0); frames -= count; }
};
probability = 0.9f; radio_audio_talk_begin(7); feed(3072); // Два окна VAD даже во время manual PTT.
REQUIRE(vad_windows == 2); radio_audio_talk_end(7); REQUIRE(radio_audio_transmitting());
probability = 0; feed(3072); REQUIRE(!radio_audio_transmitting()); // Первое окно начинает silence, второе завершает hangover.
uasync_poll(instance.ua, 0);
REQUIRE(std::count(events.begin(), events.end(), 'B') == 1 && std::count(events.begin(), events.end(), 'F') == 1);
packets.clear(); events.clear();
probability = 0.9f; feed(3072); REQUIRE(radio_audio_transmitting());
radio_audio_talk_begin(7); probability = 0; feed(3072); REQUIRE(radio_audio_transmitting());
radio_audio_talk_end(7); uasync_poll(instance.ua, 0);
REQUIRE(std::count(events.begin(), events.end(), 'B') == 1 && std::count(events.begin(), events.end(), 'F') == 1);
stall_vad = true;
std::thread tx([&] { feed(1536); });
for (int i = 0; i < 200 && !vad_stalled; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(1));
REQUIRE(vad_stalled);
std::atomic<bool> rx_finished{false};
std::thread rx([&] { int16_t out[1920]; REQUIRE(radio_audio_pull_pcm(7, out, 1920) == 1920); rx_finished = true; });
for (int i = 0; i < 200 && !rx_finished; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(1));
REQUIRE(rx_finished); stall_vad = false; tx.join(); rx.join();
radio_audio_stop(); uasync_poll(instance.ua, 0);
// Старый FIN не обращается к новому ядру, даже когда allocator повторно использовал тот же адрес.
REQUIRE(radio_audio_start(7, 1) == 0);
radio_audio_talk_begin(7); uasync_poll(instance.ua, 0);
radio_audio_talk_end(7); radio_audio_stop(); radio_audio_destroy(&instance);
REQUIRE(radio_audio_init(&instance) == 0);
events.clear(); uasync_poll(instance.ua, 0); REQUIRE(events.empty());
// Prepared policy исключает повторный core AEC; mute применяется только к нужной части кадра.
packets.clear(); REQUIRE(call_audio_start_prepared(42) == 0);
REQUIRE(call_audio_start(42) == -1);
REQUIRE(call_audio_feed_pcm(42, input.data(), 960) == -1);
std::array<uint8_t, 960> mask{};
std::fill(mask.begin() + 300, mask.end(), 1);
REQUIRE(call_audio_feed_prepared_pcm(42, input.data(), mask.data()) == 0);
uasync_poll(instance.ua, 0);
encoder = opus_codec_encoder_create(48000, 1); REQUIRE(encoder);
tail.fill(0); memcpy(tail.data(), input.data(), 300 * sizeof(int16_t)); check_packet(encoder, tail.data(), 0);
call_audio_stop(); opus_codec_encoder_destroy(encoder);
call_audio_destroy(&instance); radio_audio_destroy(&instance); uasync_destroy(instance.ua, 0);
puts("voice TX: exact Opus tail, rapid bursts, discontinuity, capture policy, final mute PASS");
}
+119
View File
@@ -1,7 +1,14 @@
#include "audio_diagnostics.h"
#ifdef __linux__
#include <dlfcn.h>
#include <time.h>
static void audio_diag_pulse_init(void* device);
static void audio_timing_begin(void* device, unsigned int frames, int capture);
static void audio_timing_end(void);
static void audio_capture_hole(uint64_t offset);
#define MA_AUDIO_TIMING_BEGIN(device, frames, capture) audio_timing_begin(device, frames, capture)
#define MA_AUDIO_TIMING_END() audio_timing_end()
#define MA_AUDIO_CAPTURE_HOLE(offset) audio_capture_hole(offset)
#define MA_DIAGNOSTIC_EVENT(device, kind, stage, value, detail) audio_diag_event(device, kind, stage, value, detail)
#define MA_DIAGNOSTIC_LOG(log, level, message) audio_diag_log(log, level, message)
#define MA_DIAGNOSTIC_DEVICE_INIT(device) audio_diag_pulse_init(device)
@@ -69,3 +76,115 @@ int audio_context_failed(ma_context* context) {
#endif
return 0;
}
/* Только device callback того же PA mainloop: запрос использует готовый timing snapshot без roundtrip. */
int64_t audio_stream_latency_us(ma_device* device, int capture, int* valid) {
*valid = 0;
#ifdef MA_SUPPORT_PULSEAUDIO
if (device && device->pContext->backend == ma_backend_pulseaudio && device->pContext->pulse.pa_stream_get_latency) {
ma_pa_stream* stream = capture ? device->pulse.pStreamCapture : device->pulse.pStreamPlayback;
ma_uint64 latency = 0; int negative = 0;
if (stream && ((ma_pa_stream_get_latency_proc)device->pContext->pulse.pa_stream_get_latency)(stream, &latency, &negative) == 0) {
*valid = 1;
return negative ? -(int64_t)latency : (int64_t)latency;
}
}
#endif
return 0;
}
/* PA device name читается в его mainloop; GUI получает только непрозрачный идентификатор маршрута. */
uint64_t audio_stream_route(ma_device* device, int capture) {
#ifdef MA_SUPPORT_PULSEAUDIO
if (device && device->pContext->backend == ma_backend_pulseaudio) {
ma_pa_stream* stream = capture ? device->pulse.pStreamCapture : device->pulse.pStreamPlayback;
const char* name = stream ? ((ma_pa_stream_get_device_name_proc)device->pContext->pulse.pa_stream_get_device_name)(stream) : NULL;
if (name && *name) {
uint64_t hash = UINT64_C(14695981039346656037);
for (; *name; ++name) { hash ^= (unsigned char)*name; hash *= UINT64_C(1099511628211); }
return hash;
}
}
#else
(void)device; (void)capture;
#endif
return 0;
}
/* Reporter копирует сообщение в bounded очередь; DEBUG/file I/O в аппаратном потоке не выполняются. */
void audio_stream_log_route(ma_device* device, int capture) {
#ifdef MA_SUPPORT_PULSEAUDIO
if (device && device->pContext->backend == ma_backend_pulseaudio) {
ma_pa_stream* stream = capture ? device->pulse.pStreamCapture : device->pulse.pStreamPlayback;
const char* name = stream ? ((ma_pa_stream_get_device_name_proc)device->pContext->pulse.pa_stream_get_device_name)(stream) : NULL;
if (name) {
char message[320];
snprintf(message, sizeof(message), "PulseAudio physical %s route: %.255s", capture ? "capture" : "playback", name);
audio_diag_log(ma_device_get_log(device), MA_LOG_LEVEL_INFO, message);
}
}
#else
(void)device; (void)capture;
#endif
}
#ifdef __linux__
/* Один backend dispatch может породить несколько client callbacks при конвертации.
* Начало snapshot фиксируется один раз; offset растёт по фактически выданному client PCM. */
static _Thread_local struct {
ma_device* device;
int valid;
int64_t first_us;
uint64_t frames;
uint64_t hole_offset;
int missing, hole_pending;
} audio_timing;
static void audio_timing_begin(void* opaque, unsigned int frames, int capture) {
ma_device* device = opaque;
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
int valid = 0;
int64_t latency = audio_stream_latency_us(device, capture, &valid);
ma_uint32 rate = capture ? device->capture.internalSampleRate : device->playback.internalSampleRate;
ma_uint64 converter_delay = capture ? ma_data_converter_get_output_latency(&device->capture.converter)
: ma_data_converter_get_input_latency(&device->playback.converter);
audio_timing.device = device;
audio_timing.missing = audio_timing.hole_pending;
audio_timing.valid = valid && device->noFixedSizedCallback;
int64_t time = now.tv_sec * INT64_C(1000000) + now.tv_nsec / 1000;
if (capture) {
time -= valid ? latency : (int64_t)frames * 1000000 / rate;
time -= (int64_t)converter_delay * 1000000 / device->sampleRate;
if (audio_timing.missing) time += (int64_t)audio_timing.hole_offset * 1000000 / rate;
} else {
time += latency + (int64_t)(converter_delay + device->playback.inputCacheRemaining) * 1000000 / device->sampleRate;
}
audio_timing.first_us = time; audio_timing.frames = 0;
audio_timing.hole_pending = 0;
}
static void audio_timing_end(void) { audio_timing.device = NULL; }
static void audio_capture_hole(uint64_t offset) { audio_timing.hole_pending = 1; audio_timing.hole_offset = offset; }
#endif
int64_t audio_stream_frame_time(ma_device* device, unsigned int frames, int* valid) {
*valid = 0;
#ifdef __linux__
if (audio_timing.device == device) {
int64_t time = audio_timing.first_us + (int64_t)audio_timing.frames * 1000000 / device->sampleRate;
audio_timing.frames += frames; *valid = audio_timing.valid;
return time;
}
#else
(void)device; (void)frames;
#endif
return 0;
}
int audio_stream_capture_missing(ma_device* device) {
#ifdef __linux__
return audio_timing.device == device && audio_timing.missing;
#else
(void)device; return 0;
#endif
}