Раздели аудиопотоки и привяжи AEC к фактическому общему выходу
This commit is contained in:
1 parent
d7fb0c83f9
commit
bd082a26bf
30 files changed
+1839
-412
No files matched your search
@@ -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})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user