Compare commits

..
Author SHA1 Message Date
molluskandClaude Opus 5 9f3d0ac2ea feat(probe): give audio_probe a Windows/cpal arm
The probe drove PipeWire directly and stubbed out everywhere else, so the
Windows port had no way to isolate the local output path. win_probe mirrors
unix_probe against CpalBackend: same phase-continuous sine, same fill-paced
production against PLAYBACK_TARGET_SAMPLES, same playout-health log tailer.

NOT COMPILE-VERIFIED. The new module is behind #[cfg(windows)], so a Linux
host never type-checks it; it needs a cargo check on a Windows target (or the
mingw cross target) before it is trusted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:22:50 -04:00
8 changed files with 157 additions and 1286 deletions
+2 -2
View File
@@ -68,9 +68,9 @@ connections are expected and valid.
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. |
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. |
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
| Resampling/device format | Cross-compiled. cpal/WASAPI now chooses native 48 kHz when available and otherwise resamples/remaps at the device boundary; needs real Windows hardware audio verification. |
| Resampling/device format | Open. Devices must support 48 kHz, and output must support stereo; a 44.1 kHz-only/default device currently errors instead of playing. |
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
| Playback pacing | Open. The fixed playback target under WASAPI shared mode still needs real-hardware verification. |
Before calling Windows support done, verify a real Windows machine can create/join a room,
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
-112
View File
@@ -1,112 +0,0 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
#
# Test-pack split package: ONE `makepkg -si` builds + installs BOTH peerspeak
# (voice chat) and pixelpass (screen sharing) from the public gitbutter repos
# over https. pixelpass lands on /usr/bin so peerspeak's screen-share button
# finds it. Shared version string is derived from peerspeak's git.
#
# Clone this repo and build from here:
# git clone https://gitbutter.xyz/mollusk/peerspeak.git
# cd peerspeak/packaging/test-pack
# makepkg -si
pkgbase=peerspeak-git
pkgname=('peerspeak-git' 'pixelpass')
pkgver=0.1.0
pkgrel=1
arch=('x86_64')
url="https://gitbutter.xyz/mollusk/peerspeak"
license=('custom' 'MIT' 'Apache-2.0' 'OFL-1.1')
makedepends=('git' 'cargo' 'pkgconf')
options=('!lto' '!debug')
source=("peerspeak::git+https://gitbutter.xyz/mollusk/peerspeak.git"
"pixelpass::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=main")
sha256sums=('SKIP'
'SKIP')
pkgver() {
cd "$srcdir/peerspeak"
# Shared across both split packages. 0.1.0.r<commits>.g<short-sha>.
printf '%s.r%s.g%s' \
"$(awk -F'\"' '/^version =/{print $2; exit}' Cargo.toml)" \
"$(git rev-list --count HEAD)" \
"$(git rev-parse --short HEAD)"
}
prepare() {
# Vendor deps up front so build() can run --frozen (no surprise network).
export CARGO_HOME="$srcdir/cargo-home"
local host; host="$(rustc -vV | sed -n 's/host: //p')"
cd "$srcdir/peerspeak"; cargo fetch --locked --target "$host"
cd "$srcdir/pixelpass"; cargo fetch --locked --target "$host"
}
build() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cd "$srcdir/peerspeak"
cargo build --frozen --release --bin peerspeak
cd "$srcdir/pixelpass"
# --features gui so the .desktop launcher (pixelpass --gui) works.
cargo build --frozen --release --features gui
}
check() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
# peerspeak library unit tests only — its integration suites bind real
# iroh/QUIC endpoints and fail in a sandboxed/offline build environment.
cd "$srcdir/peerspeak"
cargo test --frozen --release --lib
}
package_peerspeak-git() {
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
depends=('pipewire' 'opus')
optdepends=('pixelpass: screen sharing inside a room'
'mpv: screen-share viewer (vlc is used as a fallback)')
provides=('peerspeak')
conflicts=('peerspeak')
license=('custom')
cd "$srcdir/peerspeak"
install -Dm755 "target/release/peerspeak" "$pkgdir/usr/bin/peerspeak"
install -Dm644 "packaging/peerspeak.desktop" \
"$pkgdir/usr/share/applications/peerspeak.desktop"
# Hicolor icon theme (scalable SVG + the rendered raster sizes).
install -Dm644 "assets/icons/peerspeak.svg" \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/peerspeak.svg"
local s
for s in 16 24 32 48 64 128 256 512; do
install -Dm644 "assets/icons/peerspeak-$s.png" \
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/peerspeak.png"
done
}
package_pixelpass() {
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
depends=('gstreamer' 'gst-plugins-base' 'gst-plugins-good' 'gst-plugins-bad'
'gst-libav' 'gst-plugin-va' 'libpulse' 'hicolor-icon-theme'
'libglvnd' 'libxkbcommon' 'wayland')
optdepends=('mpv: recommended stream viewer (the GUI launches mpv)'
'vlc: alternative stream viewer'
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
'gst-plugin-pipewire: screen capture on Wayland sessions'
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)')
license=('MIT' 'Apache-2.0' 'OFL-1.1')
cd "$srcdir/pixelpass"
install -Dm0755 "target/release/pixelpass" "$pkgdir/usr/bin/pixelpass"
install -Dm0644 assets/pixelpass.desktop \
"$pkgdir/usr/share/applications/pixelpass.desktop"
install -Dm0644 assets/pixelpass.svg \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/pixelpass.svg"
install -Dm0644 README.md "$pkgdir/usr/share/doc/pixelpass/README.md"
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/pixelpass/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/pixelpass/LICENSE-APACHE"
install -Dm0644 assets/NotoSans-OFL.txt \
"$pkgdir/usr/share/licenses/pixelpass/NotoSans-OFL.txt"
}
-52
View File
@@ -1,52 +0,0 @@
# PeerSpeak + PixelPass — CachyOS/Arch test pack
A single **split PKGBUILD** that builds the latest code from the public gitbutter
repos and installs **both** programs at once:
- `peerspeak` — decentralized P2P voice chat
- `pixelpass` — P2P screen sharing (peerspeak launches it for the screen-share button)
## Build & install (one command)
```sh
git clone https://gitbutter.xyz/mollusk/peerspeak.git
cd peerspeak/packaging/test-pack
makepkg -si
```
`makepkg -si` auto-installs every dependency via pacman before building —
including the Rust toolchain itself (the `cargo` makedepend is provided by the
`rust` package), `git`, `pkgconf`, pipewire + opus for peerspeak, and the
gstreamer/VA-API stack for pixelpass. The only prerequisite is the `base-devel`
group (which provides `makepkg`). If you already use `rustup`, that satisfies the
`cargo` makedepend and the `rust` package won't be pulled in — no conflict.
When it finishes you'll have `peerspeak` and `pixelpass` on your PATH at
`/usr/bin`. To rebuild later with fresh upstream code, re-run `makepkg -si`; the
git sources re-pull `main` and the version bumps automatically.
> Skip the test step with `makepkg -si --nocheck` for a faster build.
## Running the cross-internet test
1. Launch `peerspeak` on both machines.
2. One person **creates** a room and shares the room code/ticket with the other.
3. The other **joins** with that code.
4. iroh does NAT hole-punching automatically; if a direct path can't be made it
falls back to a public n0 relay — **no port forwarding required**.
5. Allow the app through any local firewall if prompted (outbound UDP / QUIC;
nothing needs to be opened inbound for relay mode).
### What we're smoke-testing
- Two real humans, two networks, over the internet.
- Mic capture + remote playback both directions, no crackle/dropouts.
- Mute / deafen, push-to-talk.
- Text chat in-room.
- Avatars (presets + custom upload) show up on the other side.
- Screen share: click the screen-share control → it launches `pixelpass`; the
viewer opens in `mpv` on the receiving side.
- Notification chimes (join/leave/etc.).
- Leave / rejoin cleanly.
If anything misbehaves, grab the log path peerspeak prints on startup and the
exact repro steps.
+141 -782
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -61,10 +61,6 @@ pub mod gate;
pub mod limiter;
pub mod multitrack;
pub mod pan;
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
// pure, so it builds (and its tests run) everywhere even though only the cpal
// backend wires it in.
pub mod resample;
#[cfg(target_os = "linux")]
pub mod echo_cancel;
#[cfg(target_os = "linux")]
-307
View File
@@ -1,307 +0,0 @@
//! Dep-free linear-interpolation resamplers for the Windows/cpal backend (W4).
//!
//! The pipeline runs internally at 48 kHz (Opus + the 20 ms frame), but a WASAPI
//! endpoint may run at a different rate (commonly 44.1 kHz) and/or a non-stereo
//! channel layout. These convert at the device boundary so such a device plays and
//! captures instead of hard-erroring (the W4 limitation in the Windows port).
//!
//! ## Where each is used
//! - [`PushResampler`] (single channel) converts **capture** from the device rate
//! to 48 kHz on the capture drain thread — off the RT callback.
//! - [`StereoPullResampler`] converts **playback** from the internal 48 kHz stereo
//! bus to the device rate inside the output RT callback, pulling internal frames
//! from the ring on demand. It allocates nothing in `next`, so it is RT-safe.
//!
//! ## Quality
//! This is plain linear interpolation with no anti-aliasing filter: correct,
//! allocation-free, and adequate for speech, but it adds some aliasing when
//! downsampling. The seam is intentionally tiny so a higher-quality polyphase/FIR
//! resampler (e.g. the `rubato` crate, pending a supply-chain decision) can later
//! replace the internals without touching the cpal backend. The matching-rate /
//! matching-layout path in the backend bypasses these entirely and stays bit-exact.
/// Linear interpolation between `a` and `b` at fractional position `frac` in `[0, 1)`.
#[inline]
fn lerp(a: f32, b: f32, frac: f32) -> f32 {
a + (b - a) * frac
}
/// Stateful single-channel **push** resampler: feed input samples at `in_rate`,
/// receive output samples at `out_rate` through an `emit` callback. It carries the
/// fractional read position and the previous input sample across calls, so feeding
/// the stream block-by-block joins seamlessly. Neither [`push`](Self::push) nor
/// [`process`](Self::process) allocates.
pub struct PushResampler {
/// Input samples consumed per output sample (`in_rate / out_rate`).
step: f64,
/// Position of the next output sample, in input-sample units, measured from the
/// index of `prev` (the most recent input). Always advanced to stay `< 1.0`
/// after each input is consumed.
next: f64,
/// The previous input sample (left edge of the current interpolation segment).
prev: f32,
/// Whether any input has been seen yet (anchors the first output at input[0]).
started: bool,
}
impl PushResampler {
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
/// clamped to `>= 1` so `step` is always finite and non-zero: a zero `step`
/// would make [`push`](Self::push)'s `while self.next < 1.0` loop forever. The
/// cpal backend's `resolve()` also rejects such rates up front, so this is
/// belt-and-suspenders against a future caller (review W7).
pub fn new(in_rate: u32, out_rate: u32) -> Self {
Self {
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
next: 0.0,
prev: 0.0,
started: false,
}
}
/// Feed one input sample; `emit` is called for each output sample produced
/// (zero or more, depending on the rate ratio).
pub fn push(&mut self, cur: f32, mut emit: impl FnMut(f32)) {
if !self.started {
// First sample: just establish the left edge. Linear interpolation
// needs the next input as the right edge, so the first output is
// produced on the next push. This gives exact alignment
// (`output[k] == input[k]` at equal rates) with one input-sample of
// latency — negligible (~20 µs at 48 kHz).
self.started = true;
self.prev = cur;
self.next = 0.0;
return;
}
// `prev` sits at position 0 of this segment and `cur` at position 1; emit
// every output whose position falls in [0, 1).
while self.next < 1.0 {
emit(lerp(self.prev, cur, self.next as f32));
self.next += self.step;
}
self.next -= 1.0;
self.prev = cur;
}
/// Convenience for tests / batch callers: push a whole slice.
pub fn process(&mut self, input: &[f32], mut emit: impl FnMut(f32)) {
for &s in input {
self.push(s, &mut emit);
}
}
}
/// Stateful stereo **pull** resampler: produce output frames at `out_rate` by
/// pulling input frames at `in_rate` from a closure on demand. Call
/// [`next`](Self::next) once per output frame; it pulls as many input frames as the
/// ratio requires and returns the interpolated `(left, right)`, or `None` when the
/// puller runs dry (an underrun). Allocates nothing, so it is safe in an RT output
/// callback.
pub struct StereoPullResampler {
/// Input frames consumed per output frame (`in_rate / out_rate`).
step: f64,
/// Position of the next output frame within `[prev, cur)`, in `[0, 1)`.
frac: f64,
/// Left edge of the current interpolation segment.
prev: (f32, f32),
/// Right edge of the current interpolation segment.
cur: (f32, f32),
/// Whether `prev`/`cur` have been primed from the puller yet.
primed: bool,
}
impl StereoPullResampler {
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
/// clamped to `>= 1` so `step` is finite and non-zero — otherwise
/// [`next`](Self::next)'s `while self.frac >= 1.0` could spin (review W7).
pub fn new(in_rate: u32, out_rate: u32) -> Self {
Self {
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
frac: 0.0,
prev: (0.0, 0.0),
cur: (0.0, 0.0),
primed: false,
}
}
/// Produce the next output frame, pulling input frames via `pull` as needed.
/// Returns `None` if `pull` returns `None` before the frame can be formed
/// (underrun); the caller should substitute silence for that frame.
pub fn next(&mut self, mut pull: impl FnMut() -> Option<(f32, f32)>) -> Option<(f32, f32)> {
if !self.primed {
// Prime both edges from two pulls so the first output frame aligns
// exactly with the first input frame (`out[0] == in[0]` at equal
// rates). Needs two frames available to start, which the prefilled
// playback ring always has.
self.prev = pull()?;
self.cur = pull()?;
self.primed = true;
self.frac = 0.0;
}
// Advance the segment until the read position lands inside [prev, cur).
while self.frac >= 1.0 {
self.prev = self.cur;
self.cur = pull()?;
self.frac -= 1.0;
}
let f = self.frac as f32;
let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f));
self.frac += self.step;
Some(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Equal rates align exactly: `output[k] == input[k]`. The final input lands on
/// the next push (one-sample streaming latency), so we get `n - 1` outputs.
#[test]
fn push_identity_when_rates_match() {
let mut r = PushResampler::new(48_000, 48_000);
let input = [0.0, 0.1, 0.2, 0.3, 0.4];
let mut out = Vec::new();
r.process(&input, |s| out.push(s));
assert_eq!(out.len(), input.len() - 1);
for (a, b) in out.iter().zip(input.iter()) {
assert!((a - b).abs() < 1e-6, "{a} vs {b}");
}
}
/// Upsampling 2x roughly doubles the output count and the midpoints interpolate.
#[test]
fn push_upsample_2x_interpolates_midpoints() {
let mut r = PushResampler::new(24_000, 48_000); // step = 0.5
let input = [0.0, 1.0, 2.0, 3.0];
let mut out = Vec::new();
r.process(&input, |s| out.push(s));
// (n - 1) segments at 2 outputs each = 6.
assert_eq!(out.len(), 6, "out {out:?}");
// A half-step between 1.0 and 2.0 must appear near 1.5.
assert!(
out.iter().any(|&s| (s - 1.5).abs() < 1e-3),
"expected a ~1.5 midpoint in {out:?}"
);
}
/// Downsampling drops the rate: fewer outputs than inputs, monotonic ramp preserved.
#[test]
fn push_downsample_reduces_count() {
let mut r = PushResampler::new(48_000, 44_100); // step ~1.088
let input: Vec<f32> = (0..441).map(|i| i as f32).collect();
let mut out = Vec::new();
r.process(&input, |s| out.push(s));
// 441 in @ 48k -> ~405 out @ 44.1k.
assert!(
(390..=410).contains(&out.len()),
"expected ~405 outputs, got {}",
out.len()
);
// Output stays within the input's value range and is non-decreasing.
for w in out.windows(2) {
assert!(w[1] >= w[0] - 1e-3, "ramp should not reverse: {w:?}");
}
assert!(*out.last().unwrap() <= 440.0 + 1e-3);
}
/// Pull resampler at equal rates returns each input frame in order, aligned.
/// Two-pull priming uses one frame of lookahead, so `n` inputs yield `n - 1`
/// outputs (the last frame emits once a successor arrives).
#[test]
fn pull_identity_when_rates_match() {
let mut r = StereoPullResampler::new(48_000, 48_000);
let frames = [(0.0, 9.0), (1.0, 8.0), (2.0, 7.0), (3.0, 6.0)];
let mut idx = 0;
let mut out = Vec::new();
while let Some(f) = r.next(|| {
let v = frames.get(idx).copied();
idx += 1;
v
}) {
out.push(f);
}
assert_eq!(out.len(), frames.len() - 1, "out {out:?}");
for (got, want) in out.iter().zip(frames.iter()) {
assert!((got.0 - want.0).abs() < 1e-6 && (got.1 - want.1).abs() < 1e-6);
}
}
/// Pull resampler reports underrun (`None`) once the source is exhausted.
#[test]
fn pull_returns_none_on_underrun() {
let mut r = StereoPullResampler::new(48_000, 44_100); // step ~1.088 -> pulls >1 per out
let frames = [(0.0, 0.0), (1.0, -1.0)];
let mut idx = 0;
let mut pull = || {
let v = frames.get(idx).copied();
idx += 1;
v
};
// First frame primes + emits; subsequent calls eventually exhaust the source.
let mut produced = 0;
let mut hit_none = false;
for _ in 0..10 {
if r.next(&mut pull).is_some() {
produced += 1;
} else {
hit_none = true;
break;
}
}
assert!(produced >= 1, "should produce at least the primed frame");
assert!(hit_none, "should report underrun once the puller is dry");
}
/// Downsampling via pull consumes more input frames than it emits output frames.
#[test]
fn pull_downsample_consumes_more_than_it_emits() {
let mut r = StereoPullResampler::new(48_000, 24_000); // step = 2.0
let input: Vec<(f32, f32)> = (0..100).map(|i| (i as f32, -(i as f32))).collect();
let mut idx = 0;
let mut emitted = 0;
for _ in 0..40 {
let f = r.next(|| {
let v = input.get(idx).copied();
idx += 1;
v
});
if f.is_some() {
emitted += 1;
} else {
break;
}
}
// At step 2.0 we consume ~2 input frames per output frame.
assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output");
}
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
/// `while self.next < 1.0` forever). Clamping makes the call terminate (W7).
#[test]
fn push_zero_rate_does_not_spin() {
let mut r = PushResampler::new(0, 48_000);
let mut count = 0usize;
// Feed two samples; with a clamped non-zero step this returns promptly.
r.push(0.0, |_| count += 1);
r.push(1.0, |_| count += 1);
// Reaching here at all is the assertion (no hang); some output is produced.
assert!(count >= 1);
}
/// A zero output rate must not make the pull resampler's segment-advance loop
/// spin. Clamping keeps `step` finite so `next` terminates (W7).
#[test]
fn pull_zero_out_rate_does_not_spin() {
let mut r = StereoPullResampler::new(48_000, 0);
let frames = [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)];
let mut idx = 0;
let got = r.next(|| {
let v = frames.get(idx).copied();
idx += 1;
v
});
// Terminates and yields the primed frame instead of hanging.
assert!(got.is_some());
}
}
+3 -7
View File
@@ -33,9 +33,7 @@ fn main() {
#[cfg(not(any(target_os = "linux", windows)))]
fn main() {
eprintln!(
"audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly)."
);
eprintln!("audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly).");
}
#[cfg(target_os = "linux")]
@@ -95,8 +93,7 @@ mod unix_probe {
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample =
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample);
frame.push(sample);
@@ -201,8 +198,7 @@ mod win_probe {
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample =
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample);
frame.push(sample);
+11 -20
View File
@@ -1816,26 +1816,17 @@ async fn run_core_loop(
}
CoreCommand::SetNetworkMode(mode) => {
// Skip when the posture is unchanged. The GUI re-sends the saved
// network mode as part of its startup config-sync, and that mode
// usually already matches the freshly-built stack — rebuilding the
// iroh endpoint for an identical posture just churns the network
// and adds a needless ~1s teardown+rebuild bounce at every launch
// (seen on both Linux and Windows/Wine). A real change still
// rebuilds exactly as before.
if mode != network_mode {
network_mode = mode;
// Rebuild the persistent stack to the new posture immediately if
// idle; if a call is active, defer to the next Leave/Join so the
// live call isn't disrupted (preserves "applies on next join").
if active_session.is_none() {
let lookup = net.memory_lookup.clone();
net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?;
} else {
net_rebuild_pending = true;
}
network_mode = mode;
// Rebuild the persistent stack to the new posture immediately if
// idle; if a call is active, defer to the next Leave/Join so the
// live call isn't disrupted (preserves "applies on next join").
if active_session.is_none() {
let lookup = net.memory_lookup.clone();
net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?;
} else {
net_rebuild_pending = true;
}
}