| 65 | } |
| 66 | |
| 67 | bool PipeWireNativeRxSource::open() |
| 68 | { |
| 69 | if (m_stream) { |
| 70 | return true; |
| 71 | } |
| 72 | |
| 73 | auto& ctx = PipeWireNativeContext::instance(); |
| 74 | if (!ctx.acquire()) { |
| 75 | qCWarning(lcDax) << "PipeWireNativeRxSource: failed to acquire context for ch" << m_channel; |
| 76 | return false; |
| 77 | } |
| 78 | m_contextAcquired = true; |
| 79 | |
| 80 | const QByteArray nodeName = QString("aethersdr-dax-%1").arg(m_channel).toUtf8(); |
| 81 | const QByteArray nodeDesc = QString("AetherSDR DAX %1").arg(m_channel).toUtf8(); |
| 82 | |
| 83 | // Latency strategy: |
| 84 | // node.latency — the *request*: 256-sample quantum (~5.3 ms @ 48 kHz) |
| 85 | // node.force-quantum — *forces* the graph cycle to 256 samples while our |
| 86 | // node is active. Without this, any other client |
| 87 | // (e.g. WSJT-X's Qt PulseAudio backend) requesting |
| 88 | // a longer buffer drags the negotiated graph |
| 89 | // quantum up, undoing our latency hint. |
| 90 | // node.force-rate — pin graph rate to 48 kHz so it can't fall back to |
| 91 | // a slower clock during negotiation. |
| 92 | // node.always-process— keep draining the ring even when no client is |
| 93 | // connected, so we don't accumulate backlog |
| 94 | // between "DAX enabled" and "WSJT-X connected". |
| 95 | // pulse.min.{req,frag,quantum} |
| 96 | // — clamp the PipeWire pulse-compat fragment pool |
| 97 | // that PulseAudio-API clients sit behind. This |
| 98 | // is the hidden ~200 ms buffer Qt's PulseAudio |
| 99 | // backend negotiates by default (4–8 fragments |
| 100 | // × 50 ms each). PipeWire's pulse module reads |
| 101 | // these source-side properties when sizing each |
| 102 | // capturing pulse client's ring, so any client |
| 103 | // (WSJT-X, fldigi, …) connecting to us inherits |
| 104 | // the small 256-sample fragment cap regardless |
| 105 | // of what its own backend requested. Confirmed |
| 106 | // via pw-cat (native PipeWire client at 5.3 ms) |
| 107 | // vs QtPulseAudio:<pid> (50 ms quantum + 200 ms |
| 108 | // pulse fragment buffer). |
| 109 | pw_properties* props = pw_properties_new( |
| 110 | PW_KEY_MEDIA_TYPE, "Audio", |
| 111 | PW_KEY_MEDIA_CATEGORY, "Playback", // we play audio INTO the graph; clients capture from us |
| 112 | PW_KEY_MEDIA_CLASS, "Audio/Source", |
| 113 | PW_KEY_MEDIA_ROLE, "Production", |
| 114 | PW_KEY_NODE_NAME, nodeName.constData(), |
| 115 | PW_KEY_NODE_DESCRIPTION, nodeDesc.constData(), |
| 116 | PW_KEY_NODE_LATENCY, "256/48000", |
| 117 | PW_KEY_NODE_RATE, "1/48000", |
| 118 | PW_KEY_NODE_FORCE_QUANTUM, "256", |
| 119 | PW_KEY_NODE_FORCE_RATE, "48000", |
| 120 | PW_KEY_NODE_ALWAYS_PROCESS, "true", |
| 121 | "pulse.min.req", "256/48000", |
| 122 | "pulse.default.req", "256/48000", |
| 123 | "pulse.min.frag", "256/48000", |
| 124 | "pulse.default.frag", "256/48000", |