diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 05687b3..7959530 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,20 +1,30 @@ -# This workflow runs build and tests on every pull request and every direct push -# to master. It also publishes artifacts and creates a GitHub Release only after -# a pull request is successfully merged to master. +# CI/CD for Resgrid Relay. # -# Required Docker Hub secrets: +# * Pull requests -> build the solution and run all unit tests (no publish, +# no release). Runs on every push to an open PR. +# * Push to master/main (i.e. once a PR is merged) -> re-run build + tests, +# then publish the app artifacts (zipped), build & push the Docker image, +# and create an auto-incrementing GitHub Release — all at the SAME version. +# +# Approval is enforced by branch protection on master/main: require a pull +# request with an approving review before merging, and disallow direct pushes. +# That way the release jobs (which trigger on push to master) only ever run for +# a reviewed, merged PR. The "BuildEnv" environment can additionally require +# reviewers as a deployment gate. +# +# Required Docker Hub secrets (on the BuildEnv environment): # - DOCKERHUB_USERNAME # - DOCKERHUB_TOKEN name: Build and publish on: + pull_request: + types: [opened, synchronize, reopened] push: branches: - master - main - pull_request: - types: [opened, synchronize, reopened, closed] env: DOTNET_VERSION: "10.0.x" @@ -23,22 +33,17 @@ env: DOCKER_IMAGE: "resgridllc/resgridrelay" jobs: + # Runs on every PR push (CI) and on every push to master/main, where it also + # gates the release jobs below — they will not run unless this succeeds. build-and-test: name: Build and test - if: | - github.event_name == 'push' || - github.event.action != 'closed' || - (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'master') runs-on: windows-latest - environment: BuildEnv permissions: contents: read steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.action == 'closed' && github.event.pull_request.merge_commit_sha || github.sha }} - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -54,35 +59,12 @@ jobs: - name: Test run: dotnet test "Resgrid Audio.sln" --configuration Release --no-build --verbosity normal - check-approval: - name: Verify PR was approved - needs: build-and-test - if: github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'master' - runs-on: ubuntu-latest - environment: BuildEnv - - permissions: - pull-requests: read - - steps: - - name: Verify at least one approved review - uses: actions/github-script@v7 - with: - script: | - const { data: reviews } = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - }); - const approved = reviews.some(r => r.state === 'APPROVED'); - if (!approved) { - core.setFailed('PR must have at least one approved review before publishing.'); - } - + # ── Release pipeline: only on a push to master/main (a merged PR), and only + # after build-and-test passes. ──────────────────────────────────────────── publish-apps: name: Publish app assets - needs: check-approval - if: github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'master' + needs: build-and-test + if: github.event_name == 'push' runs-on: windows-latest environment: BuildEnv @@ -91,8 +73,6 @@ jobs: steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.merge_commit_sha || github.sha }} - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -114,6 +94,35 @@ jobs: dotnet publish ".\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj" --configuration Release --framework net10.0-windows --no-restore --output $consoleWindows dotnet publish ".\Resgrid.Audio.Relay\Resgrid.Audio.Relay.csproj" --configuration Release --framework net10.0-windows --no-restore --output $monitorWindows + - name: Bundle LiveKit FFI native library + shell: pwsh + # The LiveKit Rust FFI native lib isn't in the NuGet, so ship it next to the + # console so 'radio'/'record'/'dispatch' modes work. (The Docker image fetches + # its own linux build — see Dockerfile.) Must match Livekit.Rtc.Dotnet. + run: | + $ffiVersion = "livekit-ffi/v0.12.65" + $enc = $ffiVersion.Replace("/", "%2F") + $publishRoot = Join-Path $env:RUNNER_TEMP "publish" + function Add-Ffi($asset, $lib, $dest) { + $url = "https://github.com/livekit/rust-sdks/releases/download/$enc/$asset.zip" + $zip = Join-Path $env:RUNNER_TEMP "$asset.zip" + $ex = Join-Path $env:RUNNER_TEMP $asset + Invoke-WebRequest -Uri $url -OutFile $zip + # Validate the download produced a non-empty archive before extracting + # (mirrors the Dockerfile ffi-download stage, which fails on a bad archive). + if (-not (Test-Path $zip) -or (Get-Item $zip).Length -eq 0) { + throw "LiveKit FFI download failed or produced an empty archive: $url" + } + Expand-Archive -Path $zip -DestinationPath $ex -Force + $src = Get-ChildItem -Path $ex -Recurse -Filter $lib | Select-Object -First 1 + if (-not $src) { + throw "LiveKit FFI library '$lib' not found in '$asset.zip' from $url" + } + Copy-Item $src.FullName (Join-Path $dest $lib) -Force + } + Add-Ffi "ffi-windows-x86_64" "livekit_ffi.dll" (Join-Path $publishRoot "relay-console-net10.0-windows") + Add-Ffi "ffi-linux-x86_64" "liblivekit_ffi.so" (Join-Path $publishRoot "relay-console-net10.0") + - name: Package release assets shell: pwsh run: | @@ -147,8 +156,8 @@ jobs: docker-build-and-push: name: Publish Docker image - needs: check-approval - if: github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'master' + needs: build-and-test + if: github.event_name == 'push' runs-on: ubuntu-latest environment: BuildEnv @@ -157,8 +166,6 @@ jobs: steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.merge_commit_sha || github.sha }} - name: Validate Docker Hub configuration shell: bash @@ -222,7 +229,7 @@ jobs: github-release: name: Create GitHub release needs: [publish-apps, docker-build-and-push] - if: github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'master' + if: github.event_name == 'push' runs-on: ubuntu-latest environment: BuildEnv @@ -243,21 +250,34 @@ jobs: name: docker-release-metadata path: ${{ runner.temp }}/docker-release - - name: Get merged PR info - id: pr-info + - name: Build release notes from the merged PR uses: actions/github-script@v7 with: script: | const fs = require('fs'); - const body = (context.payload.pull_request.body || '') - .replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '') + // The release is triggered by the push of the merge commit, which has no + // PR payload — resolve the originating PR from the commit, then use its + // description (the PR write-up) as the release notes. + const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha, + }); + const pr = prs.find(p => p.merged_at) || prs[0]; + if (!pr) { + core.warning(`No pull request found for commit ${context.sha}; using a placeholder note.`); + } + const prBody = (pr && pr.body) || ''; + // Strip the "Summary by CodeRabbit" section (its heading through the end). + const cleaned = prBody + .replace(/#{1,6}\s*Summary by CodeRabbit[\s\S]*/i, '') .trim() || 'No release notes provided.'; const notes = [ - body, + cleaned, '', '## Docker image', `- \`${{ env.DOCKER_IMAGE }}:${{ env.RELEASE_VERSION }}\``, - `- \`${{ env.DOCKER_IMAGE }}:latest\`` + `- \`${{ env.DOCKER_IMAGE }}:latest\``, ].join('\n'); fs.writeFileSync('release_notes.md', notes); @@ -266,8 +286,8 @@ jobs: with: tag_name: ${{ env.RELEASE_VERSION }} name: ${{ env.RELEASE_VERSION }} + target_commitish: ${{ github.sha }} body_path: release_notes.md - target_commitish: ${{ github.event.pull_request.merge_commit_sha || github.sha }} files: | ${{ runner.temp }}/release-assets/*.zip ${{ runner.temp }}/docker-release/docker-image-tags.txt diff --git a/.gitignore b/.gitignore index 7dc0732..4328617 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,6 @@ Web/Resgrid.Services/App_Data/Resgrid.Web.Services.XML /node_modules .vs/ /.idea +/.dual-graph-pro +.mcp.json +/.claude diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e775f7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,45 @@ + +# Dual-Graph Context Policy + +This project uses a local dual-graph MCP server (graperoot-pro) for efficient, +budget-aware context retrieval. Always prefer it over native file exploration. + +## MANDATORY: Always follow this order + +1. **Call `graph_continue` first** -- before any file exploration, grep, or code reading. + +2. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with the + current project directory (`pwd`). Do NOT ask the user. + +3. **If `graph_continue` returns `skip=true`**: project is too small for the graph to + help. Skip all graph tools and explore normally. + +4. **Read `recommended_files`** using `graph_read` -- one call per file. + - `recommended_files` may contain `file::symbol` entries (e.g. `src/auth.ts::handleLogin`). + Pass them verbatim to `graph_read(file: "src/auth.ts::handleLogin")` -- it reads only + that symbol's lines, not the full file. + +5. **Check `confidence` and obey the caps strictly:** + - `confidence=high` -> Stop. Do NOT grep or explore further. + - `confidence=medium` -> If recommended files are insufficient, call `fallback_rg` + at most `max_supplementary_greps` time(s) with specific terms, then `graph_read` + at most `max_supplementary_files` additional file(s). Then stop. + - `confidence=low` -> Call `fallback_rg` at most `max_supplementary_greps` time(s), + then `graph_read` at most `max_supplementary_files` file(s). Then stop. + +## Exhaustive enumeration tasks + +Some tasks require scanning **every file** -- e.g. "find all dead exports", "list every +.find() without a limit", "audit all test files". Use these tools first: + +- **`graph_dead_exports()`** -- pre-computed at scan time. Use for any dead-export task. +- **`graph_grep_all(pattern, file_glob?, max_hits?)`** -- exhaustive grep, no call cap. + +## Rules + +- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue`. +- Do NOT do broad/recursive exploration at any confidence level. +- After edits, call `graph_register_edit(files: ["path/to/file"])`. The parameter is + `files` (plural, always an array). Use `file::symbol` notation when the edit targets + a specific function, class, or hook. + diff --git a/Dockerfile b/Dockerfile index cd1bc6e..d88281e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,62 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src +# syntax=docker/dockerfile:1.7 +# Resgrid Relay container — Docker Hardened Images (DHI), mirroring the pattern and +# fixes used by Resgrid Core's Workers.Console Dockerfile: +# * DHI base + SDK images, pinned by tag@sha256 (see resolve-dhi-digests.sh). +# * tzdata installed with --reinstall (DHI ships the dpkg marker but no zone files). +# * shell-less final image: docker-compose-wait execs the app (no /bin/sh). +# * runs as the image's non-root user. +# +# Notes specific to the relay: +# * The console relay has NO ASP.NET dependency, so it uses the runtime-only +# dhi.io/dotnet image (Core's Workers used aspnetcore only because they pull in +# the ASP.NET shared framework). +# * The LiveKit FFI native library is baked in so the cross-platform 'record' and +# 'dispatch' (TTS tone-out) modes work in a container. The 'radio' bridge mode +# needs physical audio devices and is Windows-desktop only — not containerized. +# * The old docker-entrypoint.sh is gone; its env validation and LocalXpose tunnel +# orchestration now live in the app (a shell-less image has no bash). +# +# DHI requires a Docker Hardened Images subscription/login. Resolve and pin the +# digests with ./resolve-dhi-digests.sh, which rewrites the two image ARGs below. +ARG BUILD_VERSION=2.0.0 +ARG DOTNET_SDK_IMAGE=dhi.io/dotnet:10.0-sdk-debian13@sha256:27572eda11ffbda0ff63bcaf301b3314f8b993e32957d60b1396bde5bd24d4a6 +ARG DOTNET_RUNTIME_IMAGE=dhi.io/dotnet:10.0-debian13@sha256:baed1f48538246b0c1152bd6b8ca4f04971213286fa229abf6079f01170ccf83 + +# LiveKit FFI (Rust client core) — must match the Livekit.Rtc.Dotnet package version. +# Validated against Livekit.Rtc.Dotnet 0.1.3. +ARG LIVEKIT_FFI_VERSION=livekit-ffi/v0.12.65 +# ─── Build ────────────────────────────────────────────────────────────────── +FROM ${DOTNET_SDK_IMAGE} AS build +ARG BUILD_VERSION +WORKDIR /src COPY . . RUN dotnet restore "Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj" -p:TargetFramework=net10.0 -RUN dotnet publish "Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj" -c Release -f net10.0 -o /app/publish /p:UseAppHost=false +RUN dotnet publish "Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj" \ + -c Release -f net10.0 -o /app/publish /p:UseAppHost=false -p:Version=${BUILD_VERSION} -# Download the LocalXpose CLI binary. +# ─── Publish extras (shell available here, not in the hardened final image) ── +FROM build AS publish +ARG BUILD_VERSION +# docker-compose-wait is fetched in its own architecture-aware stage (wait-download, +# below) and copied into the final image so it matches the runtime architecture. +# The hardened DHI image marks tzdata installed (dpkg) but ships none of the zone +# files, so a plain install is a no-op. --reinstall forces re-extraction; the test +# asserts the zone files are really present so the build fails loudly otherwise. +RUN DEBIAN_FRONTEND=noninteractive apt-get update \ + && apt-get install -y --reinstall --no-install-recommends tzdata \ + && test -f /usr/share/zoneinfo/America/New_York \ + && test -f /usr/share/zoneinfo/Asia/Kolkata \ + && rm -rf /var/lib/apt/lists/* +# DHI images run non-root and /app is root-owned; pre-create writable runtime dirs +# (token cache, recordings) with open perms so the non-root user can write them. +RUN mkdir -p /app/publish/data /app/publish/recordings \ + && chmod -R 0777 /app/publish/data /app/publish/recordings + +# ─── LocalXpose CLI download (optional SMTP tunnel) ───────────────────────── # LocalXpose distributes only a rolling "latest" build from S3 — there are no -# versioned release URLs. SHA256 values below are sourced from the AUR PKGBUILD -# (https://aur.archlinux.org/packages/localxpose-cli, last updated 2025-02-04) -# and must be updated whenever loclx publishes a new binary. -# Supported Docker architectures: linux/amd64, linux/arm64, linux/386, linux/arm +# versioned release URLs. SHA256 values are from the AUR PKGBUILD and must be +# refreshed whenever loclx publishes a new binary. FROM debian:bookworm-slim AS loclx-download ARG TARGETARCH=amd64 ARG LOCLX_SHA256_AMD64=03c6d1d35dfd0acb673473314c1384156ed2bfcb96e581b3e0bb398fef45fb88 @@ -35,94 +81,128 @@ RUN apt-get update && apt-get install -y --no-install-recommends wget zstd ca-ce && [ -x /usr/local/bin/loclx ] \ && rm -rf /tmp/loclx.pkg.tar.zst /tmp/loclx-extract /var/lib/apt/lists/* -FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final +# ─── LiveKit FFI native library download ──────────────────────────────────── +FROM debian:bookworm-slim AS ffi-download +ARG LIVEKIT_FFI_VERSION +ARG TARGETARCH=amd64 +# Pin each release asset's SHA-256 per arch (as loclx-download does) so the native +# library is integrity-checked before extraction/install. Values correspond to the +# LIVEKIT_FFI_VERSION above; leave empty to skip the check (a warning is emitted) +# until the official hashes are pinned here or passed in by CI. +ARG LIVEKIT_FFI_SHA256_AMD64= +ARG LIVEKIT_FFI_SHA256_ARM64= +RUN apt-get update && apt-get install -y --no-install-recommends curl unzip ca-certificates \ + && case "${TARGETARCH}" in \ + amd64) asset="ffi-linux-x86_64"; sha="${LIVEKIT_FFI_SHA256_AMD64}" ;; \ + arm64) asset="ffi-linux-arm64"; sha="${LIVEKIT_FFI_SHA256_ARM64}" ;; \ + *) echo "Unsupported arch: ${TARGETARCH}" >&2 ; exit 1 ;; \ + esac \ + && enc=$(printf '%s' "${LIVEKIT_FFI_VERSION}" | sed 's#/#%2F#') \ + && curl -sSL -o /tmp/ffi.zip \ + "https://github.com/livekit/rust-sdks/releases/download/${enc}/${asset}.zip" \ + && if [ -n "${sha}" ]; then echo "${sha} /tmp/ffi.zip" | sha256sum -c - ; \ + else echo "WARNING: no SHA-256 pinned for ${asset}.zip (set LIVEKIT_FFI_SHA256_AMD64/_ARM64); skipping integrity check" >&2 ; fi \ + && mkdir -p /tmp/ffi && unzip -q /tmp/ffi.zip -d /tmp/ffi \ + && find /tmp/ffi -name 'liblivekit_ffi.so' -exec install -m 755 {} /usr/local/lib/liblivekit_ffi.so \; \ + && test -f /usr/local/lib/liblivekit_ffi.so \ + && rm -rf /tmp/ffi.zip /tmp/ffi /var/lib/apt/lists/* + +# ─── docker-compose-wait download (architecture-aware) ────────────────────── +# The hardened final image has no shell, so docker-compose-wait launches the app +# (waits for WAIT_HOSTS, then execs WAIT_COMMAND). The release ships per-arch +# binaries; pick the one matching TARGETARCH and normalize it to "wait" so the +# ENTRYPOINT stays arch-independent. Mirrors the ffi-download / loclx-download stages. +FROM debian:bookworm-slim AS wait-download +ARG TARGETARCH=amd64 +ARG WAIT_VERSION=2.12.1 +# amd64 asset sha256 is pinned; set WAIT_SHA256_ARM64 to verify arm64 builds too +# (the asset is downloaded either way — verification is skipped when no hash is given). +ARG WAIT_SHA256_AMD64=2241be671073520e028b2f12df1e9ef0419014cffb5670b7a80b2080804be17d +ARG WAIT_SHA256_ARM64= +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ + && case "${TARGETARCH}" in \ + amd64) asset="wait"; sha="${WAIT_SHA256_AMD64}" ;; \ + arm64) asset="wait_aarch64"; sha="${WAIT_SHA256_ARM64}" ;; \ + *) echo "Unsupported arch: ${TARGETARCH}" >&2 ; exit 1 ;; \ + esac \ + && curl -sSL -o /wait \ + "https://github.com/ufoscout/docker-compose-wait/releases/download/${WAIT_VERSION}/${asset}" \ + && if [ -n "${sha}" ]; then echo "${sha} /wait" | sha256sum -c - ; fi \ + && chmod +x /wait \ + && test -x /wait \ + && rm -rf /var/lib/apt/lists/* + +# ─── Final (hardened, distroless, non-root) ───────────────────────────────── +FROM ${DOTNET_RUNTIME_IMAGE} AS final WORKDIR /app -COPY --from=build /app/publish . +# DHI/distroless bases ship without tzdata; copy the IANA database so TimeZoneInfo +# can resolve zones (otherwise TimeZoneNotFoundException). +COPY --from=publish /usr/share/zoneinfo /usr/share/zoneinfo +ENV TZ=Etc/UTC + +COPY --from=publish /app/publish . COPY --from=loclx-download /usr/local/bin/loclx /usr/local/bin/loclx -COPY docker-entrypoint.sh /docker-entrypoint.sh -RUN chmod +x /docker-entrypoint.sh +COPY --from=ffi-download /usr/local/lib/liblivekit_ffi.so /app/liblivekit_ffi.so +COPY --from=wait-download /wait /app/wait +# Native lib lives next to the app; make it discoverable by the loader. +ENV LD_LIBRARY_PATH=/app # ─── Relay configuration ─────────────────────────────────────────────── # Every setting below can be overridden at runtime via -e or docker-compose. -# Required values are marked [REQUIRED] — the relay will not start without them. -# Operational mode +# Operational mode: smtp | record | dispatch (radio is Windows-desktop only). ENV RESGRID__RELAY__Mode=smtp # Resgrid API (v4) -# [REQUIRED] e.g. https://api.resgrid.com ENV RESGRID__RELAY__Resgrid__BaseUrl="" ENV RESGRID__RELAY__Resgrid__ApiVersion=4 -# [REQUIRED] ENV RESGRID__RELAY__Resgrid__ClientId="" -# [REQUIRED] ENV RESGRID__RELAY__Resgrid__ClientSecret="" -# Required when GrantType=RefreshToken ENV RESGRID__RELAY__Resgrid__RefreshToken="" -ENV RESGRID__RELAY__Resgrid__Scope=openid profile email offline_access mobile +ENV RESGRID__RELAY__Resgrid__Scope="openid profile email offline_access mobile" ENV RESGRID__RELAY__Resgrid__TokenCachePath=./data/resgrid-token.json -# RefreshToken | ClientCredentials | SystemApiKey ENV RESGRID__RELAY__Resgrid__GrantType=RefreshToken -# Required when GrantType=SystemApiKey ENV RESGRID__RELAY__Resgrid__SystemApiKey="" -# Optional, used as fallback in hosted mode ENV RESGRID__RELAY__Resgrid__DepartmentId="" -# Telemetry (optional — telemetry is disabled when these are left empty) +# Telemetry (optional) ENV RESGRID__RELAY__Telemetry__Environment= ENV RESGRID__RELAY__Telemetry__Sentry__Dsn= -ENV RESGRID__RELAY__Telemetry__Sentry__Release= -ENV RESGRID__RELAY__Telemetry__Sentry__SendDefaultPii=true ENV RESGRID__RELAY__Telemetry__Countly__Url= ENV RESGRID__RELAY__Telemetry__Countly__AppKey= -ENV RESGRID__RELAY__Telemetry__Countly__DeviceId= -ENV RESGRID__RELAY__Telemetry__Countly__RequestTimeoutSeconds=5 -# SMTP server +# SMTP server (smtp mode) ENV RESGRID__RELAY__Smtp__ServerName=resgrid-relay ENV RESGRID__RELAY__Smtp__Port=2525 ENV RESGRID__RELAY__Smtp__DataDirectory=./data -ENV RESGRID__RELAY__Smtp__DuplicateWindowHours=72 -ENV RESGRID__RELAY__Smtp__DefaultCallPriority=1 -ENV RESGRID__RELAY__Smtp__MaxAttachmentBytes=10485760 -ENV RESGRID__RELAY__Smtp__MaxMessageBytes=26214400 -ENV RESGRID__RELAY__Smtp__SaveRawMessages=true -ENV RESGRID__RELAY__Smtp__DepartmentDispatchPrefix=G - -# Dispatch domain configuration — at least one domain list is required. -# Use the indexed form for arrays in env vars: -# RESGRID__RELAY__Smtp__DepartmentAddressDomains__0=dispatch.resgrid.com -# RESGRID__RELAY__Smtp__DepartmentAddressDomains__1=pager.example.com -# RESGRID__RELAY__Smtp__GroupAddressDomains__0=groups.resgrid.com -# RESGRID__RELAY__Smtp__GroupMessageAddressDomains__0=gm.resgrid.com -# RESGRID__RELAY__Smtp__ListAddressDomains__0=lists.resgrid.com - -# Hosted (multi-department) mode -# true when running for Resgrid Hosted ENV RESGRID__RELAY__Smtp__HostedMode=false -ENV RESGRID__RELAY__Smtp__DepartmentDomainSeparator=. -# Optional department override -ENV RESGRID__RELAY__Smtp__DefaultDepartmentId="" -# Resolve code names to numeric IDs via lookup API ENV RESGRID__RELAY__Smtp__ResolveDispatchCodes=true - -# Redis cache for dispatch lookups (optional — disabled by default) -# When enabled, group/unit/role lookup results are cached in Redis to -# reduce API traffic. This is especially beneficial in hosted mode where -# the relay handles emails for many departments. ENV RESGRID__RELAY__Smtp__RedisCache__Enabled=false -# ENV RESGRID__RELAY__Smtp__RedisCache__ConnectionString=redis:6379,abortConnect=false -# ENV RESGRID__RELAY__Smtp__RedisCache__TtlMinutes=60 -# ─── LocalXpose tunnel ───────────────────────────────────────────────── -# All optional — tunnel is disabled by default. +# Voice modes (record / dispatch). Channel selector + optional department override. +# ENV RESGRID__RELAY__Voice__Channel=default +# ENV RESGRID__RELAY__Voice__DepartmentId= +# Compliance recorder (Mode=record): +# ENV RESGRID__RELAY__Recorder__Channel=all +# ENV RESGRID__RELAY__Recorder__Store=local # local | s3 | both +# ENV RESGRID__RELAY__Recorder__LocalPath=/app/recordings +# ENV RESGRID__RELAY__Recorder__Log=jsonl # jsonl | sqlite | none +# ENV RESGRID__RELAY__Recorder__S3__Bucket= +# ENV RESGRID__RELAY__Recorder__S3__Region= +# ENV RESGRID__RELAY__Recorder__S3__AccessKey= +# ENV RESGRID__RELAY__Recorder__S3__SecretKey= +# Dispatch tone-out (Mode=dispatch): +# ENV RESGRID__RELAY__Tts__ServiceBaseUrl=https://tts.resgrid.com +# ENV RESGRID__RELAY__DispatchVoice__Channel=default + +# ─── LocalXpose tunnel (optional, smtp mode) ──────────────────────────── ENV LOCLX_ENABLED=false -# LocalXpose access token # ENV LOCLX_TOKEN="" -# e.g. smtp.loclx.io:25 # ENV LOCLX_RESERVED_ENDPOINT="" EXPOSE 2525 -ENTRYPOINT ["/docker-entrypoint.sh"] +# Shell-less launch (DHI has no /bin/sh): docker-compose-wait execs the app. +ENV WAIT_COMMAND="dotnet Resgrid.Audio.Relay.Console.dll" +ENTRYPOINT ["./wait"] diff --git a/Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs b/Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs index 7b1bf41..eb0b7e0 100644 --- a/Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs +++ b/Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs @@ -1,22 +1,40 @@ using Resgrid.Providers.ApiClient.V4.Models; using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; namespace Resgrid.Providers.ApiClient.V4 { - public static class CallsApi + public interface IResgridCallsApi { - public static async Task SaveCallAsync(NewCallInput call, CancellationToken cancellationToken = default) + Task SaveCallAsync(NewCallInput call, CancellationToken cancellationToken = default); + Task GetCallAsync(string callId, string departmentId = null, CancellationToken cancellationToken = default); + Task SaveCallFileAsync(SaveCallFileInput file, CancellationToken cancellationToken = default); + Task> GetActiveCallsAsync(string departmentId = null, CancellationToken cancellationToken = default); + } + + public sealed class CallsApi : IResgridCallsApi + { + private readonly IResgridApiClient _client; + + public CallsApi(IResgridApiClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public async Task SaveCallAsync(NewCallInput call, CancellationToken cancellationToken = default) { - var result = await ResgridV4ApiClient.PostAsync("Calls/SaveCall", call, cancellationToken).ConfigureAwait(false); + var result = await _client.PostAsync("Calls/SaveCall", call, cancellationToken).ConfigureAwait(false); if (String.IsNullOrWhiteSpace(result.Id)) throw new InvalidOperationException("The Resgrid API did not return a call id for the saved call."); return result.Id; } - public static async Task GetCallAsync(string callId, string departmentId = null, CancellationToken cancellationToken = default) + public async Task GetCallAsync(string callId, string departmentId = null, CancellationToken cancellationToken = default) { if (String.IsNullOrWhiteSpace(callId)) throw new ArgumentException("A call id is required.", nameof(callId)); @@ -25,13 +43,38 @@ public static async Task GetCallAsync(string callId, string depar if (!String.IsNullOrWhiteSpace(departmentId)) url += $"&departmentId={Uri.EscapeDataString(departmentId)}"; - return await ResgridV4ApiClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + return await _client.GetAsync(url, cancellationToken).ConfigureAwait(false); } - public static async Task SaveCallFileAsync(SaveCallFileInput file, CancellationToken cancellationToken = default) + public async Task SaveCallFileAsync(SaveCallFileInput file, CancellationToken cancellationToken = default) { - var result = await ResgridV4ApiClient.PostAsync("CallFiles/SaveCallFile", file, cancellationToken).ConfigureAwait(false); + var result = await _client.PostAsync("CallFiles/SaveCallFile", file, cancellationToken).ConfigureAwait(false); return result?.Id; } + + /// + /// Returns the currently active (open) calls for the department, used by the + /// dispatch tone-out mode to detect new calls to announce on a PTT channel. + /// Maps to: GET /api/v4/Calls/GetActiveCalls[?departmentId={departmentId}]. + /// + /// Returns an empty list if the endpoint is unavailable (404) so callers can + /// poll safely without special-casing API availability. + /// + public async Task> GetActiveCallsAsync(string departmentId = null, CancellationToken cancellationToken = default) + { + var url = "Calls/GetActiveCalls"; + if (!String.IsNullOrWhiteSpace(departmentId)) + url += $"?departmentId={Uri.EscapeDataString(departmentId)}"; + + try + { + var result = await _client.GetAsync(url, cancellationToken).ConfigureAwait(false); + return result?.Data ?? new List(); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return new List(); + } + } } } diff --git a/Providers/Resgrid.Providers.ApiClient/V4/HealthApi.cs b/Providers/Resgrid.Providers.ApiClient/V4/HealthApi.cs index a71c997..f581e41 100644 --- a/Providers/Resgrid.Providers.ApiClient/V4/HealthApi.cs +++ b/Providers/Resgrid.Providers.ApiClient/V4/HealthApi.cs @@ -1,13 +1,26 @@ +using System; using System.Threading; using System.Threading.Tasks; namespace Resgrid.Providers.ApiClient.V4 { - public static class HealthApi + public interface IResgridHealthApi { - public static Task IsHealthyAsync(CancellationToken cancellationToken = default) + Task IsHealthyAsync(CancellationToken cancellationToken = default); + } + + public sealed class HealthApi : IResgridHealthApi + { + private readonly IResgridApiClient _client; + + public HealthApi(IResgridApiClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public Task IsHealthyAsync(CancellationToken cancellationToken = default) { - return ResgridV4ApiClient.IsHealthyAsync(cancellationToken); + return _client.IsHealthyAsync(cancellationToken); } } } diff --git a/Providers/Resgrid.Providers.ApiClient/V4/IResgridApiClient.cs b/Providers/Resgrid.Providers.ApiClient/V4/IResgridApiClient.cs new file mode 100644 index 0000000..ca4f3a0 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/IResgridApiClient.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Providers.ApiClient.V4 +{ + public interface IResgridApiClient + { + string CurrentUserId { get; } + Task IsHealthyAsync(CancellationToken cancellationToken = default); + Task GetAsync(string url, CancellationToken cancellationToken = default) where T : class; + Task PostAsync(string url, object data, CancellationToken cancellationToken = default) where T : class; + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/V4/LookupsApi.cs b/Providers/Resgrid.Providers.ApiClient/V4/LookupsApi.cs index 6233300..82a0256 100644 --- a/Providers/Resgrid.Providers.ApiClient/V4/LookupsApi.cs +++ b/Providers/Resgrid.Providers.ApiClient/V4/LookupsApi.cs @@ -15,17 +15,32 @@ namespace Resgrid.Providers.ApiClient.V4 /// in the Resgrid API project. Until they exist, callers should handle the /// expected 404 gracefully and fall back to code-based DispatchList values. /// - public static class LookupsApi + public interface IResgridLookupsApi { + Task LookupGroupByDispatchCodeAsync(string code, string departmentId, CancellationToken cancellationToken = default); + Task LookupGroupByMessageCodeAsync(string code, string departmentId, CancellationToken cancellationToken = default); + Task LookupUnitByNameAsync(string name, string departmentId, CancellationToken cancellationToken = default); + Task LookupRoleByNameAsync(string name, string departmentId, CancellationToken cancellationToken = default); + } + + public sealed class LookupsApi : IResgridLookupsApi + { + private readonly IResgridApiClient _client; + + public LookupsApi(IResgridApiClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + /// /// Resolves a group dispatch email code to its numeric group ID. /// Maps to: GET /api/v4/Groups/GetGroupByDispatchCode?code={code}&departmentId={departmentId} - /// + /// /// Behind the scenes this queries DepartmentGroups.DispatchEmail. /// Returns null when the endpoint returns 404 (group not found or API /// endpoint not yet deployed). /// - public static async Task LookupGroupByDispatchCodeAsync( + public async Task LookupGroupByDispatchCodeAsync( string code, string departmentId, CancellationToken cancellationToken = default) @@ -48,7 +63,7 @@ public static async Task LookupGroupByDispatchCodeAsync( /// Behind the scenes this queries DepartmentGroups.MessageEmail. /// Returns null when the endpoint returns 404. /// - public static async Task LookupGroupByMessageCodeAsync( + public async Task LookupGroupByMessageCodeAsync( string code, string departmentId, CancellationToken cancellationToken = default) @@ -71,7 +86,7 @@ public static async Task LookupGroupByMessageCodeAsync( /// Behind the scenes this queries Units.UnitName. /// Returns null when the endpoint returns 404. /// - public static async Task LookupUnitByNameAsync( + public async Task LookupUnitByNameAsync( string name, string departmentId, CancellationToken cancellationToken = default) @@ -94,7 +109,7 @@ public static async Task LookupUnitByNameAsync( /// Behind the scenes this queries PersonnelRoles.Name. /// Returns null when the endpoint returns 404. /// - public static async Task LookupRoleByNameAsync( + public async Task LookupRoleByNameAsync( string name, string departmentId, CancellationToken cancellationToken = default) @@ -110,11 +125,11 @@ public static async Task LookupRoleByNameAsync( return await TryGetAsync(url, cancellationToken).ConfigureAwait(false); } - private static async Task TryGetAsync(string url, CancellationToken cancellationToken) where T : class + private async Task TryGetAsync(string url, CancellationToken cancellationToken) where T : class { try { - var response = await ResgridV4ApiClient.GetAsync>(url, cancellationToken).ConfigureAwait(false); + var response = await _client.GetAsync>(url, cancellationToken).ConfigureAwait(false); return response?.Data; } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) diff --git a/Providers/Resgrid.Providers.ApiClient/V4/Models/ActiveCallsModels.cs b/Providers/Resgrid.Providers.ApiClient/V4/Models/ActiveCallsModels.cs new file mode 100644 index 0000000..9a64fb0 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/Models/ActiveCallsModels.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Resgrid.Providers.ApiClient.V4.Models +{ + /// + /// Response wrapper for GET /api/v4/Calls/GetActiveCalls. Used by the dispatch + /// tone-out mode to discover newly created calls that should be announced into a + /// department's PTT channel. Reuses for each call. + /// + public sealed class ActiveCallsResult + { + [JsonPropertyName("Data")] + public List Data { get; set; } = new List(); + + [JsonPropertyName("Status")] + public string Status { get; set; } + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/V4/Models/VoiceModels.cs b/Providers/Resgrid.Providers.ApiClient/V4/Models/VoiceModels.cs new file mode 100644 index 0000000..bb78942 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/Models/VoiceModels.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Resgrid.Providers.ApiClient.V4.Models +{ + // These models mirror the Resgrid Core v4 VoiceController responses + // (DepartmentVoiceResult / CanConnectToVoiceSessionResult). They give the + // Relay everything it needs to join a department's PTT channel (LiveKit room): + // the websocket server URL, the per-channel room id (= DepartmentVoiceChannelId) + // and a pre-minted LiveKit access token (JWT, ~24h TTL). + + /// + /// Response from GET /api/v4/Voice/GetDepartmentVoiceSettings. + /// + public sealed class DepartmentVoiceResult + { + [JsonPropertyName("Data")] + public DepartmentVoiceResultData Data { get; set; } + + [JsonPropertyName("Status")] + public string Status { get; set; } + } + + public sealed class DepartmentVoiceResultData + { + /// Whether voice (PTT) is enabled for the department. + [JsonPropertyName("VoiceEnabled")] + public bool VoiceEnabled { get; set; } + + /// VoipProviderType. 2 == LiveKit. + [JsonPropertyName("Type")] + public int Type { get; set; } + + /// VoIP domain / realm. + [JsonPropertyName("Realm")] + public string Realm { get; set; } + + /// + /// Client-facing LiveKit websocket URL (e.g. wss://livekit.example.com). + /// Pass this verbatim to Room.ConnectAsync. + /// + [JsonPropertyName("VoipServerWebsocketSslAddress")] + public string VoipServerWebsocketSslAddress { get; set; } + + /// Display name embedded in the issued tokens (the current user). + [JsonPropertyName("CallerIdName")] + public string CallerIdName { get; set; } + + /// + /// Encrypted department id used as the token for the anonymous + /// CanConnectToVoiceSession seat-limit check. + /// + [JsonPropertyName("CanConnectApiToken")] + public string CanConnectApiToken { get; set; } + + [JsonPropertyName("Channels")] + public List Channels { get; set; } = new List(); + + [JsonPropertyName("UserInfo")] + public DepartmentVoiceUserInfoResultData UserInfo { get; set; } + } + + public sealed class DepartmentVoiceChannelResultData + { + /// + /// DepartmentVoiceChannelId. This IS the LiveKit room name to join. + /// + [JsonPropertyName("Id")] + public string Id { get; set; } + + [JsonPropertyName("Name")] + public string Name { get; set; } + + [JsonPropertyName("ConferenceNumber")] + public int ConferenceNumber { get; set; } + + [JsonPropertyName("IsDefault")] + public bool IsDefault { get; set; } + + /// Pre-minted LiveKit access token (JWT) granting roomJoin for this room. + [JsonPropertyName("Token")] + public string Token { get; set; } + } + + public sealed class DepartmentVoiceUserInfoResultData + { + [JsonPropertyName("Username")] + public string Username { get; set; } + + [JsonPropertyName("Password")] + public string Password { get; set; } + + [JsonPropertyName("Pin")] + public string Pin { get; set; } + } + + /// + /// Response from GET /api/v4/Voice/CanConnectToVoiceSession. + /// Lets the relay honor a department's concurrent-seat subscription limit + /// before joining/publishing. + /// + public sealed class CanConnectToVoiceSessionResult + { + [JsonPropertyName("Data")] + public CanConnectToVoiceSessionResultData Data { get; set; } + + [JsonPropertyName("Status")] + public string Status { get; set; } + } + + public sealed class CanConnectToVoiceSessionResultData + { + [JsonPropertyName("CanConnect")] + public bool CanConnect { get; set; } + + [JsonPropertyName("CurrentSessions")] + public int CurrentSessions { get; set; } + + [JsonPropertyName("MaxSessions")] + public int MaxSessions { get; set; } + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs b/Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs index 1219d36..ab858ac 100644 --- a/Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs +++ b/Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs @@ -1,4 +1,6 @@ using System; +using System.IO; +using System.Linq; namespace Resgrid.Providers.ApiClient.V4 { @@ -63,6 +65,54 @@ public sealed class ResgridApiClientOptions /// public string DepartmentId { get; set; } + /// + /// Returns a shallow copy of these options whose + /// is rewritten to a per- location so that + /// concurrently running relay modes do not share (and clobber) a single + /// on-disk token cache. The discriminator is sanitized and inserted as a + /// subfolder of the configured cache path's directory + /// (e.g. ./data/resgrid-token.json becomes + /// ./data/<discriminator>/resgrid-token.json). + /// + /// When no discriminator is supplied or no is + /// configured, the original options instance is returned unchanged. + /// + public ResgridApiClientOptions WithIsolatedTokenCache(string discriminator) + { + if (String.IsNullOrWhiteSpace(discriminator) || String.IsNullOrWhiteSpace(TokenCachePath)) + return this; + + var sanitized = SanitizeDiscriminator(discriminator); + if (String.IsNullOrWhiteSpace(sanitized)) + return this; + + var directory = Path.GetDirectoryName(TokenCachePath); + var fileName = Path.GetFileName(TokenCachePath); + var isolatedPath = String.IsNullOrWhiteSpace(directory) + ? Path.Combine(sanitized, fileName) + : Path.Combine(directory, sanitized, fileName); + + return new ResgridApiClientOptions + { + BaseUrl = BaseUrl, + ApiVersion = ApiVersion, + ClientId = ClientId, + ClientSecret = ClientSecret, + RefreshToken = RefreshToken, + Scope = Scope, + TokenCachePath = isolatedPath, + GrantType = GrantType, + SystemApiKey = SystemApiKey, + DepartmentId = DepartmentId + }; + } + + private static string SanitizeDiscriminator(string discriminator) + { + var invalid = Path.GetInvalidFileNameChars(); + return new string(discriminator.Trim().Select(c => invalid.Contains(c) ? '_' : c).ToArray()); + } + public void Validate() { if (String.IsNullOrWhiteSpace(BaseUrl)) diff --git a/Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs b/Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs index 158893b..8a4ac7c 100644 --- a/Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs +++ b/Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs @@ -15,11 +15,11 @@ namespace Resgrid.Providers.ApiClient.V4 { - public static class ResgridV4ApiClient + public sealed class ResgridV4ApiClient : IResgridApiClient, IDisposable { private const string SystemApiKeyHeaderName = "X-Resgrid-SystemApiKey"; - private static readonly SemaphoreSlim AuthLock = new SemaphoreSlim(1, 1); + private readonly SemaphoreSlim AuthLock = new SemaphoreSlim(1, 1); private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, @@ -27,23 +27,12 @@ public static class ResgridV4ApiClient WriteIndented = true }; - private static HttpClient _client = CreateClient("https://api.resgrid.com"); - private static OpenIdConfiguration _openIdConfiguration; - private static ResgridApiClientOptions _options = new ResgridApiClientOptions(); - private static ResgridApiTokenState _tokenState = new ResgridApiTokenState(); + private HttpClient _client; + private OpenIdConfiguration _openIdConfiguration; + private ResgridApiClientOptions _options; + private ResgridApiTokenState _tokenState; - public static string CurrentUserId - { - get - { - if (_options.GrantType == ResgridAuthGrantType.SystemApiKey && !String.IsNullOrWhiteSpace(_options.DepartmentId)) - return _options.DepartmentId; - - return _tokenState?.UserId; - } - } - - public static void Init(ResgridApiClientOptions options) + public ResgridV4ApiClient(ResgridApiClientOptions options) { if (options == null) throw new ArgumentNullException(nameof(options)); @@ -54,28 +43,44 @@ public static void Init(ResgridApiClientOptions options) _openIdConfiguration = null; _tokenState = LoadTokenState(options); - _client.Dispose(); _client = CreateClient(options.BaseUrl); } - public static async Task IsHealthyAsync(CancellationToken cancellationToken = default) + public string CurrentUserId + { + get + { + if (_options.GrantType == ResgridAuthGrantType.SystemApiKey && !String.IsNullOrWhiteSpace(_options.DepartmentId)) + return _options.DepartmentId; + + return _tokenState?.UserId; + } + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken = default) { using var response = await SendRawAsync(HttpMethod.Get, "Health/GetCurrent", null, cancellationToken).ConfigureAwait(false); return response.IsSuccessStatusCode; } - public static async Task GetAsync(string url, CancellationToken cancellationToken = default) where T : class + public async Task GetAsync(string url, CancellationToken cancellationToken = default) where T : class { using var response = await SendRawAsync(HttpMethod.Get, url, null, cancellationToken).ConfigureAwait(false); return await ReadResponseAsync(response, cancellationToken).ConfigureAwait(false); } - public static async Task PostAsync(string url, object data, CancellationToken cancellationToken = default) where T : class + public async Task PostAsync(string url, object data, CancellationToken cancellationToken = default) where T : class { using var response = await SendRawAsync(HttpMethod.Post, url, data, cancellationToken).ConfigureAwait(false); return await ReadResponseAsync(response, cancellationToken).ConfigureAwait(false); } + public void Dispose() + { + _client?.Dispose(); + AuthLock?.Dispose(); + } + private static HttpClient CreateClient(string baseUrl) { var client = new HttpClient(); @@ -86,7 +91,7 @@ private static HttpClient CreateClient(string baseUrl) return client; } - private static async Task SendRawAsync(HttpMethod method, string url, object data, CancellationToken cancellationToken) + private async Task SendRawAsync(HttpMethod method, string url, object data, CancellationToken cancellationToken) { await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false); @@ -108,7 +113,7 @@ private static async Task SendRawAsync(HttpMethod method, s return response; } - private static async Task SendAuthorizedRequestAsync(HttpMethod method, string url, object data, string accessToken, CancellationToken cancellationToken) + private async Task SendAuthorizedRequestAsync(HttpMethod method, string url, object data, string accessToken, CancellationToken cancellationToken) { var request = new HttpRequestMessage(method, url); @@ -130,7 +135,7 @@ private static async Task SendAuthorizedRequestAsync(HttpMe return await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); } - private static async Task ReadResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) where T : class + private async Task ReadResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) where T : class { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) @@ -148,7 +153,7 @@ private static async Task ReadResponseAsync(HttpResponseMessage response, return result; } - private static async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken) + private async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken) { // SystemApiKey mode does not use OAuth tokens — the key is // attached directly to every request. @@ -240,13 +245,13 @@ private static async Task EnsureAuthenticatedAsync(CancellationToken cancellatio } } - private static bool HasValidAccessToken() + private bool HasValidAccessToken() { return !String.IsNullOrWhiteSpace(_tokenState?.AccessToken) && _tokenState.ExpiresAtUtc > DateTimeOffset.UtcNow.AddMinutes(1); } - private static async Task GetOpenIdConfigurationAsync(CancellationToken cancellationToken) + private async Task GetOpenIdConfigurationAsync(CancellationToken cancellationToken) { var discoveryUri = new Uri(_client.BaseAddress, ".well-known/openid-configuration"); using var response = await _client.GetAsync(discoveryUri, cancellationToken).ConfigureAwait(false); @@ -266,7 +271,7 @@ private static async Task GetOpenIdConfigurationAsync(Cance return configuration; } - private static string BuildRelativeApiPath(string url) + private string BuildRelativeApiPath(string url) { var relativeUrl = url?.TrimStart('/') ?? String.Empty; return $"api/v{_options.ApiVersion}/{relativeUrl}"; @@ -277,7 +282,7 @@ private static string NormalizeBaseUrl(string baseUrl) return baseUrl.TrimEnd('/') + "/"; } - private static string ResolveRefreshToken() + private string ResolveRefreshToken() { if (!String.IsNullOrWhiteSpace(_tokenState?.RefreshToken)) return _tokenState.RefreshToken; @@ -316,7 +321,7 @@ private static ResgridApiTokenState LoadTokenState(ResgridApiClientOptions optio return cachedState; } - private static async Task PersistTokenStateAsync(ResgridApiTokenState tokenState, CancellationToken cancellationToken) + private async Task PersistTokenStateAsync(ResgridApiTokenState tokenState, CancellationToken cancellationToken) { if (String.IsNullOrWhiteSpace(_options.TokenCachePath)) return; diff --git a/Providers/Resgrid.Providers.ApiClient/V4/VoiceApi.cs b/Providers/Resgrid.Providers.ApiClient/V4/VoiceApi.cs new file mode 100644 index 0000000..d08d5f8 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/VoiceApi.cs @@ -0,0 +1,89 @@ +using Resgrid.Providers.ApiClient.V4.Models; +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Providers.ApiClient.V4 +{ + /// + /// v4 API methods for the Resgrid voice (push-to-talk) subsystem. These return + /// the LiveKit connection details (server URL + per-channel room id and access + /// token) the relay needs to join a department's PTT channel, and the seat-limit + /// check used before connecting. + /// + /// Mirrors the Resgrid Core VoiceController: + /// GET /api/v4/Voice/GetDepartmentVoiceSettings + /// GET /api/v4/Voice/CanConnectToVoiceSession?token={token} + /// + public interface IResgridVoiceApi + { + Task GetDepartmentVoiceSettingsAsync(string departmentId = null, CancellationToken cancellationToken = default); + Task CanConnectToVoiceSessionAsync(string token, CancellationToken cancellationToken = default); + } + + public sealed class VoiceApi : IResgridVoiceApi + { + private readonly IResgridApiClient _client; + + public VoiceApi(IResgridApiClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + /// + /// Gets the calling user's department voice settings, including the LiveKit + /// server URL and every voice channel with a pre-minted join token. + /// + /// is optional and only sent when supplied; + /// it is used by hosted/system-key deployments that act on behalf of a + /// specific department. In normal (per-user) mode the department is taken + /// from the authenticated context. + /// + public async Task GetDepartmentVoiceSettingsAsync( + string departmentId = null, + CancellationToken cancellationToken = default) + { + var url = "Voice/GetDepartmentVoiceSettings"; + if (!String.IsNullOrWhiteSpace(departmentId)) + url += $"?departmentId={Uri.EscapeDataString(departmentId)}"; + + var response = await _client + .GetAsync(url, cancellationToken) + .ConfigureAwait(false); + + return response?.Data; + } + + /// + /// Checks whether another participant may connect to the department's voice + /// session without exceeding the subscription seat limit. The token is the + /// value returned by + /// . Returns null if the endpoint + /// is unavailable (treat as "unknown" and proceed per policy). + /// + public async Task CanConnectToVoiceSessionAsync( + string token, + CancellationToken cancellationToken = default) + { + if (String.IsNullOrWhiteSpace(token)) + return null; + + var url = $"Voice/CanConnectToVoiceSession?token={Uri.EscapeDataString(token)}"; + + try + { + var response = await _client + .GetAsync(url, cancellationToken) + .ConfigureAwait(false); + + return response?.Data; + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } + } +} diff --git a/Resgrid Audio.sln b/Resgrid Audio.sln index 0d9f6aa..3187332 100644 --- a/Resgrid Audio.sln +++ b/Resgrid Audio.sln @@ -15,54 +15,118 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Providers", "Providers", "{ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Providers.ApiClient", "Providers\Resgrid.Providers.ApiClient\Resgrid.Providers.ApiClient.csproj", "{7AA51788-4951-4EC0-8FEB-8381200600C4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Audio.Voice", "Resgrid.Audio.Voice\Resgrid.Audio.Voice.csproj", "{9ED39807-B839-4F68-82BA-FD4ACA32AB0E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Audio.Voice.Tests", "Resgrid.Audio.Voice.Tests\Resgrid.Audio.Voice.Tests.csproj", "{AB465E37-9A39-42EC-9E4D-A684F8513A1C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Relay.Engine", "Resgrid.Relay.Engine\Resgrid.Relay.Engine.csproj", "{CDCEC8D6-76CB-439A-95BA-31B401A83265}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|x86 = Debug|x86 + Debug|x64 = Debug|x64 Release|Any CPU = Release|Any CPU Release|x86 = Release|x86 + Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Debug|Any CPU.Build.0 = Debug|Any CPU {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Debug|x86.ActiveCfg = Debug|Any CPU {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Debug|x86.Build.0 = Debug|Any CPU + {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Debug|x64.ActiveCfg = Debug|Any CPU + {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Debug|x64.Build.0 = Debug|Any CPU {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Release|Any CPU.ActiveCfg = Release|Any CPU {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Release|Any CPU.Build.0 = Release|Any CPU {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Release|x86.ActiveCfg = Release|Any CPU {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Release|x86.Build.0 = Release|Any CPU + {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Release|x64.ActiveCfg = Release|Any CPU + {64D892BF-42A5-40B6-93A4-E9ADE0774ABF}.Release|x64.Build.0 = Release|Any CPU {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Debug|Any CPU.Build.0 = Debug|Any CPU {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Debug|x86.ActiveCfg = Debug|Any CPU {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Debug|x86.Build.0 = Debug|Any CPU + {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Debug|x64.ActiveCfg = Debug|Any CPU + {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Debug|x64.Build.0 = Debug|Any CPU {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Release|Any CPU.ActiveCfg = Release|Any CPU {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Release|Any CPU.Build.0 = Release|Any CPU {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Release|x86.ActiveCfg = Release|Any CPU {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Release|x86.Build.0 = Release|Any CPU + {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Release|x64.ActiveCfg = Release|Any CPU + {B521F5CB-3C81-4E6F-8327-758CDA0C4D77}.Release|x64.Build.0 = Release|Any CPU {17987428-A954-4A78-A286-BF91E76BBCF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {17987428-A954-4A78-A286-BF91E76BBCF8}.Debug|Any CPU.Build.0 = Debug|Any CPU {17987428-A954-4A78-A286-BF91E76BBCF8}.Debug|x86.ActiveCfg = Debug|Any CPU {17987428-A954-4A78-A286-BF91E76BBCF8}.Debug|x86.Build.0 = Debug|Any CPU + {17987428-A954-4A78-A286-BF91E76BBCF8}.Debug|x64.ActiveCfg = Debug|Any CPU + {17987428-A954-4A78-A286-BF91E76BBCF8}.Debug|x64.Build.0 = Debug|Any CPU {17987428-A954-4A78-A286-BF91E76BBCF8}.Release|Any CPU.ActiveCfg = Release|Any CPU {17987428-A954-4A78-A286-BF91E76BBCF8}.Release|Any CPU.Build.0 = Release|Any CPU {17987428-A954-4A78-A286-BF91E76BBCF8}.Release|x86.ActiveCfg = Release|Any CPU {17987428-A954-4A78-A286-BF91E76BBCF8}.Release|x86.Build.0 = Release|Any CPU + {17987428-A954-4A78-A286-BF91E76BBCF8}.Release|x64.ActiveCfg = Release|Any CPU + {17987428-A954-4A78-A286-BF91E76BBCF8}.Release|x64.Build.0 = Release|Any CPU {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Debug|Any CPU.Build.0 = Debug|Any CPU {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Debug|x86.ActiveCfg = Debug|Any CPU {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Debug|x86.Build.0 = Debug|Any CPU + {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Debug|x64.ActiveCfg = Debug|Any CPU + {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Debug|x64.Build.0 = Debug|Any CPU {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Release|Any CPU.ActiveCfg = Release|Any CPU {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Release|Any CPU.Build.0 = Release|Any CPU {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Release|x86.ActiveCfg = Release|Any CPU {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Release|x86.Build.0 = Release|Any CPU + {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Release|x64.ActiveCfg = Release|Any CPU + {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF}.Release|x64.Build.0 = Release|Any CPU {7AA51788-4951-4EC0-8FEB-8381200600C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7AA51788-4951-4EC0-8FEB-8381200600C4}.Debug|Any CPU.Build.0 = Debug|Any CPU {7AA51788-4951-4EC0-8FEB-8381200600C4}.Debug|x86.ActiveCfg = Debug|Any CPU {7AA51788-4951-4EC0-8FEB-8381200600C4}.Debug|x86.Build.0 = Debug|Any CPU + {7AA51788-4951-4EC0-8FEB-8381200600C4}.Debug|x64.ActiveCfg = Debug|Any CPU + {7AA51788-4951-4EC0-8FEB-8381200600C4}.Debug|x64.Build.0 = Debug|Any CPU {7AA51788-4951-4EC0-8FEB-8381200600C4}.Release|Any CPU.ActiveCfg = Release|Any CPU {7AA51788-4951-4EC0-8FEB-8381200600C4}.Release|Any CPU.Build.0 = Release|Any CPU {7AA51788-4951-4EC0-8FEB-8381200600C4}.Release|x86.ActiveCfg = Release|Any CPU {7AA51788-4951-4EC0-8FEB-8381200600C4}.Release|x86.Build.0 = Release|Any CPU + {7AA51788-4951-4EC0-8FEB-8381200600C4}.Release|x64.ActiveCfg = Release|Any CPU + {7AA51788-4951-4EC0-8FEB-8381200600C4}.Release|x64.Build.0 = Release|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Debug|x86.ActiveCfg = Debug|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Debug|x86.Build.0 = Debug|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Debug|x64.ActiveCfg = Debug|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Debug|x64.Build.0 = Debug|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Release|Any CPU.Build.0 = Release|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Release|x86.ActiveCfg = Release|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Release|x86.Build.0 = Release|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Release|x64.ActiveCfg = Release|Any CPU + {9ED39807-B839-4F68-82BA-FD4ACA32AB0E}.Release|x64.Build.0 = Release|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Debug|x86.ActiveCfg = Debug|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Debug|x86.Build.0 = Debug|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Debug|x64.ActiveCfg = Debug|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Debug|x64.Build.0 = Debug|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Release|Any CPU.Build.0 = Release|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Release|x86.ActiveCfg = Release|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Release|x86.Build.0 = Release|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Release|x64.ActiveCfg = Release|Any CPU + {AB465E37-9A39-42EC-9E4D-A684F8513A1C}.Release|x64.Build.0 = Release|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Debug|x86.ActiveCfg = Debug|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Debug|x86.Build.0 = Debug|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Debug|x64.ActiveCfg = Debug|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Debug|x64.Build.0 = Debug|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Release|Any CPU.Build.0 = Release|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Release|x86.ActiveCfg = Release|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Release|x86.Build.0 = Release|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Release|x64.ActiveCfg = Release|Any CPU + {CDCEC8D6-76CB-439A-95BA-31B401A83265}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Resgrid.Audio.Core/ComService.cs b/Resgrid.Audio.Core/ComService.cs index 59f2c08..2e0712c 100644 --- a/Resgrid.Audio.Core/ComService.cs +++ b/Resgrid.Audio.Core/ComService.cs @@ -16,15 +16,21 @@ public class ComService { private readonly Logger _logger; private readonly AudioProcessor _audioProcessor; + private readonly IResgridApiClient _apiClient; + private readonly IResgridHealthApi _healthApi; + private readonly IResgridCallsApi _callsApi; private Config _config; public event EventHandler CallCreatedEvent; - public ComService(Logger logger, AudioProcessor audioProcessor) + public ComService(Logger logger, AudioProcessor audioProcessor, IResgridApiClient apiClient, IResgridHealthApi healthApi, IResgridCallsApi callsApi) { _logger = logger; _audioProcessor = audioProcessor; + _apiClient = apiClient; + _healthApi = healthApi; + _callsApi = callsApi; } public void Init(Config config) @@ -38,7 +44,7 @@ public bool IsConnectionValid() { try { - return HealthApi.IsHealthyAsync().GetAwaiter().GetResult(); + return _healthApi.IsHealthyAsync().GetAwaiter().GetResult(); } catch (HttpRequestException ex) { @@ -81,12 +87,12 @@ private void _audioProcessor_TriggerProcessingFinished(object sender, Events.Tri Type = "Relay Audio" }; - var savedCallId = CallsApi.SaveCallAsync(callInput).GetAwaiter().GetResult(); - var userId = ResgridV4ApiClient.CurrentUserId; + var savedCallId = _callsApi.SaveCallAsync(callInput).GetAwaiter().GetResult(); + var userId = _apiClient.CurrentUserId; if (String.IsNullOrWhiteSpace(userId)) throw new InvalidOperationException("The Resgrid access token did not contain a user id required to upload the dispatch audio."); - CallsApi.SaveCallFileAsync(new SaveCallFileInput + _callsApi.SaveCallFileAsync(new SaveCallFileInput { CallId = savedCallId, UserId = userId, @@ -96,7 +102,7 @@ private void _audioProcessor_TriggerProcessingFinished(object sender, Events.Tri Note = $"Captured dispatch audio for watcher {e.Watcher.Name}" }).GetAwaiter().GetResult(); - var savedCall = CallsApi.GetCallAsync(savedCallId).GetAwaiter().GetResult(); + var savedCall = _callsApi.GetCallAsync(savedCallId).GetAwaiter().GetResult(); CallCreatedEvent?.Invoke( this, new CallCreatedEventArgs(e.Watcher.Name, savedCallId, savedCall?.Data?.Number, DateTime.Now)); diff --git a/Resgrid.Audio.Core/Radio/CarrierDetector.cs b/Resgrid.Audio.Core/Radio/CarrierDetector.cs new file mode 100644 index 0000000..0149d38 --- /dev/null +++ b/Resgrid.Audio.Core/Radio/CarrierDetector.cs @@ -0,0 +1,81 @@ +using System; +using System.IO.Ports; +using Serilog; + +namespace Resgrid.Audio.Core.Radio +{ + /// + /// Reports whether the radio currently has a carrier (squelch open), used when the + /// squelch mode is hardware Carrier/COR/COS rather than audio VOX. + /// + public interface ICarrierDetector : IDisposable + { + bool IsCarrierPresent { get; } + } + + /// A carrier detector that is always considered open (no hardware COR). + public sealed class NullCarrierDetector : ICarrierDetector + { + public bool IsCarrierPresent => true; + public void Dispose() { } + } + + /// + /// Reads carrier-detect (COR/COS) from a serial control pin (CTS or DSR). Many + /// interface cables route the radio's COR/SQL output to one of these pins. + /// + public sealed class SerialCarrierDetector : ICarrierDetector + { + private readonly SerialPort _port; + private readonly bool _useDsr; + private readonly bool _inverted; + private readonly bool _ownsPort; + private readonly ILogger _logger; + + public SerialCarrierDetector(string portName, bool useDsr, bool inverted, ILogger logger, SerialPort sharedPort = null) + { + _useDsr = useDsr; + _inverted = inverted; + _logger = logger; + + if (sharedPort != null) + { + _port = sharedPort; + _ownsPort = false; + } + else + { + _port = new SerialPort(portName) { ReadTimeout = 200, WriteTimeout = 200 }; + _ownsPort = true; + } + + if (!_port.IsOpen) + _port.Open(); + } + + public bool IsCarrierPresent + { + get + { + try + { + bool raw = _useDsr ? _port.DsrHolding : _port.CtsHolding; + return _inverted ? !raw : raw; + } + catch (Exception ex) + { + _logger?.Debug(ex, "Failed reading serial carrier pin"); + return false; + } + } + } + + public void Dispose() + { + if (_ownsPort) + { + try { _port.Dispose(); } catch { /* ignore */ } + } + } + } +} diff --git a/Resgrid.Audio.Core/Radio/IPttController.cs b/Resgrid.Audio.Core/Radio/IPttController.cs new file mode 100644 index 0000000..fc97d88 --- /dev/null +++ b/Resgrid.Audio.Core/Radio/IPttController.cs @@ -0,0 +1,15 @@ +using System; + +namespace Resgrid.Audio.Core.Radio +{ + /// + /// Keys/unkeys a physical radio's push-to-talk line. Implementations cover the + /// common responder interfaces: VOX (no line), serial RTS/DTR, and CM108 GPIO. + /// + public interface IPttController : IDisposable + { + bool IsKeyed { get; } + void Key(); + void Unkey(); + } +} diff --git a/Resgrid.Audio.Core/Radio/IRadioDevice.cs b/Resgrid.Audio.Core/Radio/IRadioDevice.cs new file mode 100644 index 0000000..7f436b7 --- /dev/null +++ b/Resgrid.Audio.Core/Radio/IRadioDevice.cs @@ -0,0 +1,26 @@ +using System; + +namespace Resgrid.Audio.Core.Radio +{ + /// + /// Abstraction over the physical radio's audio in/out (receive audio capture and + /// transmit audio playback), at the engine rate (48 kHz mono PCM16). + /// + public interface IRadioDevice : IDisposable + { + /// Raised with PCM16 mono 48 kHz frames captured from the radio receiver. + event EventHandler SamplesReceived; + + void StartReceive(); + void StopReceive(); + + /// Begins/holds the transmit audio path open (call before keying PTT). + void StartTransmit(); + + /// Queues PCM16 mono 48 kHz samples to play out to the radio's mic input. + void Transmit(short[] pcm48kMono); + + /// Stops the transmit audio path. + void StopTransmit(); + } +} diff --git a/Resgrid.Audio.Core/Radio/NAudioRadioDevice.cs b/Resgrid.Audio.Core/Radio/NAudioRadioDevice.cs new file mode 100644 index 0000000..4a2fe2c --- /dev/null +++ b/Resgrid.Audio.Core/Radio/NAudioRadioDevice.cs @@ -0,0 +1,112 @@ +using System; +using NAudio.Wave; +using Resgrid.Audio.Voice.Abstractions; +using Serilog; + +namespace Resgrid.Audio.Core.Radio +{ + /// + /// NAudio-backed radio device: captures receive audio with + /// and plays transmit audio with , both at 48 kHz mono + /// PCM16 to match the LiveKit/Opus pipeline (no resampling on the device edge). + /// + public sealed class NAudioRadioDevice : IRadioDevice + { + private readonly int _inputDevice; + private readonly int _outputDevice; + private readonly ILogger _logger; + private readonly WaveFormat _format = new WaveFormat(AudioFormat.SampleRate, 16, AudioFormat.Channels); + + private WaveInEvent _waveIn; + private WaveOutEvent _waveOut; + private BufferedWaveProvider _txBuffer; + + public event EventHandler SamplesReceived; + + public NAudioRadioDevice(int inputDevice, int outputDevice, ILogger logger) + { + _inputDevice = inputDevice; + _outputDevice = outputDevice; + _logger = logger; + } + + public void StartReceive() + { + if (_waveIn != null) + return; + + _waveIn = new WaveInEvent + { + DeviceNumber = _inputDevice, + WaveFormat = _format, + BufferMilliseconds = 20 + }; + _waveIn.DataAvailable += OnDataAvailable; + _waveIn.StartRecording(); + _logger?.Information("Radio receive started on input device {Device} @ {Rate} Hz", _inputDevice, AudioFormat.SampleRate); + } + + private void OnDataAvailable(object sender, WaveInEventArgs e) + { + int sampleCount = e.BytesRecorded / 2; + if (sampleCount <= 0) + return; + + var samples = new short[sampleCount]; + Buffer.BlockCopy(e.Buffer, 0, samples, 0, sampleCount * 2); + SamplesReceived?.Invoke(this, samples); + } + + public void StopReceive() + { + if (_waveIn == null) + return; + try { _waveIn.StopRecording(); } catch { /* ignore */ } + _waveIn.DataAvailable -= OnDataAvailable; + _waveIn.Dispose(); + _waveIn = null; + } + + public void StartTransmit() + { + if (_waveOut != null) + return; + + _txBuffer = new BufferedWaveProvider(_format) + { + DiscardOnBufferOverflow = true, + BufferDuration = TimeSpan.FromSeconds(5) + }; + _waveOut = new WaveOutEvent { DeviceNumber = _outputDevice }; + _waveOut.Init(_txBuffer); + _waveOut.Play(); + _logger?.Information("Radio transmit path opened on output device {Device}", _outputDevice); + } + + public void Transmit(short[] pcm48kMono) + { + if (_txBuffer == null || pcm48kMono == null || pcm48kMono.Length == 0) + return; + + var bytes = new byte[pcm48kMono.Length * 2]; + Buffer.BlockCopy(pcm48kMono, 0, bytes, 0, bytes.Length); + _txBuffer.AddSamples(bytes, 0, bytes.Length); + } + + public void StopTransmit() + { + if (_waveOut == null) + return; + try { _waveOut.Stop(); } catch { /* ignore */ } + _waveOut.Dispose(); + _waveOut = null; + _txBuffer = null; + } + + public void Dispose() + { + StopReceive(); + StopTransmit(); + } + } +} diff --git a/Resgrid.Audio.Core/Radio/PttControllers.cs b/Resgrid.Audio.Core/Radio/PttControllers.cs new file mode 100644 index 0000000..8b7fa97 --- /dev/null +++ b/Resgrid.Audio.Core/Radio/PttControllers.cs @@ -0,0 +1,164 @@ +using System; +using System.IO.Ports; +using System.Linq; +using HidSharp; +using Serilog; + +namespace Resgrid.Audio.Core.Radio +{ + /// + /// VOX keying: the radio interface (e.g. a SignaLink) keys from the transmit audio + /// itself, so there is no control line. We only track logical state. + /// + public sealed class VoxPttController : IPttController + { + public bool IsKeyed { get; private set; } + public void Key() => IsKeyed = true; + public void Unkey() => IsKeyed = false; + public void Dispose() { } + } + + /// + /// Keys PTT by asserting RTS or DTR on a serial / USB-serial port — the classic and + /// most universal hardware keying method (CH340/FTDI cables, RIM, homebrew). + /// + public sealed class SerialPttController : IPttController, IDisposable + { + private readonly SerialPort _port; + private readonly bool _useDtr; + private readonly bool _ownsPort; + private readonly ILogger _logger; + + public SerialPttController(string portName, bool useDtr, ILogger logger, SerialPort sharedPort = null) + { + _useDtr = useDtr; + _logger = logger; + + if (sharedPort != null) + { + _port = sharedPort; + _ownsPort = false; + } + else + { + _port = new SerialPort(portName) { ReadTimeout = 200, WriteTimeout = 200 }; + _ownsPort = true; + } + + if (!_port.IsOpen) + _port.Open(); + + Unkey(); + } + + public bool IsKeyed { get; private set; } + + public void Key() + { + Set(true); + IsKeyed = true; + } + + public void Unkey() + { + Set(false); + IsKeyed = false; + } + + private void Set(bool on) + { + try + { + if (_useDtr) + _port.DtrEnable = on; + else + _port.RtsEnable = on; + } + catch (Exception ex) + { + _logger?.Error(ex, "Failed to set serial PTT line"); + } + } + + public void Dispose() + { + try { Unkey(); } catch { /* ignore */ } + if (_ownsPort) + { + try { _port.Dispose(); } catch { /* ignore */ } + } + } + } + + /// + /// Keys PTT via a CM108/CM119 USB sound-fob GPIO line (DMK URI, RA-40/RIM-Lite, + /// RB-USB). Writes the HID output report that drives the GPIO pin. The exact pin + /// varies by interface, so it is configurable (URI/RIM commonly use GPIO3). + /// + public sealed class Cm108PttController : IPttController + { + private readonly HidDevice _device; + private readonly HidStream _stream; + private readonly int _gpioBit; + private readonly ILogger _logger; + + public Cm108PttController(int vendorId, int productId, int gpioPin, ILogger logger) + { + _logger = logger; + _gpioBit = 1 << (Math.Max(1, gpioPin) - 1); + + var devices = DeviceList.Local.GetHidDevices(vendorId, productId == 0 ? (int?)null : productId).ToList(); + _device = devices.FirstOrDefault() + ?? throw new InvalidOperationException( + $"No CM108-class HID device found (VID=0x{vendorId:X4}, PID=0x{productId:X4}). Check the radio interface is connected."); + + if (!_device.TryOpen(out _stream)) + throw new InvalidOperationException("Failed to open the CM108 HID device for PTT control."); + + Unkey(); + } + + public bool IsKeyed { get; private set; } + + public void Key() + { + WriteGpio(true); + IsKeyed = true; + } + + public void Unkey() + { + WriteGpio(false); + IsKeyed = false; + } + + private void WriteGpio(bool on) + { + // CM108 HID output report: [reportId=0, 0, GPIO data, GPIO mask, 0]. + // The data bit drives the pin; the mask marks it as an output. + var report = new byte[] + { + 0x00, + 0x00, + (byte)(on ? _gpioBit : 0x00), + (byte)_gpioBit, + 0x00 + }; + + try + { + _stream.Write(report); + } + catch (Exception ex) + { + _logger?.Error(ex, "Failed to write CM108 GPIO PTT report"); + } + } + + public void Dispose() + { + try { Unkey(); } catch { /* ignore */ } + try { _stream?.Dispose(); } catch { /* ignore */ } + } + } +} diff --git a/Resgrid.Audio.Core/Radio/RadioBridge.cs b/Resgrid.Audio.Core/Radio/RadioBridge.cs new file mode 100644 index 0000000..edabf59 --- /dev/null +++ b/Resgrid.Audio.Core/Radio/RadioBridge.cs @@ -0,0 +1,242 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Resgrid.Audio.Voice.Abstractions; +using Resgrid.Audio.Voice.Audio; +using Resgrid.Audio.Voice.Dsp; +using Resgrid.Audio.Voice.ToneOut; +using Serilog; + +namespace Resgrid.Audio.Core.Radio +{ + /// + /// The bidirectional bridge between a physical radio and a Resgrid PTT channel. + /// + /// RX path: radio receive audio → squelch/anti-static gate → high-pass + gain → + /// publish to the LiveKit channel. + /// TX path: channel audio → key PTT → play to the radio mic, with hang time, an + /// optional courtesy tone, and half-duplex anti-loop arbitration so audio is never + /// relayed back to the side it came from. + /// + public sealed class RadioBridge : IAsyncDisposable + { + private readonly IRadioDevice _device; + private readonly IPttController _ptt; + private readonly ICarrierDetector _carrier; + private readonly RadioSettings _settings; + private readonly ILogger _logger; + + private readonly SquelchGate _squelch; + private readonly CtcssDetector _ctcss; + private readonly HighPassFilter _highPass; + private readonly SoftLimiter _limiter = new SoftLimiter(-1.0); + private readonly ToneGenerator _tones = new ToneGenerator(AudioFormat.SampleRate); + + private readonly Mdc1200Decoder _mdc; + private readonly EmergencyToneDetector _emergency; + private readonly IEmergencyAlertSink _alertSink; + + private readonly Channel _publishQueue = Channel.CreateBounded( + new BoundedChannelOptions(256) { FullMode = BoundedChannelFullMode.DropOldest }); + + private IVoiceRoomSession _session; + private IAudioPublisher _publisher; + private Task _publishPump; + private Timer _txTimer; + private CancellationTokenSource _cts; + + private volatile bool _transmitting; // channel → radio active (suppress RX→channel) + private volatile bool _receiving; // radio → channel active (suppress TX→radio) + private long _lastTxTicks; + private bool _courtesyPlayed; + + public RadioBridge( + IRadioDevice device, + IPttController ptt, + ICarrierDetector carrier, + RadioSettings settings, + ILogger logger, + Mdc1200Decoder mdc = null, + EmergencyToneDetector emergency = null, + IEmergencyAlertSink alertSink = null) + { + _device = device; + _ptt = ptt; + _carrier = carrier ?? new NullCarrierDetector(); + _settings = settings; + _logger = logger; + + _squelch = new SquelchGate(settings.Squelch, AudioFormat.SampleRate); + _ctcss = new CtcssDetector(settings.Squelch.CtcssFrequency, settings.Squelch.CtcssMinStrength, AudioFormat.SampleRate); + _highPass = new HighPassFilter(Math.Max(1, settings.HighPassCutoffHz), AudioFormat.SampleRate); + + _mdc = mdc; + _emergency = emergency; + _alertSink = alertSink; + + if (_emergency != null) + _emergency.EmergencyDetected += (_, d) => RaiseEmergency("Tone", d.Detail); + if (_mdc != null) + _mdc.PacketDecoded += OnMdcPacket; + } + + public async Task StartAsync(IVoiceRoomSession session, CancellationToken cancellationToken) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + _publisher = await _session.CreatePublisherAsync("radio", cancellationToken).ConfigureAwait(false); + _publishPump = Task.Run(() => PublishPumpAsync(_cts.Token)); + + _session.AudioFrameReceived += OnChannelAudio; + _device.SamplesReceived += OnRadioAudio; + _device.StartReceive(); + _device.StartTransmit(); + + _txTimer = new Timer(_ => ManageTransmitTail(), null, 50, 50); + _logger?.Information("Radio bridge running: {Channel} <-> radio (squelch={Mode}, ptt={Ptt})", + _session.ChannelName, _settings.Squelch.Mode, _settings.Ptt); + } + + // ---- Radio receive → channel --------------------------------------------- + + private void OnRadioAudio(object sender, short[] frame) + { + if (frame == null || frame.Length == 0) + return; + + // Always feed the signaling decoders (they apply their own thresholds). + _mdc?.Process(frame); + _emergency?.Process(frame); + + bool open = IsSquelchOpen(frame); + _receiving = open; + + if (!open || (_settings.AntiLoop && _transmitting)) + return; + + // Copy + condition the audio before publishing. + var outgoing = (short[])frame.Clone(); + _highPass.Process(outgoing); + if (Math.Abs(_settings.RxGain - 1.0) > 1e-6) + Resampler.ApplyGain(outgoing, _settings.RxGain); + _limiter.Process(outgoing); + + _publishQueue.Writer.TryWrite(outgoing); + } + + private bool IsSquelchOpen(short[] frame) + { + switch (_settings.Squelch.Mode) + { + case SquelchMode.Off: return true; + case SquelchMode.Carrier: return _carrier.IsCarrierPresent; + case SquelchMode.Ctcss: return _ctcss.Process(frame); + case SquelchMode.Vox: + default: return _squelch.Process(frame); + } + } + + private async Task PublishPumpAsync(CancellationToken token) + { + try + { + await foreach (var frame in _publishQueue.Reader.ReadAllAsync(token).ConfigureAwait(false)) + { + if (_publisher != null) + await _publisher.WriteAsync(frame, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { /* shutting down */ } + catch (Exception ex) { _logger?.Error(ex, "Radio publish pump failed"); } + } + + // ---- Channel → radio transmit -------------------------------------------- + + private void OnChannelAudio(object sender, VoiceAudioFrame frame) + { + if (frame?.Pcm == null || frame.Pcm.Length == 0) + return; + + // Half-duplex: don't transmit while the radio is actively receiving. + if (_settings.AntiLoop && _receiving) + return; + + if (!_ptt.IsKeyed) + { + _ptt.Key(); + _transmitting = true; + _courtesyPlayed = false; + _logger?.Debug("PTT keyed (channel audio from {Who})", frame.Participant?.ToString() ?? "remote"); + } + + var tx = (short[])frame.Pcm.Clone(); + if (Math.Abs(_settings.TxGain - 1.0) > 1e-6) + Resampler.ApplyGain(tx, _settings.TxGain); + _limiter.Process(tx); + + _device.Transmit(tx); + Interlocked.Exchange(ref _lastTxTicks, DateTime.UtcNow.Ticks); + } + + private void ManageTransmitTail() + { + if (!_ptt.IsKeyed) + return; + + var idleMs = (DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastTxTicks), DateTimeKind.Utc)).TotalMilliseconds; + + if (idleMs >= _settings.TxHangMs && !_courtesyPlayed && _settings.CourtesyTone) + { + _device.Transmit(_tones.CourtesyBeep()); + _courtesyPlayed = true; + return; + } + + if (idleMs >= _settings.TxHangMs + _settings.TxTailMs) + { + _ptt.Unkey(); + _transmitting = false; + _logger?.Debug("PTT unkeyed after {Idle:0} ms idle", idleMs); + } + } + + private void OnMdcPacket(object sender, Mdc1200Packet packet) + { + _logger?.Information("MDC-1200 decoded: {Packet}", packet); + if (packet.IsEmergency(new Mdc1200Settings())) + RaiseEmergency("MDC-1200", $"Emergency from unit {packet.UnitId:X4} (op 0x{packet.Op:X2})"); + } + + private void RaiseEmergency(string source, string detail) + { + if (_alertSink == null) + return; + _ = _alertSink.RaiseAsync(source, detail); + } + + public async ValueTask DisposeAsync() + { + try { _cts?.Cancel(); } catch { /* ignore */ } + _publishQueue.Writer.TryComplete(); + + if (_session != null) + _session.AudioFrameReceived -= OnChannelAudio; + _device.SamplesReceived -= OnRadioAudio; + + if (_txTimer != null) + await _txTimer.DisposeAsync().ConfigureAwait(false); + + try { _ptt.Unkey(); } catch { /* ignore */ } + + try { if (_publishPump != null) await _publishPump.ConfigureAwait(false); } catch { /* ignore */ } + if (_publisher != null) + await _publisher.DisposeAsync().ConfigureAwait(false); + + _device.StopTransmit(); + _device.StopReceive(); + _cts?.Dispose(); + } + } +} diff --git a/Resgrid.Audio.Core/Radio/RadioSettings.cs b/Resgrid.Audio.Core/Radio/RadioSettings.cs new file mode 100644 index 0000000..1de61d8 --- /dev/null +++ b/Resgrid.Audio.Core/Radio/RadioSettings.cs @@ -0,0 +1,86 @@ +using Resgrid.Audio.Voice.Dsp; + +namespace Resgrid.Audio.Core.Radio +{ + /// How the relay keys the radio's PTT (transmit) line. + public enum PttKeyingMethod + { + /// No keying line — rely on the radio interface's own VOX (e.g. SignaLink). + Vox = 0, + + /// Assert RTS on a serial/USB-serial port (CH340/FTDI, RIM, homebrew cables). + SerialRts = 1, + + /// Assert DTR on a serial/USB-serial port. + SerialDtr = 2, + + /// Toggle a CM108/CM119 USB sound-fob GPIO (DMK URI, RA-series, RIM-Lite). + Cm108 = 3 + } + + /// Where the relay reads carrier-detect (COR/COS) when squelch mode is Carrier. + public enum CarrierDetectSource + { + None = 0, + SerialCts = 1, + SerialDsr = 2, + Cm108Gpio = 3 + } + + /// + /// Configuration for the physical radio interface: audio devices, PTT keying, + /// carrier detect, and the transmit-side tuning (gain, hang/tail, courtesy tone, + /// anti-loop). The receive-side anti-static tuning lives in . + /// + public sealed class RadioSettings + { + /// WaveIn device index for radio receive audio. + public int InputDevice { get; set; } + + /// WaveOut device index for radio transmit audio (-1 = system default). + public int OutputDevice { get; set; } = -1; + + public PttKeyingMethod Ptt { get; set; } = PttKeyingMethod.Vox; + + /// Serial port name (COM3, /dev/ttyUSB0) for serial PTT and/or carrier detect. + public string SerialPort { get; set; } = ""; + + /// CM108 USB device VID (default C-Media 0x0D8C). + public int Cm108VendorId { get; set; } = 0x0D8C; + + /// CM108 USB device PID (0 = match any C-Media device). + public int Cm108ProductId { get; set; } = 0; + + /// CM108 GPIO pin used for PTT (1–4; URI/RIM commonly use GPIO3). + public int Cm108GpioPin { get; set; } = 3; + + public CarrierDetectSource CarrierDetect { get; set; } = CarrierDetectSource.None; + + /// Invert the carrier-detect polarity (some interfaces are active-low). + public bool CarrierDetectInverted { get; set; } + + /// Play a short courtesy beep on the channel when the radio unkeys. + public bool CourtesyTone { get; set; } = true; + + /// Keep PTT keyed this long after channel audio stops (avoids choppy tails). + public int TxHangMs { get; set; } = 300; + + /// Extra dead-carrier time after audio before dropping PTT. + public int TxTailMs { get; set; } = 150; + + /// Prevent relaying audio back to the side it came from (half-duplex arbitration). + public bool AntiLoop { get; set; } = true; + + /// Linear gain applied to audio transmitted to the radio. + public double TxGain { get; set; } = 1.0; + + /// Linear gain applied to received radio audio before publishing. + public double RxGain { get; set; } = 1.0; + + /// High-pass cutoff (Hz) applied to received audio to strip sub-audible tones/hum. + public int HighPassCutoffHz { get; set; } = 250; + + /// Receive-side squelch / anti-static settings. + public SquelchSettings Squelch { get; set; } = new SquelchSettings(); + } +} diff --git a/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj b/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj index ededdf9..dc62a20 100644 --- a/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj +++ b/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj @@ -10,8 +10,10 @@ + - + + @@ -28,5 +30,6 @@ + diff --git a/Resgrid.Audio.Relay.Console/Program.cs b/Resgrid.Audio.Relay.Console/Program.cs index 265d042..0714763 100644 --- a/Resgrid.Audio.Relay.Console/Program.cs +++ b/Resgrid.Audio.Relay.Console/Program.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; -using Resgrid.Audio.Relay.Console.Configuration; -using Resgrid.Audio.Relay.Console.Smtp; +using Resgrid.Relay.Engine; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Relay.Engine.Smtp; using Resgrid.Providers.ApiClient.V4; using Serilog; using Serilog.Core; @@ -12,10 +13,13 @@ using System.Threading; using System.Threading.Tasks; using Cli = System.Console; +using EngineVoice = Resgrid.Relay.Engine.Voice; #if NET10_0_WINDOWS using NAudio.Wave; using Resgrid.Audio.Core; using Resgrid.Audio.Core.Model; +using System.IO.Ports; +using HidSharp; #endif namespace Resgrid.Audio.Relay.Console @@ -42,6 +46,8 @@ public static async Task Main(string[] args) return ShowDevices(); case "monitor": return await MonitorAsync(args.Skip(1).ToArray()).ConfigureAwait(false); + case "tune": + return await TuneAsync(args.Skip(1).ToArray()).ConfigureAwait(false); case "version": case "--version": case "-v": @@ -68,6 +74,10 @@ private static async Task RunAsync() if (!ValidateOptions(hostOptions)) return 1; + // Optional LocalXpose tunnel (replaces the old shell entrypoint logic so the + // container can run on a shell-less hardened base image). + using var loclxTunnel = LoclxTunnel.StartIfEnabled(hostOptions); + using var cancellationTokenSource = new CancellationTokenSource(); ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => { @@ -84,14 +94,20 @@ private static async Task RunAsync() { var smtpLogger = CreateLogger(debug: false); await using var telemetry = SmtpTelemetry.Create(hostOptions, smtpLogger); - ResgridV4ApiClient.Init(hostOptions.Resgrid); - await SmtpRelayRunner.RunAsync(hostOptions.Smtp, telemetry, cancellationTokenSource.Token).ConfigureAwait(false); + using var apiClient = new ResgridV4ApiClient(hostOptions.Resgrid); + await SmtpRelayRunner.RunAsync(hostOptions.Smtp, telemetry, apiClient, cancellationTokenSource.Token).ConfigureAwait(false); return 0; } case "audio": return await RunAudioModeAsync(hostOptions, cancellationTokenSource.Token).ConfigureAwait(false); + case "radio": + return await RunRadioModeAsync(hostOptions, cancellationTokenSource.Token).ConfigureAwait(false); + case "record": + return await EngineVoice.RecordMode.RunAsync(hostOptions, CreateLogger(debug: false), cancellationTokenSource.Token).ConfigureAwait(false); + case "dispatch": + return await EngineVoice.DispatchVoiceMode.RunAsync(hostOptions, CreateLogger(debug: false), cancellationTokenSource.Token).ConfigureAwait(false); default: - Cli.Error.WriteLine($"Unsupported relay mode '{hostOptions.Mode}'. Supported modes are 'smtp' and 'audio'."); + Cli.Error.WriteLine($"Unsupported relay mode '{hostOptions.Mode}'. Supported modes are 'smtp', 'audio', 'radio', 'record' and 'dispatch'."); return 1; } } @@ -189,12 +205,39 @@ private static void ShowHelp() Cli.WriteLine("Commands:"); Cli.WriteLine(" run Starts the relay in the configured mode (default)"); Cli.WriteLine(" setup Creates or updates the Windows audio settings.json file"); - Cli.WriteLine(" devices Lists Windows audio input devices"); + Cli.WriteLine(" devices Lists audio in/out devices, serial ports and CM108 HID (radio mode)"); Cli.WriteLine(" monitor Monitors a Windows audio device without dispatching calls"); + Cli.WriteLine(" tune Live receive-level meter + squelch state to tune anti-static (radio mode)"); Cli.WriteLine(" version Prints the application version"); Cli.WriteLine(); + Cli.WriteLine("Modes (RESGRID__RELAY__Mode):"); + Cli.WriteLine(" smtp SMTP dispatch relay (default, cross-platform)"); + Cli.WriteLine(" audio Windows tone-detect dispatch importer"); + Cli.WriteLine(" radio Bidirectional radio <-> Resgrid PTT (LiveKit) bridge (Windows)"); + Cli.WriteLine(" record Record PTT channel transmissions to disk/S3 + metadata log (any OS)"); + Cli.WriteLine(" dispatch Tone out new calls (tones + Resgrid TTS) to a PTT channel (any OS)"); + Cli.WriteLine(); Cli.WriteLine("Environment variables:"); - Cli.WriteLine(" RESGRID__RELAY__Mode=smtp|audio"); + Cli.WriteLine(" RESGRID__RELAY__Mode=smtp|audio|radio|record|dispatch"); + Cli.WriteLine(" # Voice (radio/record/dispatch):"); + Cli.WriteLine(" RESGRID__RELAY__Voice__Channel=default||"); + Cli.WriteLine(" RESGRID__RELAY__Voice__DepartmentId=... (hosted/system-key)"); + Cli.WriteLine(" # Radio bridge (Windows):"); + Cli.WriteLine(" RESGRID__RELAY__Radio__InputDevice=0 RESGRID__RELAY__Radio__OutputDevice=-1"); + Cli.WriteLine(" RESGRID__RELAY__Radio__PttMethod=Vox|SerialRts|SerialDtr|Cm108"); + Cli.WriteLine(" RESGRID__RELAY__Radio__SerialPort=COM3 RESGRID__RELAY__Radio__Cm108GpioPin=3"); + Cli.WriteLine(" RESGRID__RELAY__Radio__Squelch__Mode=Vox|Carrier|Ctcss|Off"); + Cli.WriteLine(" RESGRID__RELAY__Radio__Squelch__OpenDbfs=-38 ...__CloseDbfs=-45 ...__HangMs=600"); + Cli.WriteLine(" RESGRID__RELAY__Radio__Emergency__DetectMdc1200=true ...__DetectTones=true"); + Cli.WriteLine(" # Recorder (record mode / RecordWhileBridging):"); + Cli.WriteLine(" RESGRID__RELAY__Recorder__Channel=all| ...__Store=local|s3|both"); + Cli.WriteLine(" RESGRID__RELAY__Recorder__Log=jsonl|sqlite|none ...__LocalPath=./recordings"); + Cli.WriteLine(" RESGRID__RELAY__Recorder__S3__Bucket=... ...__S3__Region=... ...__S3__AccessKey=..."); + Cli.WriteLine(" # Dispatch tone-out:"); + Cli.WriteLine(" RESGRID__RELAY__Tts__ServiceBaseUrl=https://tts.resgrid.com"); + Cli.WriteLine(" RESGRID__RELAY__DispatchVoice__Channel=default ...__PollSeconds=15"); + Cli.WriteLine(); + Cli.WriteLine(" # SMTP mode:"); Cli.WriteLine(" RESGRID__RELAY__Resgrid__ClientId=..."); Cli.WriteLine(" RESGRID__RELAY__Resgrid__ClientSecret=..."); Cli.WriteLine(" RESGRID__RELAY__Resgrid__RefreshToken=..."); @@ -222,14 +265,16 @@ private static async Task RunAudioModeAsync(RelayHostOptions hostOptions, C { var config = LoadAudioConfig(hostOptions.AudioConfigPath); var apiOptions = ResolveResgridOptions(hostOptions, config); - ResgridV4ApiClient.Init(apiOptions); + using var apiClient = new ResgridV4ApiClient(apiOptions); + var healthApi = new HealthApi(apiClient); + var callsApi = new CallsApi(apiClient); Logger logger = CreateLogger(config.Debug); var audioStorage = new WatcherAudioStorage(logger); var evaluator = new AudioEvaluator(logger); var recorder = new AudioRecorder(evaluator, audioStorage); var processor = new AudioProcessor(recorder, evaluator, audioStorage); - var comService = new ComService(logger, processor); + var comService = new ComService(logger, processor, apiClient, healthApi, callsApi); evaluator.WatcherTriggered += (_, eventArgs) => Cli.WriteLine($"{DateTime.Now:G}: WATCHER TRIGGERED: {eventArgs.Watcher.Name}"); @@ -349,15 +394,70 @@ private static Watcher CreateWatcher(int index) private static int ShowDevices() { + Cli.WriteLine("Audio input devices (radio receive):"); for (var waveInDevice = 0; waveInDevice < WaveIn.DeviceCount; waveInDevice++) { var deviceInfo = WaveIn.GetCapabilities(waveInDevice); - Cli.WriteLine($"Device {waveInDevice}: {deviceInfo.ProductName}, {deviceInfo.Channels} channels"); + Cli.WriteLine($" In {waveInDevice}: {deviceInfo.ProductName}, {deviceInfo.Channels} channels"); + } + + Cli.WriteLine("Audio output devices (radio transmit):"); + Cli.WriteLine(" Out -1: System default"); + for (var waveOutDevice = 0; waveOutDevice < WaveOut.DeviceCount; waveOutDevice++) + { + var deviceInfo = WaveOut.GetCapabilities(waveOutDevice); + Cli.WriteLine($" Out {waveOutDevice}: {deviceInfo.ProductName}, {deviceInfo.Channels} channels"); } + Cli.WriteLine("Serial ports (PTT / carrier detect):"); + var ports = SerialPort.GetPortNames(); + if (ports.Length == 0) + Cli.WriteLine(" (none)"); + foreach (var port in ports) + Cli.WriteLine($" {port}"); + + Cli.WriteLine("CM108-class USB HID devices (PTT GPIO):"); + var hids = DeviceList.Local.GetHidDevices(0x0D8C, null).ToList(); + if (hids.Count == 0) + Cli.WriteLine(" (none found at VID 0x0D8C)"); + foreach (var hid in hids) + Cli.WriteLine($" VID=0x{hid.VendorID:X4} PID=0x{hid.ProductID:X4} {SafeName(hid)}"); + return 0; } + private static string SafeName(HidDevice device) + { + try { return device.GetFriendlyName(); } + catch { return "(unnamed)"; } + } + + private static Task RunRadioModeAsync(RelayHostOptions hostOptions, CancellationToken cancellationToken) + => EngineVoice.RadioMode.RunAsync(hostOptions, CreateLogger(debug: false), cancellationToken); + + private static async Task TuneAsync(string[] args) + { + var hostOptions = LoadHostOptions(); + if (args.Length > 0 && Int32.TryParse(args[0], out var device)) + hostOptions.Radio.InputDevice = device; + + using var cancellationTokenSource = new CancellationTokenSource(); + ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => + { + eventArgs.Cancel = true; + cancellationTokenSource.Cancel(); + }; + Cli.CancelKeyPress += cancelHandler; + try + { + return await EngineVoice.RadioMode.RunTuneAsync(hostOptions, cancellationTokenSource.Token).ConfigureAwait(false); + } + finally + { + Cli.CancelKeyPress -= cancelHandler; + } + } + private static async Task MonitorAsync(string[] args) { var device = args.Length > 0 ? Int32.Parse(args[0]) : 0; @@ -472,6 +572,18 @@ private static Task MonitorAsync(string[] args) Cli.Error.WriteLine("Audio monitoring is only available in the net10.0-windows build."); return Task.FromResult(1); } + + private static Task RunRadioModeAsync(RelayHostOptions hostOptions, CancellationToken cancellationToken) + { + Cli.Error.WriteLine("Radio bridge mode is only available in the net10.0-windows build (it needs physical audio devices)."); + return Task.FromResult(1); + } + + private static Task TuneAsync(string[] args) + { + Cli.Error.WriteLine("Tune is only available in the net10.0-windows build."); + return Task.FromResult(1); + } #endif private static Logger CreateLogger(bool debug) diff --git a/Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj b/Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj index fffafe6..994c71e 100644 --- a/Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj +++ b/Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj @@ -22,16 +22,14 @@ - - - + - - + + diff --git a/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj b/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj index 76af315..0809211 100644 --- a/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj +++ b/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj @@ -13,7 +13,7 @@ - + @@ -31,7 +31,7 @@ - + diff --git a/Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs b/Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs index 3df03b1..90eca99 100644 --- a/Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs +++ b/Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs @@ -1,7 +1,7 @@ using FluentAssertions; using NUnit.Framework; -using Resgrid.Audio.Relay.Console.Configuration; -using Resgrid.Audio.Relay.Console.Smtp; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Relay.Engine.Smtp; using Resgrid.Providers.ApiClient.V4; using System; diff --git a/Resgrid.Audio.Tests/SmtpRelayTelemetryTests.cs b/Resgrid.Audio.Tests/SmtpRelayTelemetryTests.cs index 99c2fdb..300c84d 100644 --- a/Resgrid.Audio.Tests/SmtpRelayTelemetryTests.cs +++ b/Resgrid.Audio.Tests/SmtpRelayTelemetryTests.cs @@ -1,8 +1,8 @@ using FluentAssertions; using MimeKit; using NUnit.Framework; -using Resgrid.Audio.Relay.Console.Configuration; -using Resgrid.Audio.Relay.Console.Smtp; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Relay.Engine.Smtp; using Resgrid.Providers.ApiClient.V4.Models; using SmtpServer; using SmtpServer.IO; @@ -28,7 +28,7 @@ public async Task SaveAsync_Should_Track_Unroutable_Message_And_Return_Ok() { var telemetry = new FakeSmtpTelemetry(); var callsClient = new FakeResgridCallsClient(); - var store = new RelayMessageStore(CreateOptions(dataDirectory), telemetry, callsClient); + var store = new RelayMessageStore(CreateOptions(dataDirectory), telemetry, callsClient: callsClient); var response = await store.SaveAsync( new FakeSessionContext(), @@ -63,7 +63,7 @@ public async Task SaveAsync_Should_Remove_Duplicate_Registration_When_Processing { SaveCallException = new InvalidOperationException("Resgrid is unavailable") }; - var failingStore = new RelayMessageStore(CreateOptions(dataDirectory), failingTelemetry, failingClient); + var failingStore = new RelayMessageStore(CreateOptions(dataDirectory), failingTelemetry, callsClient: failingClient); await FluentActions .Awaiting(() => failingStore.SaveAsync(new FakeSessionContext(), null, buffer, CancellationToken.None)) @@ -78,7 +78,7 @@ await FluentActions { NextCallId = "call-42" }; - var successStore = new RelayMessageStore(CreateOptions(dataDirectory), successTelemetry, successClient); + var successStore = new RelayMessageStore(CreateOptions(dataDirectory), successTelemetry, callsClient: successClient); var response = await successStore.SaveAsync(new FakeSessionContext(), null, buffer, CancellationToken.None); @@ -181,6 +181,7 @@ private sealed class FakeResgridCallsClient : IResgridCallsClient public List SaveCallFileInputs { get; } = new List(); public Exception SaveCallException { get; set; } public string NextCallId { get; set; } = "call-1"; + public string CurrentUserId { get; set; } = "fake-user-id"; public Task SaveCallAsync(NewCallInput call, CancellationToken cancellationToken) { diff --git a/Resgrid.Audio.Voice.Tests/AudioTests.cs b/Resgrid.Audio.Voice.Tests/AudioTests.cs new file mode 100644 index 0000000..cc40e1c --- /dev/null +++ b/Resgrid.Audio.Voice.Tests/AudioTests.cs @@ -0,0 +1,90 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Audio.Voice.Audio; + +namespace Resgrid.Audio.Voice.Tests +{ + [TestFixture] + public class MuLawTests + { + [Test] + public void Decode_0xFF_IsNearZero() + { + MuLaw.Decode(0xFF).Should().BeInRange((short)-50, (short)50); + } + + [Test] + public void Decode_ProducesOppositeSignsForSignBit() + { + // 0x00 and 0x80 are the largest-magnitude codes with opposite sign bits. + var negative = MuLaw.Decode(0x00); + var positive = MuLaw.Decode(0x80); + negative.Should().BeLessThan(0); + positive.Should().BeGreaterThan(0); + } + } + + [TestFixture] + public class ResamplerTests + { + [Test] + public void Resample_SameRate_ReturnsEquivalentCopy() + { + var input = new short[] { 1, 2, 3, 4, 5 }; + var output = Resampler.Resample(input, 48000, 48000); + output.Should().Equal(input); + output.Should().NotBeSameAs(input); + } + + [Test] + public void Resample_8kTo48k_SixfoldsLength() + { + var input = new short[800]; // 100 ms @ 8 kHz + var output = Resampler.Resample(input, 8000, 48000); + output.Length.Should().BeInRange(4790, 4800); // ~6x + } + + [Test] + public void Resample_PreservesToneFrequency() + { + // A 1 kHz tone at 8 kHz, upsampled to 48 kHz, must still read as 1 kHz. + var tone = Enumerable.Range(0, 1600) + .Select(i => (short)(Math.Sin(2 * Math.PI * 1000 * i / 8000.0) * 16000)).ToArray(); + var up = Resampler.Resample(tone, 8000, 48000); + + var atTone = Resgrid.Audio.Voice.Dsp.Goertzel.NormalizedStrength(up, 1000, 48000); + var offTone = Resgrid.Audio.Voice.Dsp.Goertzel.NormalizedStrength(up, 3000, 48000); + atTone.Should().BeGreaterThan(offTone); + } + + [Test] + public void ApplyGain_ScalesAndClips() + { + var samples = new short[] { 1000, -1000, 20000 }; + Resampler.ApplyGain(samples, 2.0); + samples[0].Should().Be(2000); + samples[1].Should().Be(-2000); + samples[2].Should().Be(short.MaxValue); // clipped + } + } + + [TestFixture] + public class WavIoTests + { + [Test] + public void WritePcm16_ThenRead_RoundTrips() + { + var pcm = Enumerable.Range(0, 1000).Select(i => (short)((i % 200) - 100)).ToArray(); + var wav = WavIo.WritePcm16(pcm, 48000, 1); + + // 44-byte header + 2 bytes/sample. + wav.Length.Should().Be(44 + pcm.Length * 2); + + var (samples, rate) = WavIo.ReadToPcm16Mono(wav); + rate.Should().Be(48000); + samples.Should().Equal(pcm); + } + } +} diff --git a/Resgrid.Audio.Voice.Tests/DspTests.cs b/Resgrid.Audio.Voice.Tests/DspTests.cs new file mode 100644 index 0000000..5448b34 --- /dev/null +++ b/Resgrid.Audio.Voice.Tests/DspTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Audio.Voice.Dsp; +using Resgrid.Audio.Voice.ToneOut; + +namespace Resgrid.Audio.Voice.Tests +{ + [TestFixture] + public class GoertzelTests + { + private static short[] Tone(double hz, int samples, int rate = 48000) => + Enumerable.Range(0, samples).Select(i => (short)(Math.Sin(2 * Math.PI * hz * i / rate) * 16000)).ToArray(); + + [Test] + public void NormalizedStrength_PeaksAtToneFrequency() + { + var tone = Tone(1000, 4800); + var onFreq = Goertzel.NormalizedStrength(tone, 1000, 48000); + var offFreq = Goertzel.NormalizedStrength(tone, 2500, 48000); + + onFreq.Should().BeGreaterThan(0.5); + onFreq.Should().BeGreaterThan(offFreq); + } + } + + [TestFixture] + public class Mdc1200Tests + { + private static short[] Pad(short[] core) => + new short[2000].Concat(core).Concat(new short[2000]).ToArray(); + + [Test] + public void EncodeDecode_RoundTrips() + { + var settings = new Mdc1200Settings(); + var packet = new Mdc1200Packet { Op = 0x06, Arg = 0x12, UnitId = 0xABCD }; + var wave = Pad(Mdc1200Codec.Encode(packet, settings, 48000)); + + var decoded = Mdc1200Codec.TryDecode(wave, settings, 48000); + + decoded.Should().NotBeNull(); + decoded.Op.Should().Be(0x06); + decoded.Arg.Should().Be(0x12); + decoded.UnitId.Should().Be(0xABCD); + decoded.IsEmergency(settings).Should().BeTrue(); + } + + [Test] + public void TryDecode_RejectsNoise() + { + var rnd = new short[20000]; + for (int i = 0; i < rnd.Length; i++) + rnd[i] = (short)((i * 7919) % 1000 - 500); // deterministic pseudo-noise + Mdc1200Codec.TryDecode(rnd, new Mdc1200Settings(), 48000).Should().BeNull(); + } + + [Test] + public void Crc16Ccitt_DetectsCorruption() + { + var a = Mdc1200Codec.Crc16Ccitt(new byte[] { 0x06, 0x00, 0x12, 0x34 }); + var b = Mdc1200Codec.Crc16Ccitt(new byte[] { 0x06, 0x00, 0x12, 0x35 }); + a.Should().NotBe(b); + } + + [Test] + public void StreamingDecoder_RaisesPacket() + { + var settings = new Mdc1200Settings(); + var packet = new Mdc1200Packet { Op = 0x01, Arg = 0x00, UnitId = 0x1234 }; + var wave = Pad(Mdc1200Codec.Encode(packet, settings, 48000)); + + var decoder = new Mdc1200Decoder(settings, 48000); + Mdc1200Packet got = null; + decoder.PacketDecoded += (_, p) => got = p; + + foreach (var chunk in wave.Chunk(480)) + decoder.Process(chunk); + + got.Should().NotBeNull(); + got.UnitId.Should().Be(0x1234); + } + } + + [TestFixture] + public class SquelchGateTests + { + private static short[] LevelFrame(double dbfs, int samples = 480) + { + double amp = Math.Pow(10, dbfs / 20.0) * short.MaxValue; + return Enumerable.Range(0, samples) + .Select(i => (short)(Math.Sin(2 * Math.PI * 1000 * i / 48000.0) * amp * Math.Sqrt(2))) + .ToArray(); + } + + [Test] + public void Gate_OpensAboveOpenThreshold() + { + var gate = new SquelchGate(new SquelchSettings { OpenDbfs = -38, CloseDbfs = -45, HangMs = 100 }); + gate.Process(LevelFrame(-20)).Should().BeTrue(); + } + + [Test] + public void Gate_StaysClosedBelowOpenThreshold() + { + var gate = new SquelchGate(new SquelchSettings { OpenDbfs = -38, CloseDbfs = -45, HangMs = 100 }); + gate.Process(LevelFrame(-60)).Should().BeFalse(); + } + + [Test] + public void Gate_HangKeepsOpenThenCloses() + { + var gate = new SquelchGate(new SquelchSettings { OpenDbfs = -38, CloseDbfs = -45, HangMs = 30 }); + gate.Process(LevelFrame(-20)).Should().BeTrue(); // open, hang = 30 ms + + // 480 samples @ 48 kHz = 10 ms per frame; below the close threshold the + // hang counts down 30 -> 20 -> 10 -> 0 (closes when it reaches 0). + gate.Process(LevelFrame(-60)).Should().BeTrue(); // hang 20 ms + gate.Process(LevelFrame(-60)).Should().BeTrue(); // hang 10 ms + gate.Process(LevelFrame(-60)).Should().BeFalse(); // hang expired -> closed + } + } + + [TestFixture] + public class ToneGeneratorTests + { + [Test] + public void Sine_HasExpectedLengthAndFrequency() + { + var gen = new ToneGenerator(48000); + var tone = gen.Sine(1000, 100); // 100 ms + tone.Length.Should().Be(4800); + Goertzel.NormalizedStrength(tone, 1000, 48000).Should().BeGreaterThan(0.4); + } + + [Test] + public void BuildAlert_TwoTone_ConcatenatesBothTones() + { + var gen = new ToneGenerator(48000); + var profile = new ToneProfile + { + Type = ToneType.TwoToneSequential, + ToneAFrequency = 1000, ToneADurationMs = 100, + ToneBFrequency = 2000, ToneBDurationMs = 200, + PreSpeechSilenceMs = 0 + }; + var alert = gen.BuildAlert(profile); + alert.Length.Should().Be(48000 / 1000 * 300); // 100 ms + 200 ms + } + + [Test] + public void BuildAlert_None_IsEmpty() + { + new ToneGenerator().BuildAlert(new ToneProfile { Type = ToneType.None }).Should().BeEmpty(); + } + } +} diff --git a/Resgrid.Audio.Voice.Tests/RecordingTests.cs b/Resgrid.Audio.Voice.Tests/RecordingTests.cs new file mode 100644 index 0000000..f383b55 --- /dev/null +++ b/Resgrid.Audio.Voice.Tests/RecordingTests.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Audio.Voice.Recording; + +namespace Resgrid.Audio.Voice.Tests +{ + [TestFixture] + public class TransmissionRecordTests + { + [Test] + public void Serializes_WithCamelCaseComplianceFields() + { + var record = new TransmissionRecord + { + Id = "abc", + ChannelId = "chan-1", + ParticipantIdentity = "user-42", + ParticipantName = "Engine 5", + StartUtc = new DateTime(2026, 6, 23, 12, 0, 0, DateTimeKind.Utc), + EndUtc = new DateTime(2026, 6, 23, 12, 0, 5, DateTimeKind.Utc), + DurationMs = 5000, + Codec = "pcm_s16le" + }; + record.Locations.Add(new StoredLocation { Kind = "local", Location = "/tmp/x.wav", SizeBytes = 10 }); + + var json = JsonSerializer.Serialize(record); + + json.Should().Contain("\"participantIdentity\":\"user-42\""); + json.Should().Contain("\"durationMs\":5000"); + json.Should().Contain("\"locations\""); + json.Should().Contain("\"kind\":\"local\""); + } + } + + [TestFixture] + public class JsonlTransmissionLogTests + { + [Test] + public async Task Append_WritesOneJsonLinePerRecord() + { + var path = Path.Combine(Path.GetTempPath(), $"tx-{Guid.NewGuid():N}.jsonl"); + try + { + var log = new JsonlTransmissionLog(path); + await log.AppendAsync(new TransmissionRecord { Id = "1", ChannelName = "Main", DurationMs = 100 }); + await log.AppendAsync(new TransmissionRecord { Id = "2", ChannelName = "Main", DurationMs = 200 }); + await log.DisposeAsync(); + + var lines = File.ReadAllLines(path); + lines.Should().HaveCount(2); + + var first = JsonSerializer.Deserialize(lines[0]); + first.Id.Should().Be("1"); + first.DurationMs.Should().Be(100); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + } + + [TestFixture] + public class LocalFileTransmissionStoreTests + { + [Test] + public async Task Save_WritesDatePartitionedFile() + { + var root = Path.Combine(Path.GetTempPath(), $"rec-{Guid.NewGuid():N}"); + try + { + var store = new LocalFileTransmissionStore(root); + var data = new byte[] { 1, 2, 3, 4 }; + var location = await store.SaveAsync("test.wav", data); + + location.Kind.Should().Be("local"); + location.SizeBytes.Should().Be(4); + File.Exists(location.Location).Should().BeTrue(); + File.ReadAllBytes(location.Location).Should().Equal(data); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + } +} diff --git a/Resgrid.Audio.Voice.Tests/Resgrid.Audio.Voice.Tests.csproj b/Resgrid.Audio.Voice.Tests/Resgrid.Audio.Voice.Tests.csproj new file mode 100644 index 0000000..744f76a --- /dev/null +++ b/Resgrid.Audio.Voice.Tests/Resgrid.Audio.Voice.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + false + false + disable + disable + + + + + + + + + + + + + diff --git a/Resgrid.Audio.Voice/Abstractions/AudioFormat.cs b/Resgrid.Audio.Voice/Abstractions/AudioFormat.cs new file mode 100644 index 0000000..872d803 --- /dev/null +++ b/Resgrid.Audio.Voice/Abstractions/AudioFormat.cs @@ -0,0 +1,53 @@ +using System; + +namespace Resgrid.Audio.Voice.Abstractions +{ + /// + /// The canonical PCM audio format used across the voice engine. LiveKit's WebRTC + /// audio is Opus at 48 kHz; we publish and consume raw PCM16 mono at 48 kHz and + /// resample to/from the radio device rate at the edges. + /// + public static class AudioFormat + { + /// 48 kHz — the WebRTC/Opus rate LiveKit uses. + public const int SampleRate = 48000; + + /// Mono. PTT radio audio is single channel. + public const int Channels = 1; + + /// 10 ms frame — the natural Opus/WebRTC frame interval. + public const int FrameMilliseconds = 10; + + /// Samples per 10 ms mono frame at 48 kHz = 480. + public const int SamplesPerFrame = SampleRate / 1000 * FrameMilliseconds; // 480 + + /// Bytes per sample for PCM16. + public const int BytesPerSample = 2; + + /// Computes the RMS amplitude (0..1) of a PCM16 mono buffer. + public static double Rms(ReadOnlySpan samples) + { + if (samples.Length == 0) + return 0; + + double sumSquares = 0; + for (int i = 0; i < samples.Length; i++) + { + double s = samples[i] / 32768.0; + sumSquares += s * s; + } + + return Math.Sqrt(sumSquares / samples.Length); + } + + /// RMS expressed in dBFS (full scale). Silence ≈ -inf, clipped ≈ 0 dB. + public static double Dbfs(ReadOnlySpan samples) + { + var rms = Rms(samples); + if (rms <= 1e-9) + return -100.0; + + return 20.0 * Math.Log10(rms); + } + } +} diff --git a/Resgrid.Audio.Voice/Abstractions/IAudioPublisher.cs b/Resgrid.Audio.Voice/Abstractions/IAudioPublisher.cs new file mode 100644 index 0000000..f6e3f9d --- /dev/null +++ b/Resgrid.Audio.Voice/Abstractions/IAudioPublisher.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Audio.Voice.Abstractions +{ + /// + /// Publishes locally-sourced PCM16 mono 48 kHz audio into a PTT channel. Used by + /// the radio bridge (radio RX → channel) and dispatch tone-out (tones + TTS → + /// channel). Implementations chunk arbitrary-length input into 10 ms frames. + /// + public interface IAudioPublisher : IAsyncDisposable + { + /// + /// Queues PCM16 mono 48 kHz samples for transmission. Any length is accepted; + /// the publisher buffers a residual partial frame between calls. + /// + ValueTask WriteAsync(ReadOnlyMemory pcm48kMono, CancellationToken cancellationToken = default); + + /// Pushes any buffered partial frame, padded with silence. + ValueTask FlushAsync(CancellationToken cancellationToken = default); + } +} diff --git a/Resgrid.Audio.Voice/Abstractions/IVoiceRoomSession.cs b/Resgrid.Audio.Voice/Abstractions/IVoiceRoomSession.cs new file mode 100644 index 0000000..b89bdeb --- /dev/null +++ b/Resgrid.Audio.Voice/Abstractions/IVoiceRoomSession.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Audio.Voice.Abstractions +{ + /// State of a session's connection to a PTT channel. + public sealed class VoiceConnectionStateChange + { + public VoiceConnectionStateChange(bool connected, string reason) + { + Connected = connected; + Reason = reason; + } + + public bool Connected { get; } + public string Reason { get; } + } + + /// + /// A live connection to a single Resgrid PTT channel (LiveKit room). Exposes + /// inbound audio (per participant) and lets callers publish outbound audio. One + /// session per channel; many sessions are coordinated by VoiceRoomManager. + /// + public interface IVoiceRoomSession : IAsyncDisposable + { + /// The channel/room id (DepartmentVoiceChannelId). + string ChannelId { get; } + + /// Friendly channel name. + string ChannelName { get; } + + bool IsConnected { get; } + + /// Connects and begins receiving subscribed audio. + Task ConnectAsync(CancellationToken cancellationToken = default); + + /// Disconnects from the channel. + Task DisconnectAsync(CancellationToken cancellationToken = default); + + /// + /// Publishes a local audio track and returns a publisher to feed it PCM. + /// Call once per logical source (e.g. the radio, or tone-out). Most channels + /// only need one local publisher. + /// + Task CreatePublisherAsync(string trackName, CancellationToken cancellationToken = default); + + /// Raised for every inbound PCM16 mono 48 kHz frame, tagged with sender. + event EventHandler AudioFrameReceived; + + /// Raised when the channel connection state changes. + event EventHandler ConnectionChanged; + } +} diff --git a/Resgrid.Audio.Voice/Abstractions/IVoiceTransport.cs b/Resgrid.Audio.Voice/Abstractions/IVoiceTransport.cs new file mode 100644 index 0000000..356e1f7 --- /dev/null +++ b/Resgrid.Audio.Voice/Abstractions/IVoiceTransport.cs @@ -0,0 +1,12 @@ +namespace Resgrid.Audio.Voice.Abstractions +{ + /// + /// Factory for s. The concrete implementation + /// (LiveKit) is the only place that references the WebRTC SDK, so the recorder, + /// tone-out and radio bridge stay transport-agnostic and unit-testable. + /// + public interface IVoiceTransport + { + IVoiceRoomSession CreateSession(VoiceChannel channel); + } +} diff --git a/Resgrid.Audio.Voice/Abstractions/VoiceAudioFrame.cs b/Resgrid.Audio.Voice/Abstractions/VoiceAudioFrame.cs new file mode 100644 index 0000000..4dba963 --- /dev/null +++ b/Resgrid.Audio.Voice/Abstractions/VoiceAudioFrame.cs @@ -0,0 +1,33 @@ +using System; + +namespace Resgrid.Audio.Voice.Abstractions +{ + /// + /// A decoded PCM16 mono 48 kHz audio frame received from a remote PTT participant, + /// tagged with who sent it and on which track. Raised by an + /// for every inbound frame so consumers (radio + /// playout, recorder) can route/segment it. + /// + public sealed class VoiceAudioFrame + { + public VoiceAudioFrame(VoiceParticipant participant, string trackSid, short[] pcm, DateTime timestampUtc) + { + Participant = participant; + TrackSid = trackSid; + Pcm = pcm ?? Array.Empty(); + TimestampUtc = timestampUtc; + } + + /// The participant who transmitted this audio. + public VoiceParticipant Participant { get; } + + /// The remote track id this frame came from. + public string TrackSid { get; } + + /// PCM16 mono samples at 48 kHz. + public short[] Pcm { get; } + + /// UTC time the frame was received. + public DateTime TimestampUtc { get; } + } +} diff --git a/Resgrid.Audio.Voice/Abstractions/VoiceChannel.cs b/Resgrid.Audio.Voice/Abstractions/VoiceChannel.cs new file mode 100644 index 0000000..d064a39 --- /dev/null +++ b/Resgrid.Audio.Voice/Abstractions/VoiceChannel.cs @@ -0,0 +1,32 @@ +namespace Resgrid.Audio.Voice.Abstractions +{ + /// + /// Everything needed to join one Resgrid PTT channel: the LiveKit room id, the + /// websocket server URL and a pre-minted access token. Produced by an + /// IVoiceChannelProvider from the Resgrid v4 Voice API. + /// + public sealed class VoiceChannel + { + public VoiceChannel(string id, string name, bool isDefault, string roomUrl, string token) + { + Id = id; + Name = name; + IsDefault = isDefault; + RoomUrl = roomUrl; + Token = token; + } + + /// DepartmentVoiceChannelId — the LiveKit room name. + public string Id { get; } + + public string Name { get; } + + public bool IsDefault { get; } + + /// Client websocket URL (wss://...) passed to Room.ConnectAsync. + public string RoomUrl { get; } + + /// LiveKit JWT access token granting roomJoin for this room. + public string Token { get; } + } +} diff --git a/Resgrid.Audio.Voice/Abstractions/VoiceParticipant.cs b/Resgrid.Audio.Voice/Abstractions/VoiceParticipant.cs new file mode 100644 index 0000000..b7e6137 --- /dev/null +++ b/Resgrid.Audio.Voice/Abstractions/VoiceParticipant.cs @@ -0,0 +1,33 @@ +namespace Resgrid.Audio.Voice.Abstractions +{ + /// + /// Immutable snapshot of a remote participant transmitting on a PTT channel. + /// Captured when a track is subscribed so transmission logs can record exactly + /// who transmitted (identity + display name). + /// + public sealed class VoiceParticipant + { + public VoiceParticipant(string sid, string identity, string name, string kind) + { + Sid = sid; + Identity = identity; + Name = name; + Kind = kind; + } + + /// LiveKit participant session id (unique per connection). + public string Sid { get; } + + /// Stable participant identity (typically the Resgrid user id). + public string Identity { get; } + + /// Human-friendly display name (caller id). + public string Name { get; } + + /// Participant kind (Standard, Agent, Sip, Bridge, ...). + public string Kind { get; } + + public override string ToString() => + string.IsNullOrWhiteSpace(Name) ? (Identity ?? Sid) : $"{Name} ({Identity})"; + } +} diff --git a/Resgrid.Audio.Voice/Audio/MuLaw.cs b/Resgrid.Audio.Voice/Audio/MuLaw.cs new file mode 100644 index 0000000..c03872f --- /dev/null +++ b/Resgrid.Audio.Voice/Audio/MuLaw.cs @@ -0,0 +1,36 @@ +namespace Resgrid.Audio.Voice.Audio +{ + /// + /// G.711 µ-law and A-law companding decoders. The Resgrid TTS service returns + /// 8 kHz mono µ-law WAV; these decode it to linear PCM16 for resampling. + /// + public static class MuLaw + { + private const int Bias = 0x84; + + /// Decodes a single µ-law byte to a 16-bit linear sample. + public static short Decode(byte mulaw) + { + mulaw = (byte)~mulaw; + int sign = mulaw & 0x80; + int exponent = (mulaw >> 4) & 0x07; + int mantissa = mulaw & 0x0F; + int sample = ((mantissa << 3) + Bias) << exponent; + sample -= Bias; + return (short)(sign != 0 ? -sample : sample); + } + + /// Decodes a single A-law byte to a 16-bit linear sample. + public static short DecodeALaw(byte alaw) + { + alaw ^= 0x55; + int sign = alaw & 0x80; + int exponent = (alaw & 0x70) >> 4; + int mantissa = alaw & 0x0F; + int sample = (mantissa << 4) + 8; + if (exponent != 0) + sample = (sample + 0x100) << (exponent - 1); + return (short)(sign != 0 ? sample : -sample); + } + } +} diff --git a/Resgrid.Audio.Voice/Audio/Resampler.cs b/Resgrid.Audio.Voice/Audio/Resampler.cs new file mode 100644 index 0000000..5ab706e --- /dev/null +++ b/Resgrid.Audio.Voice/Audio/Resampler.cs @@ -0,0 +1,72 @@ +using System; + +namespace Resgrid.Audio.Voice.Audio +{ + /// + /// Lightweight mono PCM16 sample-rate conversion (linear interpolation) plus + /// gain/mix helpers. Linear interpolation is adequate for voice-band audio and + /// keeps the engine free of OS-specific resamplers. + /// + public static class Resampler + { + /// Resamples mono PCM16 from to . + public static short[] Resample(short[] input, int inRate, int outRate) + { + if (input == null || input.Length == 0) + return Array.Empty(); + if (inRate <= 0) + throw new ArgumentOutOfRangeException(nameof(inRate), "Sample rate must be positive."); + if (outRate <= 0) + throw new ArgumentOutOfRangeException(nameof(outRate), "Sample rate must be positive."); + if (inRate == outRate) + return (short[])input.Clone(); + + long outLen = (long)input.Length * outRate / inRate; + if (outLen <= 0) + return Array.Empty(); + if (outLen > int.MaxValue) + throw new ArgumentOutOfRangeException(nameof(outRate), + $"Resampled length {outLen} exceeds the maximum array size ({int.MaxValue})."); + + var output = new short[(int)outLen]; + double step = (double)inRate / outRate; + double pos = 0; + + for (long j = 0; j < outLen; j++) + { + int idx = (int)pos; + double frac = pos - idx; + int next = Math.Min(idx + 1, input.Length - 1); + double sample = input[idx] * (1 - frac) + input[next] * frac; + output[j] = ClampToShort(sample); + pos += step; + } + + return output; + } + + /// Applies linear gain with hard clipping. + public static void ApplyGain(short[] samples, double gain) + { + if (samples == null || Math.Abs(gain - 1.0) < 1e-6) + return; + for (int i = 0; i < samples.Length; i++) + samples[i] = ClampToShort(samples[i] * gain); + } + + /// Additively mixes into (in place) with clipping. + public static void MixInto(short[] target, ReadOnlySpan source) + { + int n = Math.Min(target.Length, source.Length); + for (int i = 0; i < n; i++) + target[i] = ClampToShort(target[i] + source[i]); + } + + public static short ClampToShort(double value) + { + if (value > short.MaxValue) return short.MaxValue; + if (value < short.MinValue) return short.MinValue; + return (short)value; + } + } +} diff --git a/Resgrid.Audio.Voice/Audio/WavIo.cs b/Resgrid.Audio.Voice/Audio/WavIo.cs new file mode 100644 index 0000000..352759e --- /dev/null +++ b/Resgrid.Audio.Voice/Audio/WavIo.cs @@ -0,0 +1,145 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; + +namespace Resgrid.Audio.Voice.Audio +{ + /// + /// Minimal, cross-platform WAV (RIFF/PCM) reader and writer for PCM16 and µ-law. + /// Hand-rolled to avoid any OS-specific media APIs (MediaFoundation/ACM) so the + /// recorder and tone-out work identically on Windows and Linux. + /// + public static class WavIo + { + private const ushort FormatPcm = 1; + private const ushort FormatMuLaw = 7; + private const ushort FormatALaw = 6; + + /// Serializes PCM16 samples to a canonical 44-byte-header WAV. + public static byte[] WritePcm16(IReadOnlyList samples, int sampleRate, int channels) + { + int dataBytes = samples.Count * 2; + using var ms = new MemoryStream(44 + dataBytes); + using var w = new BinaryWriter(ms); + + int byteRate = sampleRate * channels * 2; + short blockAlign = (short)(channels * 2); + + w.Write(new[] { (byte)'R', (byte)'I', (byte)'F', (byte)'F' }); + w.Write(36 + dataBytes); + w.Write(new[] { (byte)'W', (byte)'A', (byte)'V', (byte)'E' }); + w.Write(new[] { (byte)'f', (byte)'m', (byte)'t', (byte)' ' }); + w.Write(16); // PCM fmt chunk size + w.Write(FormatPcm); // audio format = PCM + w.Write((short)channels); + w.Write(sampleRate); + w.Write(byteRate); + w.Write(blockAlign); + w.Write((short)16); // bits per sample + w.Write(new[] { (byte)'d', (byte)'a', (byte)'t', (byte)'a' }); + w.Write(dataBytes); + for (int i = 0; i < samples.Count; i++) + w.Write(samples[i]); + + w.Flush(); + return ms.ToArray(); + } + + /// + /// Reads a WAV file into PCM16 mono samples, decoding µ-law/A-law and + /// downmixing to mono if needed. Returns the decoded samples and source rate. + /// + public static (short[] Samples, int SampleRate) ReadToPcm16Mono(byte[] wav) + { + if (wav == null || wav.Length < 12) + throw new ArgumentException("Not a valid WAV payload.", nameof(wav)); + + var span = wav.AsSpan(); + if (span[0] != 'R' || span[1] != 'I' || span[2] != 'F' || span[3] != 'F') + throw new ArgumentException("Missing RIFF header.", nameof(wav)); + + ushort format = 0, channels = 1, bits = 16; + int sampleRate = 8000; + int pos = 12; + ReadOnlySpan data = default; + + while (pos + 8 <= wav.Length) + { + var id = span.Slice(pos, 4); + int size = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(pos + 4, 4)); + int body = pos + 8; + if (body + size > wav.Length) + size = wav.Length - body; + + if (id[0] == 'f' && id[1] == 'm' && id[2] == 't' && id[3] == ' ') + { + format = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(body, 2)); + channels = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(body + 2, 2)); + sampleRate = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(body + 4, 4)); + bits = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(body + 14, 2)); + } + else if (id[0] == 'd' && id[1] == 'a' && id[2] == 't' && id[3] == 'a') + { + data = span.Slice(body, size); + } + + pos = body + size + (size & 1); // chunks are word-aligned + } + + if (data.IsEmpty) + throw new ArgumentException("WAV has no data chunk.", nameof(wav)); + + short[] mono = DecodeToMono(format, channels, bits, data); + return (mono, sampleRate); + } + + private static short[] DecodeToMono(ushort format, int channels, int bits, ReadOnlySpan data) + { + short[] interleaved; + if (format == FormatPcm && bits == 16) + { + interleaved = new short[data.Length / 2]; + for (int i = 0; i < interleaved.Length; i++) + interleaved[i] = BinaryPrimitives.ReadInt16LittleEndian(data.Slice(i * 2, 2)); + } + else if (format == FormatMuLaw) + { + interleaved = new short[data.Length]; + for (int i = 0; i < data.Length; i++) + interleaved[i] = MuLaw.Decode(data[i]); + } + else if (format == FormatALaw) + { + interleaved = new short[data.Length]; + for (int i = 0; i < data.Length; i++) + interleaved[i] = MuLaw.DecodeALaw(data[i]); + } + else if (format == FormatPcm && bits == 8) + { + interleaved = new short[data.Length]; + for (int i = 0; i < data.Length; i++) + interleaved[i] = (short)((data[i] - 128) << 8); + } + else + { + throw new NotSupportedException($"Unsupported WAV format {format} / {bits}-bit."); + } + + if (channels <= 1) + return interleaved; + + // Downmix to mono by averaging channels. + int frames = interleaved.Length / channels; + var mono = new short[frames]; + for (int f = 0; f < frames; f++) + { + int sum = 0; + for (int c = 0; c < channels; c++) + sum += interleaved[f * channels + c]; + mono[f] = (short)(sum / channels); + } + return mono; + } + } +} diff --git a/Resgrid.Audio.Voice/Connection/IVoiceChannelProvider.cs b/Resgrid.Audio.Voice/Connection/IVoiceChannelProvider.cs new file mode 100644 index 0000000..2a888b9 --- /dev/null +++ b/Resgrid.Audio.Voice/Connection/IVoiceChannelProvider.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Audio.Voice.Abstractions; + +namespace Resgrid.Audio.Voice.Connection +{ + /// + /// Resolves the PTT channels (LiveKit rooms + tokens) a relay should join. + /// Backed by the Resgrid v4 Voice API. Abstracted so hosted (multi-department) + /// resolution can be swapped in without touching the bridge/recorder/tone-out. + /// + public interface IVoiceChannelProvider + { + /// All voice channels for the (optional) department. + Task> GetChannelsAsync(string departmentId = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a single channel by selector: a channel id, a channel name + /// (case-insensitive), or null/empty/"default" for the department's default + /// channel (falling back to the first channel). + /// + Task GetChannelAsync(string selector, string departmentId = null, CancellationToken cancellationToken = default); + + /// + /// Returns whether another participant may connect without exceeding the + /// department's concurrent-seat subscription limit. Null = unknown (proceed). + /// + Task CanConnectAsync(string departmentId = null, CancellationToken cancellationToken = default); + } +} diff --git a/Resgrid.Audio.Voice/Connection/ResgridVoiceChannelProvider.cs b/Resgrid.Audio.Voice/Connection/ResgridVoiceChannelProvider.cs new file mode 100644 index 0000000..37eb777 --- /dev/null +++ b/Resgrid.Audio.Voice/Connection/ResgridVoiceChannelProvider.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Audio.Voice.Abstractions; +using Resgrid.Providers.ApiClient.V4; +using Resgrid.Providers.ApiClient.V4.Models; +using Serilog; + +namespace Resgrid.Audio.Voice.Connection +{ + /// + /// Resolves PTT channels from the Resgrid v4 Voice API + /// (GET /api/v4/Voice/GetDepartmentVoiceSettings). Each channel carries the + /// LiveKit server URL and a pre-minted join token. Assumes + /// has already been initialized. + /// + public sealed class ResgridVoiceChannelProvider : IVoiceChannelProvider + { + private readonly ILogger _logger; + private readonly IResgridVoiceApi _voiceApi; + + public ResgridVoiceChannelProvider(ILogger logger, IResgridVoiceApi voiceApi) + { + _logger = logger; + _voiceApi = voiceApi ?? throw new ArgumentNullException(nameof(voiceApi)); + } + + public async Task> GetChannelsAsync(string departmentId = null, CancellationToken cancellationToken = default) + { + var settings = await _voiceApi.GetDepartmentVoiceSettingsAsync(departmentId, cancellationToken).ConfigureAwait(false); + if (settings == null) + throw new InvalidOperationException("The Resgrid API returned no voice settings. Is voice/PTT enabled for this department?"); + + if (!settings.VoiceEnabled) + throw new InvalidOperationException("Voice/PTT is not enabled for this department."); + + if (string.IsNullOrWhiteSpace(settings.VoipServerWebsocketSslAddress)) + throw new InvalidOperationException("The Resgrid API did not return a LiveKit server URL (VoipServerWebsocketSslAddress)."); + + var url = settings.VoipServerWebsocketSslAddress; + var channels = (settings.Channels ?? new List()) + .Where(c => c != null && !string.IsNullOrWhiteSpace(c.Id) && !string.IsNullOrWhiteSpace(c.Token)) + .Select(c => new VoiceChannel(c.Id, c.Name, c.IsDefault, url, c.Token)) + .ToList(); + + if (channels.Count == 0) + throw new InvalidOperationException("The department has no voice channels with valid tokens."); + + return channels; + } + + public async Task GetChannelAsync(string selector, string departmentId = null, CancellationToken cancellationToken = default) + { + var channels = await GetChannelsAsync(departmentId, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(selector) || selector.Equals("default", StringComparison.OrdinalIgnoreCase)) + return channels.FirstOrDefault(c => c.IsDefault) ?? channels[0]; + + var byId = channels.FirstOrDefault(c => string.Equals(c.Id, selector, StringComparison.OrdinalIgnoreCase)); + if (byId != null) + return byId; + + var byName = channels.FirstOrDefault(c => string.Equals(c.Name, selector, StringComparison.OrdinalIgnoreCase)); + if (byName != null) + return byName; + + throw new InvalidOperationException( + $"No voice channel matched '{selector}'. Available: {string.Join(", ", channels.Select(c => $"{c.Name} [{c.Id}]"))}."); + } + + public async Task CanConnectAsync(string departmentId = null, CancellationToken cancellationToken = default) + { + var settings = await _voiceApi.GetDepartmentVoiceSettingsAsync(departmentId, cancellationToken).ConfigureAwait(false); + if (settings == null || string.IsNullOrWhiteSpace(settings.CanConnectApiToken)) + return null; + + var result = await _voiceApi.CanConnectToVoiceSessionAsync(settings.CanConnectApiToken, cancellationToken).ConfigureAwait(false); + if (result == null) + return null; + + if (!result.CanConnect) + _logger?.Warning("Voice seat limit reached: {Current}/{Max} sessions in use.", result.CurrentSessions, result.MaxSessions); + + return result.CanConnect; + } + } +} diff --git a/Resgrid.Audio.Voice/Dsp/AudioFilters.cs b/Resgrid.Audio.Voice/Dsp/AudioFilters.cs new file mode 100644 index 0000000..fb5639c --- /dev/null +++ b/Resgrid.Audio.Voice/Dsp/AudioFilters.cs @@ -0,0 +1,75 @@ +using System; + +namespace Resgrid.Audio.Voice.Dsp +{ + /// + /// A stateful one-pole high-pass filter. Removes DC, hum and sub-audible CTCSS/PL + /// tones from the relayed audio so they don't bleed onto the destination network. + /// + public sealed class HighPassFilter + { + private readonly double _alpha; + private double _prevIn; + private double _prevOut; + + public HighPassFilter(double cutoffHz, int sampleRate) + { + double rc = 1.0 / (2 * Math.PI * Math.Max(1, cutoffHz)); + double dt = 1.0 / sampleRate; + _alpha = rc / (rc + dt); + } + + /// Filters a frame in place. + public void Process(short[] samples) + { + if (samples == null) + return; + + for (int i = 0; i < samples.Length; i++) + { + double x = samples[i]; + double y = _alpha * (_prevOut + x - _prevIn); + _prevIn = x; + _prevOut = y; + + if (y > short.MaxValue) y = short.MaxValue; + else if (y < short.MinValue) y = short.MinValue; + samples[i] = (short)y; + } + } + + public void Reset() + { + _prevIn = 0; + _prevOut = 0; + } + } + + /// + /// A simple peak limiter that prevents clipping/over-deviation when audio is + /// boosted toward the radio or the channel. + /// + public sealed class SoftLimiter + { + private readonly double _threshold; + + public SoftLimiter(double thresholdDbfs = -1.0) + { + _threshold = Math.Pow(10, thresholdDbfs / 20.0) * short.MaxValue; + } + + public void Process(short[] samples) + { + if (samples == null) + return; + + for (int i = 0; i < samples.Length; i++) + { + double v = samples[i]; + if (v > _threshold) v = _threshold; + else if (v < -_threshold) v = -_threshold; + samples[i] = (short)v; + } + } + } +} diff --git a/Resgrid.Audio.Voice/Dsp/CtcssDetector.cs b/Resgrid.Audio.Voice/Dsp/CtcssDetector.cs new file mode 100644 index 0000000..f1ad18c --- /dev/null +++ b/Resgrid.Audio.Voice/Dsp/CtcssDetector.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Audio.Voice.Dsp +{ + /// + /// Detects a sub-audible CTCSS/PL tone (67–254 Hz) using Goertzel over a long + /// window (low frequencies need many samples to resolve). Used as a squelch source + /// so the relay only opens when the correct PL tone is present — rejecting other + /// users and noise on a shared channel. + /// + public sealed class CtcssDetector + { + private readonly double _frequency; + private readonly double _minStrength; + private readonly int _sampleRate; + private readonly int _windowSamples; + private readonly List _window = new List(); + private bool _present; + + public CtcssDetector(double frequencyHz, double minStrength, int sampleRate, int windowMs = 200) + { + _frequency = frequencyHz; + _minStrength = minStrength; + _sampleRate = sampleRate; + _windowSamples = Math.Max(sampleRate * windowMs / 1000, sampleRate / 20); + } + + public bool IsPresent => _present; + + /// Feeds audio; returns the latest tone-present state. + public bool Process(ReadOnlySpan pcm) + { + if (_frequency <= 0) + { + _present = false; + return false; + } + + for (int i = 0; i < pcm.Length; i++) + _window.Add(pcm[i]); + + while (_window.Count >= _windowSamples) + { + var block = _window.GetRange(0, _windowSamples).ToArray(); + _window.RemoveRange(0, _windowSamples / 2); // 50% overlap for responsiveness + var strength = Goertzel.NormalizedStrength(block, _frequency, _sampleRate); + _present = strength >= _minStrength; + } + + return _present; + } + } +} diff --git a/Resgrid.Audio.Voice/Dsp/EmergencyToneDetector.cs b/Resgrid.Audio.Voice/Dsp/EmergencyToneDetector.cs new file mode 100644 index 0000000..899f691 --- /dev/null +++ b/Resgrid.Audio.Voice/Dsp/EmergencyToneDetector.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Audio.Voice.Dsp +{ + /// Tunables for sustained emergency-tone detection. + public sealed class EmergencyToneSettings + { + /// Tone frequencies (Hz) that signal an emergency on this radio system. + public List Frequencies { get; set; } = new List(); + + /// Goertzel normalized strength (0..1) required to count a tone present. + public double MinStrength { get; set; } = 0.35; + + /// Continuous milliseconds the tone must hold to trigger. + public int HoldMs { get; set; } = 600; + + /// Suppress repeat triggers for this long after a detection. + public int CooldownMs { get; set; } = 8000; + + /// Analysis block size in ms. + public int BlockMs { get; set; } = 40; + } + + /// A detected emergency signal. + public sealed class EmergencyDetection + { + public EmergencyDetection(string kind, string detail, DateTime utc) + { + Kind = kind; + Detail = detail; + Utc = utc; + } + + public string Kind { get; } + public string Detail { get; } + public DateTime Utc { get; } + } + + /// + /// Streaming detector for sustained emergency tones on the RF side. Many systems + /// signal alarms with a held tone or warble; this raises an event when a configured + /// frequency holds above threshold long enough, with a cooldown to avoid spamming. + /// Feed it PCM16 mono frames at the engine rate. + /// + public sealed class EmergencyToneDetector + { + private readonly EmergencyToneSettings _settings; + private readonly int _sampleRate; + private readonly int _blockSamples; + private readonly List _window = new List(); + + private double _heldMs; + private double _cooldownRemainingMs; + private double _activeFrequency; + + public event EventHandler EmergencyDetected; + + public EmergencyToneDetector(EmergencyToneSettings settings, int sampleRate) + { + _settings = settings ?? new EmergencyToneSettings(); + _sampleRate = sampleRate; + _blockSamples = Math.Max(64, _sampleRate * Math.Max(10, _settings.BlockMs) / 1000); + } + + public void Process(ReadOnlySpan pcm) + { + if (_settings.Frequencies == null || _settings.Frequencies.Count == 0) + return; + + for (int i = 0; i < pcm.Length; i++) + _window.Add(pcm[i]); + + while (_window.Count >= _blockSamples) + { + var block = _window.GetRange(0, _blockSamples).ToArray(); + _window.RemoveRange(0, _blockSamples); + AnalyzeBlock(block); + } + } + + private void AnalyzeBlock(short[] block) + { + double blockMs = block.Length * 1000.0 / _sampleRate; + + if (_cooldownRemainingMs > 0) + { + _cooldownRemainingMs -= blockMs; + return; + } + + double bestFreq = 0; + double bestStrength = 0; + foreach (var f in _settings.Frequencies) + { + var strength = Goertzel.NormalizedStrength(block, f, _sampleRate); + if (strength > bestStrength) + { + bestStrength = strength; + bestFreq = f; + } + } + + bool present = bestStrength >= _settings.MinStrength; + if (present && (Math.Abs(bestFreq - _activeFrequency) < 1 || _heldMs == 0)) + { + _activeFrequency = bestFreq; + _heldMs += blockMs; + if (_heldMs >= _settings.HoldMs) + { + EmergencyDetected?.Invoke(this, new EmergencyDetection( + "Tone", $"Emergency tone {_activeFrequency:0} Hz held {_heldMs:0} ms", DateTime.UtcNow)); + _heldMs = 0; + _cooldownRemainingMs = _settings.CooldownMs; + } + } + else + { + _heldMs = 0; + _activeFrequency = 0; + } + } + } +} diff --git a/Resgrid.Audio.Voice/Dsp/Goertzel.cs b/Resgrid.Audio.Voice/Dsp/Goertzel.cs new file mode 100644 index 0000000..5a86b05 --- /dev/null +++ b/Resgrid.Audio.Voice/Dsp/Goertzel.cs @@ -0,0 +1,58 @@ +using System; + +namespace Resgrid.Audio.Voice.Dsp +{ + /// + /// Goertzel single-frequency power estimator — an efficient way to measure the + /// energy at one target frequency without a full FFT. Used for CTCSS/PL detection, + /// emergency-tone detection, and FFSK bit slicing. + /// + public static class Goertzel + { + /// Magnitude-squared of within the buffer. + public static double Power(ReadOnlySpan samples, double frequencyHz, int sampleRate) + { + if (samples.Length == 0) + return 0; + + double omega = 2.0 * Math.PI * frequencyHz / sampleRate; + double coeff = 2.0 * Math.Cos(omega); + double s0, s1 = 0, s2 = 0; + + for (int i = 0; i < samples.Length; i++) + { + s0 = (samples[i] / 32768.0) + coeff * s1 - s2; + s2 = s1; + s1 = s0; + } + + return s1 * s1 + s2 * s2 - coeff * s1 * s2; + } + + /// + /// Normalized tone strength (0..~1): the target-frequency power relative to the + /// buffer's total energy. Robust to overall level so a single threshold works + /// across loud and quiet signals. + /// + public static double NormalizedStrength(ReadOnlySpan samples, double frequencyHz, int sampleRate) + { + if (samples.Length == 0) + return 0; + + double total = 0; + for (int i = 0; i < samples.Length; i++) + { + double s = samples[i] / 32768.0; + total += s * s; + } + if (total <= 1e-12) + return 0; + + double power = Power(samples, frequencyHz, sampleRate); + // Goertzel power for a windowed pure tone ~= (N/2)^2 * amplitude^2; normalize + // against total energy (N * meanSquare) to get a 0..1-ish ratio. + double normalized = power / (total * samples.Length / 4.0); + return normalized < 0 ? 0 : (normalized > 1 ? 1 : normalized); + } + } +} diff --git a/Resgrid.Audio.Voice/Dsp/IEmergencyAlertSink.cs b/Resgrid.Audio.Voice/Dsp/IEmergencyAlertSink.cs new file mode 100644 index 0000000..b1264aa --- /dev/null +++ b/Resgrid.Audio.Voice/Dsp/IEmergencyAlertSink.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Providers.ApiClient.V4; +using Resgrid.Providers.ApiClient.V4.Models; +using Serilog; + +namespace Resgrid.Audio.Voice.Dsp +{ + /// + /// Receives emergency signals detected on the RF side (emergency tones, MDC-1200 + /// emergency op / ANI) and surfaces them to Resgrid. + /// + public interface IEmergencyAlertSink + { + Task RaiseAsync(string source, string detail, CancellationToken cancellationToken = default); + } + + /// + /// Raises detected radio emergencies into Resgrid by creating a high-priority call + /// (when enabled) and always logging. Call creation is opt-in to avoid duplicate + /// dispatches in environments that already handle alerting another way. + /// + public sealed class ResgridEmergencyAlertSink : IEmergencyAlertSink + { + private readonly ILogger _logger; + private readonly bool _createCall; + private readonly int _priority; + private readonly string _dispatchList; + private readonly IResgridCallsApi _callsApi; + + public ResgridEmergencyAlertSink(ILogger logger, bool createCall = false, int priority = 1, string dispatchList = null, IResgridCallsApi callsApi = null) + { + _logger = logger; + _createCall = createCall; + _priority = priority; + _dispatchList = dispatchList; + _callsApi = callsApi; + } + + public async Task RaiseAsync(string source, string detail, CancellationToken cancellationToken = default) + { + _logger?.Warning("RADIO EMERGENCY [{Source}]: {Detail}", source, detail); + + if (!_createCall) + return; + + try + { + var input = new NewCallInput + { + Priority = _priority, + Name = $"RADIO EMERGENCY ({source})", + Nature = detail, + Note = $"Emergency signaling detected on the radio network by Resgrid Relay at {DateTime.UtcNow:F} UTC. Source: {source}. {detail}", + Type = "Radio Emergency", + DispatchList = _dispatchList, + ReferenceId = DateTime.UtcNow.ToString("O") + }; + + var callId = await _callsApi.SaveCallAsync(input, cancellationToken).ConfigureAwait(false); + _logger?.Information("Created emergency call {CallId} from radio signaling", callId); + } + catch (Exception ex) + { + _logger?.Error(ex, "Failed to create Resgrid emergency call from radio signaling"); + } + } + } +} diff --git a/Resgrid.Audio.Voice/Dsp/Mdc1200.cs b/Resgrid.Audio.Voice/Dsp/Mdc1200.cs new file mode 100644 index 0000000..b923e2f --- /dev/null +++ b/Resgrid.Audio.Voice/Dsp/Mdc1200.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Audio.Voice.Dsp +{ + /// + /// MDC-1200 (Motorola) data burst parameters. MDC-1200 is 1200 bps FFSK with a + /// 1200 Hz mark / 1800 Hz space, a preamble, a sync word, then op/arg/unit-id and a + /// CRC. Real off-air signals vary in polarity/preamble/sync between fleets, so the + /// tone polarity and sync word are configurable; defaults match the common case. + /// + public sealed class Mdc1200Settings + { + public double MarkHz { get; set; } = 1200; // bit '1' + public double SpaceHz { get; set; } = 1800; // bit '0' + public int Baud { get; set; } = 1200; + public int PreambleBits { get; set; } = 24; // alternating 1010… for clock lock + public ushort SyncWord { get; set; } = 0x0707; + /// Op-code that denotes an emergency (radio emergency button / ANI). + public byte EmergencyOp { get; set; } = 0x06; + } + + /// A decoded MDC-1200 packet. + public sealed class Mdc1200Packet + { + public byte Op { get; set; } + public byte Arg { get; set; } + public ushort UnitId { get; set; } + + public bool IsEmergency(Mdc1200Settings settings) => Op == settings.EmergencyOp; + + public override string ToString() => $"MDC1200 op=0x{Op:X2} arg=0x{Arg:X2} unit={UnitId:X4}"; + } + + /// + /// MDC-1200 FFSK encode/decode helpers. The encoder is primarily used to generate + /// known signals for unit tests (so the decoder can be validated by round-trip) and + /// for any future "transmit ANI" feature. CRC is CRC-16/CCITT (poly 0x1021, init + /// 0xFFFF) over the four payload bytes. + /// + public static class Mdc1200Codec + { + /// Generates a continuous-phase FFSK waveform (PCM16 mono) for a packet. + public static short[] Encode(Mdc1200Packet packet, Mdc1200Settings settings, int sampleRate, double amplitude = 0.6) + { + var bits = BuildBitStream(packet, settings); + int samplesPerBit = sampleRate / settings.Baud; + var samples = new short[bits.Count * samplesPerBit]; + + double phase = 0; + int idx = 0; + foreach (var bit in bits) + { + double freq = bit ? settings.MarkHz : settings.SpaceHz; + double inc = 2 * Math.PI * freq / sampleRate; + for (int s = 0; s < samplesPerBit; s++) + { + samples[idx++] = (short)(Math.Sin(phase) * amplitude * short.MaxValue); + phase += inc; + if (phase > 2 * Math.PI) phase -= 2 * Math.PI; + } + } + return samples; + } + + /// The bits transmitted: preamble + sync + op,arg,unitHi,unitLo + CRC16. + public static List BuildBitStream(Mdc1200Packet packet, Mdc1200Settings settings) + { + var bits = new List(); + for (int i = 0; i < settings.PreambleBits; i++) + bits.Add(i % 2 == 0); + + AppendBits(bits, settings.SyncWord, 16); + + var payload = new byte[] { packet.Op, packet.Arg, (byte)(packet.UnitId >> 8), (byte)(packet.UnitId & 0xFF) }; + foreach (var b in payload) + AppendBits(bits, b, 8); + + ushort crc = Crc16Ccitt(payload); + AppendBits(bits, crc, 16); + return bits; + } + + public static void AppendBits(List bits, int value, int count) + { + for (int i = count - 1; i >= 0; i--) + bits.Add(((value >> i) & 1) == 1); + } + + public static ushort Crc16Ccitt(ReadOnlySpan data) + { + ushort crc = 0xFFFF; + foreach (var b in data) + { + crc ^= (ushort)(b << 8); + for (int i = 0; i < 8; i++) + crc = (crc & 0x8000) != 0 ? (ushort)((crc << 1) ^ 0x1021) : (ushort)(crc << 1); + } + return crc; + } + + /// + /// Attempts to decode a single MDC-1200 packet from a PCM16 buffer by trying all + /// bit-phase alignments, locating the sync word, then verifying the CRC. + /// Returns null if no valid packet is present. + /// + public static Mdc1200Packet TryDecode(ReadOnlySpan pcm, Mdc1200Settings settings, int sampleRate) + { + int samplesPerBit = sampleRate / settings.Baud; + if (pcm.Length < samplesPerBit * (16 + 48)) + return null; + + // Copy to array so we can re-window across phase offsets. + var samples = pcm.ToArray(); + + for (int phase = 0; phase < samplesPerBit; phase++) + { + var bits = SliceBits(samples, phase, samplesPerBit, settings, sampleRate); + var packet = FindPacket(bits, settings); + if (packet != null) + return packet; + } + return null; + } + + private static bool[] SliceBits(short[] samples, int phase, int samplesPerBit, Mdc1200Settings settings, int sampleRate) + { + int n = (samples.Length - phase) / samplesPerBit; + if (n <= 0) + return Array.Empty(); + + var bits = new bool[n]; + for (int i = 0; i < n; i++) + { + int start = phase + i * samplesPerBit; + var window = samples.AsSpan(start, samplesPerBit); + double mark = Goertzel.Power(window, settings.MarkHz, sampleRate); + double space = Goertzel.Power(window, settings.SpaceHz, sampleRate); + bits[i] = mark >= space; + } + return bits; + } + + private static Mdc1200Packet FindPacket(bool[] bits, Mdc1200Settings settings) + { + // Search for the 16-bit sync word, then read 48 payload+crc bits. + for (int start = 0; start + 16 + 48 <= bits.Length; start++) + { + if (ReadValue(bits, start, 16) != settings.SyncWord) + continue; + + int p = start + 16; + byte op = (byte)ReadValue(bits, p, 8); + byte arg = (byte)ReadValue(bits, p + 8, 8); + byte unitHi = (byte)ReadValue(bits, p + 16, 8); + byte unitLo = (byte)ReadValue(bits, p + 24, 8); + ushort crc = (ushort)ReadValue(bits, p + 32, 16); + + var payload = new byte[] { op, arg, unitHi, unitLo }; + if (Crc16Ccitt(payload) == crc) + { + return new Mdc1200Packet + { + Op = op, + Arg = arg, + UnitId = (ushort)((unitHi << 8) | unitLo) + }; + } + } + return null; + } + + private static int ReadValue(bool[] bits, int start, int count) + { + int value = 0; + for (int i = 0; i < count; i++) + value = (value << 1) | (bits[start + i] ? 1 : 0); + return value; + } + } +} diff --git a/Resgrid.Audio.Voice/Dsp/Mdc1200Decoder.cs b/Resgrid.Audio.Voice/Dsp/Mdc1200Decoder.cs new file mode 100644 index 0000000..49311e9 --- /dev/null +++ b/Resgrid.Audio.Voice/Dsp/Mdc1200Decoder.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Audio.Voice.Dsp +{ + /// + /// Streaming MDC-1200 decoder. Buffers inbound RF audio and periodically attempts + /// to decode a packet, de-duplicating repeats. Feed it PCM16 mono frames from the + /// radio receive path. + /// + public sealed class Mdc1200Decoder + { + private readonly Mdc1200Settings _settings; + private readonly int _sampleRate; + private readonly int _windowSamples; + private readonly int _tailSamples; + private readonly List _buffer = new List(); + + private string _lastPacket; + private DateTime _lastPacketUtc; + + public event EventHandler PacketDecoded; + + public Mdc1200Decoder(Mdc1200Settings settings, int sampleRate) + { + _settings = settings ?? new Mdc1200Settings(); + _sampleRate = sampleRate; + + // One packet is ~ (preamble + 16 sync + 48 payload) bits. Window generously. + int samplesPerBit = sampleRate / _settings.Baud; + int packetBits = _settings.PreambleBits + 16 + 48; + _windowSamples = (int)(packetBits * samplesPerBit * 1.5); + _tailSamples = packetBits * samplesPerBit; // overlap so packets aren't split + } + + public void Process(ReadOnlySpan pcm) + { + for (int i = 0; i < pcm.Length; i++) + _buffer.Add(pcm[i]); + + if (_buffer.Count < _windowSamples) + return; + + var window = _buffer.ToArray(); + var packet = Mdc1200Codec.TryDecode(window, _settings, _sampleRate); + if (packet != null) + Emit(packet); + + // Retain a tail so a packet spanning the boundary still decodes next time. + int drop = _buffer.Count - _tailSamples; + if (drop > 0) + _buffer.RemoveRange(0, drop); + } + + private void Emit(Mdc1200Packet packet) + { + var key = packet.ToString(); + var now = DateTime.UtcNow; + if (key == _lastPacket && (now - _lastPacketUtc).TotalSeconds < 2) + return; + + _lastPacket = key; + _lastPacketUtc = now; + PacketDecoded?.Invoke(this, packet); + } + } +} diff --git a/Resgrid.Audio.Voice/Dsp/SquelchGate.cs b/Resgrid.Audio.Voice/Dsp/SquelchGate.cs new file mode 100644 index 0000000..e02e311 --- /dev/null +++ b/Resgrid.Audio.Voice/Dsp/SquelchGate.cs @@ -0,0 +1,100 @@ +using System; +using Resgrid.Audio.Voice.Abstractions; + +namespace Resgrid.Audio.Voice.Dsp +{ + /// How a radio receive path decides that real traffic (not static) is present. + public enum SquelchMode + { + /// Always open — relay everything (use only with a radio that has hardware squelch). + Off = 0, + + /// Audio-level (VOX) gate with hysteresis — the default anti-static gate. + Vox = 1, + + /// Hardware carrier-operated relay (COR/COS) via serial/GPIO — gate handled by the device. + Carrier = 2, + + /// Sub-audible CTCSS/PL tone must be present to open. + Ctcss = 3 + } + + /// Tunables for the VOX/level squelch — the core "don't relay static" controls. + public sealed class SquelchSettings + { + public SquelchMode Mode { get; set; } = SquelchMode.Vox; + + /// dBFS at/above which the gate opens. Set just above the static floor. + public double OpenDbfs { get; set; } = -38; + + /// dBFS below which the gate begins to close (must be ≤ OpenDbfs for hysteresis). + public double CloseDbfs { get; set; } = -45; + + /// Keep the gate open this long after audio drops, to avoid chopping speech. + public int HangMs { get; set; } = 600; + + /// CTCSS/PL tone frequency in Hz (Ctcss mode). + public double CtcssFrequency { get; set; } = 0; + + /// Goertzel strength (0..1) required for CTCSS detection. + public double CtcssMinStrength { get; set; } = 0.30; + } + + /// + /// Level-based (VOX) squelch with open/close hysteresis and a hang timer. Fed one + /// frame at a time, it decides whether the radio receive audio is real traffic + /// worth relaying or just noise/static. This is the primary tuning knob that keeps + /// static off the Resgrid channel. + /// + public sealed class SquelchGate + { + private readonly SquelchSettings _settings; + private readonly int _sampleRate; + private bool _open; + private double _hangRemainingMs; + + public SquelchGate(SquelchSettings settings, int sampleRate = AudioFormat.SampleRate) + { + _settings = settings ?? new SquelchSettings(); + _sampleRate = sampleRate; + } + + public bool IsOpen => _open; + + /// Most recent measured frame level in dBFS (for live tuning meters). + public double LastDbfs { get; private set; } + + /// Processes one frame; returns whether the gate is open (relay this audio). + public bool Process(ReadOnlySpan frame) + { + double db = AudioFormat.Dbfs(frame); + LastDbfs = db; + double frameMs = frame.Length * 1000.0 / _sampleRate; + + if (db >= _settings.OpenDbfs) + { + _open = true; + _hangRemainingMs = _settings.HangMs; + } + else if (_open && db < _settings.CloseDbfs) + { + _hangRemainingMs -= frameMs; + if (_hangRemainingMs <= 0) + _open = false; + } + else if (_open) + { + // Between close and open thresholds: hold open, refresh hang a little. + _hangRemainingMs = Math.Max(_hangRemainingMs, frameMs); + } + + return _open; + } + + public void ForceClosed() + { + _open = false; + _hangRemainingMs = 0; + } + } +} diff --git a/Resgrid.Audio.Voice/LiveKit/LiveKitAudioPublisher.cs b/Resgrid.Audio.Voice/LiveKit/LiveKitAudioPublisher.cs new file mode 100644 index 0000000..691f9be --- /dev/null +++ b/Resgrid.Audio.Voice/LiveKit/LiveKitAudioPublisher.cs @@ -0,0 +1,143 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using LiveKit.Rtc; +using Resgrid.Audio.Voice.Abstractions; +using Serilog; + +namespace Resgrid.Audio.Voice.LiveKit +{ + /// + /// Feeds PCM16 mono 48 kHz audio into a published LiveKit audio track. Accepts + /// arbitrary-length writes and emits fixed 10 ms (480-sample) frames, buffering + /// any residual between calls. Backed by a LiveKit . + /// + internal sealed class LiveKitAudioPublisher : IAudioPublisher + { + private readonly AudioSource _source; + private readonly LocalAudioTrack _track; + private readonly LocalParticipant _participant; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1); + + private readonly short[] _residual = new short[AudioFormat.SamplesPerFrame]; + private int _residualCount; + private bool _disposed; + + public LiveKitAudioPublisher(AudioSource source, LocalAudioTrack track, LocalParticipant participant, ILogger logger) + { + _source = source; + _track = track; + _participant = participant; + _logger = logger; + } + + public async ValueTask WriteAsync(ReadOnlyMemory pcm48kMono, CancellationToken cancellationToken = default) + { + if (_disposed) + return; + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Work over ReadOnlyMemory; take spans only inside synchronous copies so + // nothing span-typed is held across an await boundary. + var input = pcm48kMono; + int len = input.Length; + int offset = 0; + + // Top up an in-progress residual frame first. + if (_residualCount > 0) + { + int need = AudioFormat.SamplesPerFrame - _residualCount; + int take = Math.Min(need, len); + input.Slice(offset, take).Span.CopyTo(_residual.AsSpan(_residualCount)); + _residualCount += take; + offset += take; + + if (_residualCount == AudioFormat.SamplesPerFrame) + { + var frame = new short[AudioFormat.SamplesPerFrame]; + Array.Copy(_residual, frame, AudioFormat.SamplesPerFrame); + await CaptureAsync(frame, cancellationToken).ConfigureAwait(false); + _residualCount = 0; + } + } + + // Emit whole frames straight from the input. + while (len - offset >= AudioFormat.SamplesPerFrame) + { + var frame = new short[AudioFormat.SamplesPerFrame]; + input.Slice(offset, AudioFormat.SamplesPerFrame).Span.CopyTo(frame); + await CaptureAsync(frame, cancellationToken).ConfigureAwait(false); + offset += AudioFormat.SamplesPerFrame; + } + + // Stash the remainder. + int remaining = len - offset; + if (remaining > 0) + { + input.Slice(offset, remaining).Span.CopyTo(_residual.AsSpan(0)); + _residualCount = remaining; + } + } + finally + { + _gate.Release(); + } + } + + public async ValueTask FlushAsync(CancellationToken cancellationToken = default) + { + if (_disposed) + return; + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_residualCount > 0) + { + var frame = new short[AudioFormat.SamplesPerFrame]; + Array.Copy(_residual, frame, _residualCount); // remainder zero-padded (silence) + await CaptureAsync(frame, cancellationToken).ConfigureAwait(false); + _residualCount = 0; + } + } + finally + { + _gate.Release(); + } + } + + private async Task CaptureAsync(short[] frameSamples, CancellationToken cancellationToken) + { + var frame = new AudioFrame(frameSamples, AudioFormat.SampleRate, AudioFormat.Channels, AudioFormat.SamplesPerFrame, null); + await _source.CaptureFrameAsync(frame, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + // Flush buffered residual audio BEFORE marking disposed — FlushAsync + // short-circuits once _disposed is set, which would drop the final partial frame. + try { await FlushAsync(CancellationToken.None).ConfigureAwait(false); } + catch (Exception ex) { _logger?.Debug(ex, "Publisher flush on dispose failed"); } + + _disposed = true; + + try + { + if (_track != null && _participant != null) + await _participant.UnpublishTrackAsync(_track.Sid, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) { _logger?.Debug(ex, "Unpublish on dispose failed"); } + + try { _source?.Dispose(); } + catch (Exception ex) { _logger?.Debug(ex, "AudioSource dispose failed"); } + + _gate.Dispose(); + } + } +} diff --git a/Resgrid.Audio.Voice/LiveKit/LiveKitRoomSession.cs b/Resgrid.Audio.Voice/LiveKit/LiveKitRoomSession.cs new file mode 100644 index 0000000..34adbb0 --- /dev/null +++ b/Resgrid.Audio.Voice/LiveKit/LiveKitRoomSession.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using LiveKit.Rtc; +using Resgrid.Audio.Voice.Abstractions; +using Serilog; + +namespace Resgrid.Audio.Voice.LiveKit +{ + /// + /// A LiveKit-backed . Connects to one room, + /// surfaces every inbound audio frame (tagged with the transmitting participant), + /// and lets callers publish a local audio track. This is the single class that + /// touches the LiveKit RTC SDK for receive/transmit. + /// + internal sealed class LiveKitRoomSession : IVoiceRoomSession + { + private readonly VoiceChannel _channel; + private readonly int _publishQueueMs; + private readonly ILogger _logger; + + private Room _room; + private readonly ConcurrentDictionary _readLoops = + new ConcurrentDictionary(StringComparer.Ordinal); + private int _disposed; + + public LiveKitRoomSession(VoiceChannel channel, int publishQueueMs, ILogger logger) + { + _channel = channel; + _publishQueueMs = publishQueueMs <= 0 ? 1000 : publishQueueMs; + _logger = logger; + } + + public string ChannelId => _channel.Id; + public string ChannelName => _channel.Name; + public bool IsConnected => _room != null && _room.IsConnected; + + public event EventHandler AudioFrameReceived; + public event EventHandler ConnectionChanged; + + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + _room = new Room(); + + _room.TrackSubscribed += OnTrackSubscribed; + _room.TrackUnsubscribed += OnTrackUnsubscribed; + // Initial connect is signaled explicitly after ConnectAsync below (deterministic, + // fires exactly once); also subscribing to _room.Connected would double-notify. + _room.Disconnected += (_, reason) => RaiseConnection(false, reason.ToString()); + _room.Reconnecting += (_, __) => RaiseConnection(false, "reconnecting"); + _room.Reconnected += (_, __) => RaiseConnection(true, "reconnected"); + + var options = new RoomOptions { AutoSubscribe = true }; + + _logger?.Information("Connecting to PTT channel {Channel} ({ChannelId}) at {Url}", _channel.Name, _channel.Id, _channel.RoomUrl); + await _room.ConnectAsync(_channel.RoomUrl, _channel.Token, options, cancellationToken).ConfigureAwait(false); + RaiseConnection(true, "connected"); + } + + public async Task CreatePublisherAsync(string trackName, CancellationToken cancellationToken = default) + { + if (_room == null) + throw new InvalidOperationException("Connect the session before creating a publisher."); + + var source = new AudioSource(AudioFormat.SampleRate, AudioFormat.Channels, _publishQueueMs); + var track = LocalAudioTrack.Create(trackName ?? "relay", source); + var publishOptions = new TrackPublishOptions { Source = global::LiveKit.Proto.TrackSource.SourceMicrophone }; + + await _room.LocalParticipant.PublishTrackAsync(track, publishOptions, cancellationToken).ConfigureAwait(false); + _logger?.Information("Publishing local audio track '{Track}' on channel {Channel}", trackName, _channel.Name); + + return new LiveKitAudioPublisher(source, track, _room.LocalParticipant, _logger); + } + + private void OnTrackSubscribed(object sender, TrackSubscribedEventArgs e) + { + if (e?.Track == null || e.Track.Kind != global::LiveKit.Proto.TrackKind.KindAudio) + return; + + var participant = new VoiceParticipant( + e.Participant?.Sid, + e.Participant?.Identity, + e.Participant?.Name, + e.Participant?.Kind.ToString()); + + var sid = e.Track.Sid ?? e.Publication?.Sid ?? Guid.NewGuid().ToString(); + var loop = new TrackReadLoop(e.Track, sid, participant, OnFrame, _logger); + if (_readLoops.TryAdd(sid, loop)) + { + _logger?.Debug("Subscribed audio track {Sid} from {Participant}", sid, participant); + loop.Start(); + } + } + + private void OnTrackUnsubscribed(object sender, TrackSubscribedEventArgs e) + { + var sid = e?.Track?.Sid ?? e?.Publication?.Sid; + if (sid != null && _readLoops.TryRemove(sid, out var loop)) + { + _logger?.Debug("Unsubscribed audio track {Sid}", sid); + _ = loop.StopAsync(); + } + } + + private void OnFrame(VoiceAudioFrame frame) => AudioFrameReceived?.Invoke(this, frame); + + private void RaiseConnection(bool connected, string reason) => + ConnectionChanged?.Invoke(this, new VoiceConnectionStateChange(connected, reason)); + + public async Task DisconnectAsync(CancellationToken cancellationToken = default) + { + foreach (var kvp in _readLoops) + await kvp.Value.StopAsync().ConfigureAwait(false); + _readLoops.Clear(); + + if (_room != null) + { + try { await _room.DisconnectAsync().ConfigureAwait(false); } + catch (Exception ex) { _logger?.Debug(ex, "Room disconnect failed"); } + RaiseConnection(false, "disconnected"); + } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + return; + + await DisconnectAsync().ConfigureAwait(false); + try { _room?.Dispose(); } catch { /* best effort */ } + _room = null; + } + + /// + /// Pumps a single subscribed audio track: reads 48 kHz mono frames from a + /// LiveKit and forwards each as a tagged + /// . + /// + private sealed class TrackReadLoop + { + private readonly Track _track; + private readonly string _sid; + private readonly VoiceParticipant _participant; + private readonly Action _onFrame; + private readonly ILogger _logger; + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + private Task _task; + + public TrackReadLoop(Track track, string sid, VoiceParticipant participant, Action onFrame, ILogger logger) + { + _track = track; + _sid = sid; + _participant = participant; + _onFrame = onFrame; + _logger = logger; + } + + public void Start() => _task = Task.Run(PumpAsync); + + private async Task PumpAsync() + { + AudioStream stream = null; + try + { + stream = AudioStream.FromTrack( + _track, + (uint)AudioFormat.SampleRate, + (uint)AudioFormat.Channels, + null, // frameSizeMs — use the stream default + 200, // capacity (frames buffered) + null, // no noise cancellation + null); // no frame processor + + while (!_cts.IsCancellationRequested) + { + var evt = await stream.ReadAsync(_cts.Token).ConfigureAwait(false); + if (evt == null) + break; + + var data = evt.Value.Frame?.DataArray; + if (data == null || data.Length == 0) + continue; + + var pcm = (short[])data.Clone(); + _onFrame(new VoiceAudioFrame(_participant, _sid, pcm, DateTime.UtcNow)); + } + } + catch (OperationCanceledException) + { + // normal shutdown + } + catch (Exception ex) + { + _logger?.Debug(ex, "Audio read loop for track {Sid} ended", _sid); + } + finally + { + if (stream != null) + { + try { await stream.DisposeAsync().ConfigureAwait(false); } catch { /* best effort */ } + } + } + } + + public async Task StopAsync() + { + try { _cts.Cancel(); } catch { /* ignore */ } + if (_task != null) + { + try { await _task.ConfigureAwait(false); } catch { /* ignore */ } + } + _cts.Dispose(); + } + } + } +} diff --git a/Resgrid.Audio.Voice/LiveKit/LiveKitVoiceTransport.cs b/Resgrid.Audio.Voice/LiveKit/LiveKitVoiceTransport.cs new file mode 100644 index 0000000..0050a13 --- /dev/null +++ b/Resgrid.Audio.Voice/LiveKit/LiveKitVoiceTransport.cs @@ -0,0 +1,24 @@ +using Resgrid.Audio.Voice.Abstractions; +using Serilog; + +namespace Resgrid.Audio.Voice.LiveKit +{ + /// + /// Creates LiveKit-backed voice sessions. The single composition root that binds + /// the transport-agnostic engine to the LiveKit RTC SDK. + /// + public sealed class LiveKitVoiceTransport : IVoiceTransport + { + private readonly int _publishQueueMs; + private readonly ILogger _logger; + + public LiveKitVoiceTransport(ILogger logger, int publishQueueMs = 1000) + { + _logger = logger; + _publishQueueMs = publishQueueMs; + } + + public IVoiceRoomSession CreateSession(VoiceChannel channel) => + new LiveKitRoomSession(channel, _publishQueueMs, _logger); + } +} diff --git a/Resgrid.Audio.Voice/Recording/ITransmissionStore.cs b/Resgrid.Audio.Voice/Recording/ITransmissionStore.cs new file mode 100644 index 0000000..3d447a5 --- /dev/null +++ b/Resgrid.Audio.Voice/Recording/ITransmissionStore.cs @@ -0,0 +1,29 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Audio.Voice.Recording +{ + /// + /// Persists a recorded transmission's audio bytes. Implementations target local + /// disk or S3-compatible object storage; a recorder may write to several. + /// + public interface ITransmissionStore + { + /// Short identifier of the backing store ("local", "s3"). + string Kind { get; } + + /// + /// Persists under a name derived from + /// and returns where it landed. + /// + Task SaveAsync(string objectName, byte[] data, CancellationToken cancellationToken = default); + } + + /// + /// Appends transmission metadata to a durable log for compliance reporting. + /// + public interface ITransmissionLog : System.IAsyncDisposable + { + Task AppendAsync(TransmissionRecord record, CancellationToken cancellationToken = default); + } +} diff --git a/Resgrid.Audio.Voice/Recording/JsonlTransmissionLog.cs b/Resgrid.Audio.Voice/Recording/JsonlTransmissionLog.cs new file mode 100644 index 0000000..199556e --- /dev/null +++ b/Resgrid.Audio.Voice/Recording/JsonlTransmissionLog.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Audio.Voice.Recording +{ + /// + /// Append-only JSON Lines transmission log. One JSON object per line makes the log + /// trivially greppable, streamable, and tamper-evident-friendly while remaining + /// dependency-free. Writes are serialized so concurrent finalizers don't interleave. + /// + public sealed class JsonlTransmissionLog : ITransmissionLog + { + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + WriteIndented = false + }; + + private readonly string _path; + private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1); + + public JsonlTransmissionLog(string path) + { + _path = string.IsNullOrWhiteSpace(path) + ? Path.Combine(AppContext.BaseDirectory, "recordings", "transmissions.jsonl") + : path; + + var dir = Path.GetDirectoryName(_path); + if (!string.IsNullOrWhiteSpace(dir)) + Directory.CreateDirectory(dir); + } + + public async Task AppendAsync(TransmissionRecord record, CancellationToken cancellationToken = default) + { + var line = JsonSerializer.Serialize(record, JsonOptions) + "\n"; + var bytes = Encoding.UTF8.GetBytes(line); + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + using var fs = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read); + await fs.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await fs.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + public ValueTask DisposeAsync() + { + _gate.Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/Resgrid.Audio.Voice/Recording/LocalFileTransmissionStore.cs b/Resgrid.Audio.Voice/Recording/LocalFileTransmissionStore.cs new file mode 100644 index 0000000..5a03fdc --- /dev/null +++ b/Resgrid.Audio.Voice/Recording/LocalFileTransmissionStore.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Audio.Voice.Recording +{ + /// + /// Writes recorded transmissions to the local filesystem, partitioned by UTC date + /// (root/yyyy/MM/dd/objectName) so long-running recorders don't pile thousands of + /// files into one directory. + /// + public sealed class LocalFileTransmissionStore : ITransmissionStore + { + private readonly string _root; + + public LocalFileTransmissionStore(string root) + { + _root = string.IsNullOrWhiteSpace(root) + ? Path.Combine(AppContext.BaseDirectory, "recordings") + : root; + } + + public string Kind => "local"; + + public async Task SaveAsync(string objectName, byte[] data, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var dir = Path.Combine(_root, now.ToString("yyyy"), now.ToString("MM"), now.ToString("dd")); + Directory.CreateDirectory(dir); + + var path = Path.Combine(dir, objectName); + await File.WriteAllBytesAsync(path, data, cancellationToken).ConfigureAwait(false); + + return new StoredLocation { Kind = Kind, Location = path, SizeBytes = data.LongLength }; + } + } +} diff --git a/Resgrid.Audio.Voice/Recording/RecorderSettings.cs b/Resgrid.Audio.Voice/Recording/RecorderSettings.cs new file mode 100644 index 0000000..79f2ce5 --- /dev/null +++ b/Resgrid.Audio.Voice/Recording/RecorderSettings.cs @@ -0,0 +1,24 @@ +namespace Resgrid.Audio.Voice.Recording +{ + /// + /// Tunables controlling how a channel's audio is segmented into discrete + /// transmissions for recording. + /// + public sealed class RecorderSettings + { + /// End a transmission after this many ms of silence (no active audio). + public int HangMs { get; set; } = 1500; + + /// Hard cap on a single transmission; rolls to a new file beyond this. + public int MaxSegmentSeconds { get; set; } = 300; + + /// Drop transmissions whose total recorded audio is shorter than this. + public int MinActiveMs { get; set; } = 250; + + /// dBFS below which a frame is treated as silence for segmentation. + public double SilenceFloorDbfs { get; set; } = -50; + + /// How often the finalizer scans for ended transmissions. + public int ScanIntervalMs { get; set; } = 500; + } +} diff --git a/Resgrid.Audio.Voice/Recording/S3TransmissionStore.cs b/Resgrid.Audio.Voice/Recording/S3TransmissionStore.cs new file mode 100644 index 0000000..b802af9 --- /dev/null +++ b/Resgrid.Audio.Voice/Recording/S3TransmissionStore.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; + +namespace Resgrid.Audio.Voice.Recording +{ + /// + /// Uploads recorded transmissions to S3 (or any S3-compatible endpoint such as + /// MinIO, matching how Resgrid stores TTS audio). Objects are keyed by UTC date + /// under an optional prefix. + /// + public sealed class S3TransmissionStore : ITransmissionStore, IDisposable + { + private readonly IAmazonS3 _client; + private readonly string _bucket; + private readonly string _prefix; + private readonly bool _ownsClient; + + public S3TransmissionStore(IAmazonS3 client, string bucket, string prefix, bool ownsClient = false) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _bucket = bucket ?? throw new ArgumentNullException(nameof(bucket)); + _prefix = (prefix ?? string.Empty).Trim('/'); + _ownsClient = ownsClient; + } + + public string Kind => "s3"; + + /// + /// Builds a store from connection settings. Supports a custom + /// (S3-compatible services) or an AWS + /// . Falls back to the ambient AWS credential chain + /// when no access key is supplied. + /// + public static S3TransmissionStore Create( + string endpoint, string accessKey, string secretKey, string region, + string bucket, string prefix, bool forcePathStyle, bool useSsl) + { + var cfg = new AmazonS3Config(); + + if (!string.IsNullOrWhiteSpace(endpoint)) + { + if (!endpoint.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + endpoint = (useSsl ? "https://" : "http://") + endpoint; + cfg.ServiceURL = endpoint; + cfg.ForcePathStyle = forcePathStyle; + } + else if (!string.IsNullOrWhiteSpace(region)) + { + cfg.RegionEndpoint = RegionEndpoint.GetBySystemName(region); + } + + IAmazonS3 client = !string.IsNullOrWhiteSpace(accessKey) + ? new AmazonS3Client(new BasicAWSCredentials(accessKey, secretKey), cfg) + : new AmazonS3Client(cfg); + + return new S3TransmissionStore(client, bucket, prefix, ownsClient: true); + } + + public async Task SaveAsync(string objectName, byte[] data, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var key = string.Join("/", + string.IsNullOrEmpty(_prefix) ? null : _prefix, + now.ToString("yyyy"), now.ToString("MM"), now.ToString("dd"), objectName) + .TrimStart('/'); + + using var stream = new MemoryStream(data, writable: false); + var request = new PutObjectRequest + { + BucketName = _bucket, + Key = key, + InputStream = stream, + ContentType = "audio/wav", + AutoCloseStream = false + }; + + await _client.PutObjectAsync(request, cancellationToken).ConfigureAwait(false); + + return new StoredLocation { Kind = Kind, Location = $"s3://{_bucket}/{key}", SizeBytes = data.LongLength }; + } + + public void Dispose() + { + if (_ownsClient) + _client?.Dispose(); + } + } +} diff --git a/Resgrid.Audio.Voice/Recording/SqliteTransmissionLog.cs b/Resgrid.Audio.Voice/Recording/SqliteTransmissionLog.cs new file mode 100644 index 0000000..bd67b1a --- /dev/null +++ b/Resgrid.Audio.Voice/Recording/SqliteTransmissionLog.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; + +namespace Resgrid.Audio.Voice.Recording +{ + /// + /// Queryable transmission log backed by SQLite. Useful when compliance staff need + /// to search/report (by user, channel, time range) rather than scan a flat file. + /// The full record is also stored as JSON for forward-compatibility. + /// + public sealed class SqliteTransmissionLog : ITransmissionLog + { + private readonly string _connectionString; + private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1); + + public SqliteTransmissionLog(string databasePath) + { + if (string.IsNullOrWhiteSpace(databasePath)) + databasePath = Path.Combine(AppContext.BaseDirectory, "recordings", "transmissions.db"); + + var dir = Path.GetDirectoryName(databasePath); + if (!string.IsNullOrWhiteSpace(dir)) + Directory.CreateDirectory(dir); + + _connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath }.ToString(); + EnsureSchema(); + } + + private void EnsureSchema() + { + using var conn = new SqliteConnection(_connectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" +CREATE TABLE IF NOT EXISTS transmissions ( + id TEXT PRIMARY KEY, + channel_id TEXT, + channel_name TEXT, + participant_identity TEXT, + participant_name TEXT, + track_sid TEXT, + start_utc TEXT, + end_utc TEXT, + duration_ms INTEGER, + sample_rate INTEGER, + channels INTEGER, + codec TEXT, + samples INTEGER, + locations TEXT, + record_json TEXT +); +CREATE INDEX IF NOT EXISTS ix_tx_channel_start ON transmissions(channel_id, start_utc); +CREATE INDEX IF NOT EXISTS ix_tx_identity ON transmissions(participant_identity);"; + cmd.ExecuteNonQuery(); + } + + public async Task AppendAsync(TransmissionRecord record, CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await using var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + var cmd = conn.CreateCommand(); + cmd.CommandText = @" +INSERT OR REPLACE INTO transmissions +(id, channel_id, channel_name, participant_identity, participant_name, track_sid, + start_utc, end_utc, duration_ms, sample_rate, channels, codec, samples, locations, record_json) +VALUES ($id,$cid,$cname,$pid,$pname,$tsid,$start,$end,$dur,$rate,$ch,$codec,$samples,$loc,$json);"; + + cmd.Parameters.AddWithValue("$id", record.Id ?? string.Empty); + cmd.Parameters.AddWithValue("$cid", (object)record.ChannelId ?? DBNull.Value); + cmd.Parameters.AddWithValue("$cname", (object)record.ChannelName ?? DBNull.Value); + cmd.Parameters.AddWithValue("$pid", (object)record.ParticipantIdentity ?? DBNull.Value); + cmd.Parameters.AddWithValue("$pname", (object)record.ParticipantName ?? DBNull.Value); + cmd.Parameters.AddWithValue("$tsid", (object)record.TrackSid ?? DBNull.Value); + cmd.Parameters.AddWithValue("$start", record.StartUtc.ToString("O")); + cmd.Parameters.AddWithValue("$end", record.EndUtc.ToString("O")); + cmd.Parameters.AddWithValue("$dur", record.DurationMs); + cmd.Parameters.AddWithValue("$rate", record.SampleRate); + cmd.Parameters.AddWithValue("$ch", record.Channels); + cmd.Parameters.AddWithValue("$codec", (object)record.Codec ?? DBNull.Value); + cmd.Parameters.AddWithValue("$samples", record.Samples); + cmd.Parameters.AddWithValue("$loc", JsonSerializer.Serialize(record.Locations)); + cmd.Parameters.AddWithValue("$json", JsonSerializer.Serialize(record)); + + await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + public ValueTask DisposeAsync() + { + _gate.Dispose(); + SqliteConnection.ClearAllPools(); + return ValueTask.CompletedTask; + } + } +} diff --git a/Resgrid.Audio.Voice/Recording/TransmissionRecord.cs b/Resgrid.Audio.Voice/Recording/TransmissionRecord.cs new file mode 100644 index 0000000..d664e8e --- /dev/null +++ b/Resgrid.Audio.Voice/Recording/TransmissionRecord.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Resgrid.Audio.Voice.Recording +{ + /// + /// Where a recorded transmission's audio was persisted (local path or S3 URI). + /// + public sealed class StoredLocation + { + [JsonPropertyName("kind")] public string Kind { get; set; } + [JsonPropertyName("location")] public string Location { get; set; } + [JsonPropertyName("sizeBytes")] public long SizeBytes { get; set; } + } + + /// + /// Compliance metadata for a single recorded transmission (one talk-spurt) on a + /// PTT channel: who transmitted, when, for how long, on which channel, and where + /// the audio is stored. One of these is appended to the transmission log per + /// transmission for legal/after-action retention. + /// + public sealed class TransmissionRecord + { + [JsonPropertyName("id")] public string Id { get; set; } + [JsonPropertyName("channelId")] public string ChannelId { get; set; } + [JsonPropertyName("channelName")] public string ChannelName { get; set; } + [JsonPropertyName("roomName")] public string RoomName { get; set; } + + [JsonPropertyName("participantIdentity")] public string ParticipantIdentity { get; set; } + [JsonPropertyName("participantName")] public string ParticipantName { get; set; } + [JsonPropertyName("trackSid")] public string TrackSid { get; set; } + + [JsonPropertyName("startUtc")] public DateTime StartUtc { get; set; } + [JsonPropertyName("endUtc")] public DateTime EndUtc { get; set; } + [JsonPropertyName("durationMs")] public long DurationMs { get; set; } + + [JsonPropertyName("sampleRate")] public int SampleRate { get; set; } + [JsonPropertyName("channels")] public int Channels { get; set; } + [JsonPropertyName("codec")] public string Codec { get; set; } + [JsonPropertyName("samples")] public long Samples { get; set; } + + [JsonPropertyName("locations")] public List Locations { get; set; } = new List(); + } +} diff --git a/Resgrid.Audio.Voice/Recording/TransmissionRecorder.cs b/Resgrid.Audio.Voice/Recording/TransmissionRecorder.cs new file mode 100644 index 0000000..2fe9cfd --- /dev/null +++ b/Resgrid.Audio.Voice/Recording/TransmissionRecorder.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Audio.Voice.Abstractions; +using Resgrid.Audio.Voice.Audio; +using Serilog; + +namespace Resgrid.Audio.Voice.Recording +{ + /// + /// Records every transmission on a PTT channel for compliance. Subscribes to a + /// session's inbound audio, segments it per participant talk-spurt (start on + /// first audio, end after a silence hang), then writes a WAV to each configured + /// store and appends a metadata row (who/when/duration/channel/where) to the log. + /// + public sealed class TransmissionRecorder : IAsyncDisposable + { + private readonly IVoiceRoomSession _session; + private readonly RecorderSettings _settings; + private readonly IReadOnlyList _stores; + private readonly ITransmissionLog _log; + private readonly ILogger _logger; + + private readonly ConcurrentDictionary _segments = + new ConcurrentDictionary(StringComparer.Ordinal); + private Timer _scanTimer; + private int _started; + + /// Raised after a transmission is fully persisted. + public event EventHandler TransmissionRecorded; + + public TransmissionRecorder( + IVoiceRoomSession session, + RecorderSettings settings, + IReadOnlyList stores, + ITransmissionLog log, + ILogger logger) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _settings = settings ?? new RecorderSettings(); + _stores = stores ?? Array.Empty(); + _log = log; + _logger = logger; + } + + public void Start() + { + if (Interlocked.Exchange(ref _started, 1) == 1) + return; + + _session.AudioFrameReceived += OnAudioFrame; + _scanTimer = new Timer(_ => ScanForEnded(), null, _settings.ScanIntervalMs, _settings.ScanIntervalMs); + _logger?.Information("Recording channel {Channel} ({ChannelId}) to {Stores}", + _session.ChannelName, _session.ChannelId, string.Join("+", _stores.Select(s => s.Kind))); + } + + private void OnAudioFrame(object sender, VoiceAudioFrame frame) + { + if (frame?.Pcm == null || frame.Pcm.Length == 0 || string.IsNullOrEmpty(frame.TrackSid)) + return; + + bool active = AudioFormat.Dbfs(frame.Pcm) > _settings.SilenceFloorDbfs; + var now = frame.TimestampUtc; + + var seg = _segments.GetOrAdd(frame.TrackSid, _ => new Segment(frame.TrackSid, frame.Participant, now)); + bool rollover; + lock (seg.Sync) + { + seg.Append(frame.Pcm, active, now); + rollover = seg.DurationSeconds >= _settings.MaxSegmentSeconds; + } + + if (rollover && _segments.TryRemove(frame.TrackSid, out var rolled)) + _ = FinalizeAsync(rolled); + } + + private void ScanForEnded() + { + var cutoff = DateTime.UtcNow; + foreach (var kvp in _segments.ToArray()) + { + var seg = kvp.Value; + bool ended; + lock (seg.Sync) + ended = (cutoff - seg.LastActiveUtc).TotalMilliseconds > _settings.HangMs; + + if (ended && _segments.TryRemove(kvp.Key, out var ready)) + _ = FinalizeAsync(ready); + } + } + + private async Task FinalizeAsync(Segment seg) + { + if (!seg.TryClaimFinalize()) + return; + + short[] pcm; + DateTime start, end; + bool hadActive; + lock (seg.Sync) + { + pcm = seg.Pcm.ToArray(); + start = seg.StartUtc; + end = seg.LastFrameUtc; + hadActive = seg.HadActive; + } + + double durationMs = pcm.Length / (double)AudioFormat.SampleRate * 1000.0; + if (!hadActive || durationMs < _settings.MinActiveMs) + return; // silence/comfort-noise only — not a real transmission + + try + { + var record = new TransmissionRecord + { + Id = Guid.NewGuid().ToString("N"), + ChannelId = _session.ChannelId, + ChannelName = _session.ChannelName, + RoomName = _session.ChannelId, + ParticipantIdentity = seg.Participant?.Identity, + ParticipantName = seg.Participant?.Name, + TrackSid = seg.TrackSid, + StartUtc = start, + EndUtc = end, + DurationMs = (long)durationMs, + SampleRate = AudioFormat.SampleRate, + Channels = AudioFormat.Channels, + Codec = "pcm_s16le", + Samples = pcm.Length + }; + + var wav = WavIo.WritePcm16(pcm, AudioFormat.SampleRate, AudioFormat.Channels); + var objectName = BuildObjectName(record); + + foreach (var store in _stores) + { + try + { + var location = await store.SaveAsync(objectName, wav).ConfigureAwait(false); + record.Locations.Add(location); + } + catch (Exception ex) + { + _logger?.Error(ex, "Failed to store transmission to {Store}", store.Kind); + } + } + + if (_log != null) + await _log.AppendAsync(record).ConfigureAwait(false); + + _logger?.Information("Recorded {Duration} ms from {Participant} on {Channel}", + record.DurationMs, record.ParticipantName ?? record.ParticipantIdentity ?? "unknown", record.ChannelName); + TransmissionRecorded?.Invoke(this, record); + } + catch (Exception ex) + { + _logger?.Error(ex, "Failed to finalize transmission on channel {Channel}", _session.ChannelName); + } + } + + private static string BuildObjectName(TransmissionRecord r) + { + var who = Sanitize(r.ParticipantName ?? r.ParticipantIdentity ?? "unknown"); + var chan = Sanitize(r.ChannelName ?? r.ChannelId ?? "channel"); + var ts = r.StartUtc.ToString("yyyyMMdd'T'HHmmss'Z'"); + return $"{chan}_{ts}_{who}_{r.Id.Substring(0, 8)}.wav"; + } + + private static string Sanitize(string value) + { + var sb = new StringBuilder(value.Length); + foreach (var c in value) + sb.Append(char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '_'); + return sb.ToString().Trim('_'); + } + + public async ValueTask DisposeAsync() + { + _session.AudioFrameReceived -= OnAudioFrame; + if (_scanTimer != null) + await _scanTimer.DisposeAsync().ConfigureAwait(false); + + // Flush any in-flight transmissions. + foreach (var kvp in _segments.ToArray()) + { + if (_segments.TryRemove(kvp.Key, out var seg)) + await FinalizeAsync(seg).ConfigureAwait(false); + } + } + + /// An in-progress recording for one participant track. + private sealed class Segment + { + public readonly object Sync = new object(); + private int _finalized; + + public Segment(string trackSid, VoiceParticipant participant, DateTime startUtc) + { + TrackSid = trackSid; + Participant = participant; + StartUtc = startUtc; + LastFrameUtc = startUtc; + LastActiveUtc = startUtc; + } + + public string TrackSid { get; } + public VoiceParticipant Participant { get; } + public DateTime StartUtc { get; } + public DateTime LastFrameUtc { get; private set; } + public DateTime LastActiveUtc { get; private set; } + public bool HadActive { get; private set; } + public List Pcm { get; } = new List(); + public double DurationSeconds => Pcm.Count / (double)AudioFormat.SampleRate; + + public void Append(short[] pcm, bool active, DateTime now) + { + Pcm.AddRange(pcm); + LastFrameUtc = now; + if (active) + { + LastActiveUtc = now; + HadActive = true; + } + } + + public bool TryClaimFinalize() => Interlocked.Exchange(ref _finalized, 1) == 0; + } + } +} diff --git a/Resgrid.Audio.Voice/Resgrid.Audio.Voice.csproj b/Resgrid.Audio.Voice/Resgrid.Audio.Voice.csproj new file mode 100644 index 0000000..f1808bd --- /dev/null +++ b/Resgrid.Audio.Voice/Resgrid.Audio.Voice.csproj @@ -0,0 +1,22 @@ + + + net10.0 + Resgrid.Audio.Voice + Resgrid.Audio.Voice + false + disable + disable + + + + + + + + + + + + + + diff --git a/Resgrid.Audio.Voice/ToneOut/DispatchToneOutService.cs b/Resgrid.Audio.Voice/ToneOut/DispatchToneOutService.cs new file mode 100644 index 0000000..308246b --- /dev/null +++ b/Resgrid.Audio.Voice/ToneOut/DispatchToneOutService.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Audio.Voice.Abstractions; +using Serilog; + +namespace Resgrid.Audio.Voice.ToneOut +{ + /// + /// Builds and publishes a dispatch "tone-out": alert tones followed by a spoken + /// (TTS) announcement of the call, transmitted onto a PTT channel — the LiveKit + /// equivalent of toning out a station over the radio. + /// + public sealed class DispatchToneOutService + { + private readonly ITextToSpeech _tts; + private readonly ToneGenerator _toneGenerator; + private readonly ToneProfile _profile; + private readonly ILogger _logger; + + public DispatchToneOutService(ITextToSpeech tts, ToneGenerator toneGenerator, ToneProfile profile, ILogger logger) + { + _tts = tts ?? throw new ArgumentNullException(nameof(tts)); + _toneGenerator = toneGenerator ?? new ToneGenerator(); + _profile = profile ?? new ToneProfile(); + _logger = logger; + } + + /// Renders alert tones + TTS for into one PCM16 48 kHz buffer. + public async Task BuildAnnouncementAsync(string text, CancellationToken cancellationToken = default) + { + var alert = _toneGenerator.BuildAlert(_profile); + var speech = await _tts.SynthesizeAsync(text, cancellationToken).ConfigureAwait(false); + + var combined = new short[alert.Length + speech.Length]; + Array.Copy(alert, 0, combined, 0, alert.Length); + Array.Copy(speech, 0, combined, alert.Length, speech.Length); + return combined; + } + + /// Builds and transmits an announcement on the given publisher. + public async Task AnnounceAsync(IAudioPublisher publisher, string text, CancellationToken cancellationToken = default) + { + if (publisher == null) + throw new ArgumentNullException(nameof(publisher)); + + // Normalize once so the preview logging (text.Length/Substring) and the + // announcement build can't NRE on a null text argument. + text ??= string.Empty; + + var audio = await BuildAnnouncementAsync(text, cancellationToken).ConfigureAwait(false); + _logger?.Information("Toning out dispatch ({Ms} ms): {Preview}", + audio.Length / (AudioFormat.SampleRate / 1000), + text.Length > 80 ? text.Substring(0, 80) + "…" : text); + + // CaptureFrameAsync back-pressures on the AudioSource queue, so this paces + // roughly in real time as the audio is transmitted. + await publisher.WriteAsync(audio, cancellationToken).ConfigureAwait(false); + await publisher.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + /// Builds and transmits the announcement across several channels. + public async Task AnnounceToChannelsAsync(IEnumerable publishers, string text, CancellationToken cancellationToken = default) + { + var audio = await BuildAnnouncementAsync(text, cancellationToken).ConfigureAwait(false); + foreach (var publisher in publishers) + { + try + { + await publisher.WriteAsync(audio, cancellationToken).ConfigureAwait(false); + await publisher.FlushAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Normal shutdown — let cancellation propagate instead of logging it as a failure. + throw; + } + catch (Exception ex) + { + _logger?.Error(ex, "Failed to tone out on a channel"); + } + } + } + } +} diff --git a/Resgrid.Audio.Voice/ToneOut/HostedDepartmentVoiceResolver.cs b/Resgrid.Audio.Voice/ToneOut/HostedDepartmentVoiceResolver.cs new file mode 100644 index 0000000..da3267a --- /dev/null +++ b/Resgrid.Audio.Voice/ToneOut/HostedDepartmentVoiceResolver.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Audio.Voice.Abstractions; +using Resgrid.Audio.Voice.Connection; + +namespace Resgrid.Audio.Voice.ToneOut +{ + /// + /// Hosted (Resgrid staff) multi-department tone-out resolver. SCAFFOLD — wired for + /// the deferred hosted scenario where a single relay tones out calls into the PTT + /// channels of ANY configured department. + /// + /// Two completion paths are intended: + /// 1. Pass the target departmentId to when the + /// Resgrid Voice API supports system-key, on-behalf-of resolution (preferred — + /// reuses the server-minted tokens), or + /// 2. Mint LiveKit tokens directly with Livekit.Server.Sdk.Dotnet using the + /// department's voice channel room ids + the LiveKit API key/secret. + /// + /// Until the hosted API surface is finalized this delegates to the provider with an + /// explicit departmentId and throws a clear error if that path is unavailable. + /// + public sealed class HostedDepartmentVoiceResolver : IDepartmentVoiceResolver + { + private readonly IVoiceChannelProvider _channels; + + public HostedDepartmentVoiceResolver(IVoiceChannelProvider channels) + { + _channels = channels; + } + + public async Task> ResolveAnnouncementChannelsAsync( + string departmentId, string channelSelector, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(departmentId)) + throw new InvalidOperationException( + "Hosted tone-out requires an explicit target departmentId. " + + "Configure the SystemApiKey grant and the department to announce to."); + + // Path 1: ask the Voice API for the department's channels on behalf of the + // system key. When the hosted API surface lands this returns server-minted + // tokens just like the customer path. + var channel = await _channels.GetChannelAsync(channelSelector, departmentId, cancellationToken).ConfigureAwait(false); + return new[] { channel }; + } + } +} diff --git a/Resgrid.Audio.Voice/ToneOut/IDepartmentVoiceResolver.cs b/Resgrid.Audio.Voice/ToneOut/IDepartmentVoiceResolver.cs new file mode 100644 index 0000000..db8710d --- /dev/null +++ b/Resgrid.Audio.Voice/ToneOut/IDepartmentVoiceResolver.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Audio.Voice.Abstractions; +using Resgrid.Audio.Voice.Connection; + +namespace Resgrid.Audio.Voice.ToneOut +{ + /// + /// Resolves which department(s)/channel(s) a dispatch tone-out targets. The + /// customer resolver targets the relay's own department; the hosted resolver + /// (Resgrid staff, multi-department) is scaffolded for a later phase. + /// + public interface IDepartmentVoiceResolver + { + Task> ResolveAnnouncementChannelsAsync( + string departmentId, string channelSelector, CancellationToken cancellationToken = default); + } + + /// + /// Customer (single-department) resolver: announces on the relay's own department + /// voice channel(s) via the standard authenticated Voice API. + /// + public sealed class CustomerDepartmentVoiceResolver : IDepartmentVoiceResolver + { + private readonly IVoiceChannelProvider _channels; + + public CustomerDepartmentVoiceResolver(IVoiceChannelProvider channels) + { + _channels = channels ?? throw new ArgumentNullException(nameof(channels)); + } + + public async Task> ResolveAnnouncementChannelsAsync( + string departmentId, string channelSelector, CancellationToken cancellationToken = default) + { + var channel = await _channels.GetChannelAsync(channelSelector, departmentId, cancellationToken).ConfigureAwait(false); + return new[] { channel }; + } + } +} diff --git a/Resgrid.Audio.Voice/ToneOut/ITextToSpeech.cs b/Resgrid.Audio.Voice/ToneOut/ITextToSpeech.cs new file mode 100644 index 0000000..ca9b517 --- /dev/null +++ b/Resgrid.Audio.Voice/ToneOut/ITextToSpeech.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Audio.Voice.ToneOut +{ + /// + /// Synthesizes spoken audio for a dispatch announcement. Implementations return + /// PCM16 mono at the engine rate (48 kHz) ready to publish to a PTT channel. + /// + public interface ITextToSpeech + { + Task SynthesizeAsync(string text, CancellationToken cancellationToken = default); + } +} diff --git a/Resgrid.Audio.Voice/ToneOut/ResgridTtsClient.cs b/Resgrid.Audio.Voice/ToneOut/ResgridTtsClient.cs new file mode 100644 index 0000000..e11114b --- /dev/null +++ b/Resgrid.Audio.Voice/ToneOut/ResgridTtsClient.cs @@ -0,0 +1,93 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Audio.Voice.Abstractions; +using Resgrid.Audio.Voice.Audio; +using Serilog; + +namespace Resgrid.Audio.Voice.ToneOut +{ + /// + /// backed by the Resgrid Core TTS service. Requests + /// synthesis (POST /tts), downloads the generated 8 kHz mono µ-law WAV + /// (GET /tts/audio/{hash}.wav), and resamples it to 48 kHz PCM16 for publishing. + /// + public sealed class ResgridTtsClient : ITextToSpeech, IDisposable + { + private readonly TtsSettings _settings; + private readonly HttpClient _http; + private readonly ILogger _logger; + + public ResgridTtsClient(TtsSettings settings, ILogger logger, HttpClient http = null) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + if (string.IsNullOrWhiteSpace(_settings.ServiceBaseUrl)) + throw new ArgumentException("Tts ServiceBaseUrl is required for dispatch tone-out.", nameof(settings)); + + _logger = logger; + _http = http ?? new HttpClient(); + _http.Timeout = TimeSpan.FromSeconds(Math.Max(5, _settings.RequestTimeoutSeconds)); + } + + public async Task SynthesizeAsync(string text, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text)) + return Array.Empty(); + + var request = new TtsRequest + { + Text = text, + Voice = string.IsNullOrWhiteSpace(_settings.Voice) ? null : _settings.Voice, + Speed = _settings.Speed > 0 ? _settings.Speed : (int?)null + }; + + var generateUrl = Combine(_settings.ServiceBaseUrl, "tts"); + using var postResponse = await _http.PostAsJsonAsync(generateUrl, request, cancellationToken).ConfigureAwait(false); + postResponse.EnsureSuccessStatusCode(); + + var result = await postResponse.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); + if (result == null || string.IsNullOrWhiteSpace(result.Hash)) + throw new InvalidOperationException("TTS service did not return an audio hash."); + + var audioUrl = !string.IsNullOrWhiteSpace(result.Url) && result.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? result.Url + : Combine(PlaybackBase(), $"tts/audio/{result.Hash}.wav"); + + var wav = await _http.GetByteArrayAsync(audioUrl, cancellationToken).ConfigureAwait(false); + var (samples, sampleRate) = WavIo.ReadToPcm16Mono(wav); + + var resampled = Resampler.Resample(samples, sampleRate, AudioFormat.SampleRate); + _logger?.Debug("Synthesized {Chars} chars -> {Ms} ms of audio (cached={Cached})", + text.Length, resampled.Length / (AudioFormat.SampleRate / 1000), result.Cached); + return resampled; + } + + private string PlaybackBase() => + string.IsNullOrWhiteSpace(_settings.PlaybackBaseUrl) ? _settings.ServiceBaseUrl : _settings.PlaybackBaseUrl; + + private static string Combine(string baseUrl, string path) => + $"{baseUrl.TrimEnd('/')}/{path.TrimStart('/')}"; + + public void Dispose() => _http?.Dispose(); + + private sealed class TtsRequest + { + [JsonPropertyName("text")] public string Text { get; set; } + [JsonPropertyName("voice")] public string Voice { get; set; } + [JsonPropertyName("speed")] public int? Speed { get; set; } + } + + private sealed class TtsResponse + { + [JsonPropertyName("hash")] public string Hash { get; set; } + [JsonPropertyName("objectKey")] public string ObjectKey { get; set; } + [JsonPropertyName("url")] public string Url { get; set; } + [JsonPropertyName("voice")] public string Voice { get; set; } + [JsonPropertyName("speed")] public int Speed { get; set; } + [JsonPropertyName("cached")] public bool Cached { get; set; } + } + } +} diff --git a/Resgrid.Audio.Voice/ToneOut/ToneGenerator.cs b/Resgrid.Audio.Voice/ToneOut/ToneGenerator.cs new file mode 100644 index 0000000..7e11744 --- /dev/null +++ b/Resgrid.Audio.Voice/ToneOut/ToneGenerator.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using Resgrid.Audio.Voice.Abstractions; + +namespace Resgrid.Audio.Voice.ToneOut +{ + /// + /// Synthesizes alert tones (sine-based) at the engine rate (48 kHz mono PCM16): + /// two-tone sequential paging, hi/lo warble, single tones, silence, and a + /// courtesy beep. Pure DSP — no device or SDK dependencies, so it is unit-testable. + /// + public sealed class ToneGenerator + { + private readonly int _sampleRate; + + public ToneGenerator(int sampleRate = AudioFormat.SampleRate) + { + _sampleRate = sampleRate; + } + + /// A pure sine tone with short raised-cosine fades to avoid clicks. + public short[] Sine(double frequencyHz, int milliseconds, double amplitude = 0.5) + { + int count = Math.Max(0, _sampleRate * milliseconds / 1000); + var samples = new short[count]; + double step = 2 * Math.PI * frequencyHz / _sampleRate; + int fade = Math.Min(count / 2, _sampleRate / 200); // ~5 ms fade + + for (int i = 0; i < count; i++) + { + double env = 1.0; + if (i < fade) env = 0.5 * (1 - Math.Cos(Math.PI * i / fade)); + else if (i >= count - fade) env = 0.5 * (1 - Math.Cos(Math.PI * (count - 1 - i) / fade)); + + double value = Math.Sin(i * step) * amplitude * env; + double scaled = value * short.MaxValue; + // Clamp before the cast so a ToneProfile.Amplitude > 1 clips instead of wrapping (sign flip). + samples[i] = (short)Math.Clamp(scaled, short.MinValue, short.MaxValue); + } + return samples; + } + + public short[] Silence(int milliseconds) + { + int count = Math.Max(0, _sampleRate * milliseconds / 1000); + return new short[count]; + } + + /// A short courtesy beep (e.g. a repeater "K" tone after unkey). + public short[] CourtesyBeep(double frequencyHz = 660, int milliseconds = 120, double amplitude = 0.4) + => Sine(frequencyHz, milliseconds, amplitude); + + /// Builds the full alert preamble for a . + public short[] BuildAlert(ToneProfile profile) + { + if (profile == null || profile.Type == ToneType.None) + return Array.Empty(); + + var buffer = new List(); + switch (profile.Type) + { + case ToneType.TwoToneSequential: + buffer.AddRange(Sine(profile.ToneAFrequency, profile.ToneADurationMs, profile.Amplitude)); + buffer.AddRange(Sine(profile.ToneBFrequency, profile.ToneBDurationMs, profile.Amplitude)); + break; + + case ToneType.HiLo: + for (int i = 0; i < profile.HiLoCycles; i++) + { + buffer.AddRange(Sine(profile.ToneAFrequency, profile.HiLoSegmentMs, profile.Amplitude)); + buffer.AddRange(Sine(profile.ToneBFrequency, profile.HiLoSegmentMs, profile.Amplitude)); + } + break; + + case ToneType.SingleTone: + buffer.AddRange(Sine(profile.ToneAFrequency, profile.ToneADurationMs, profile.Amplitude)); + break; + } + + if (profile.PreSpeechSilenceMs > 0) + buffer.AddRange(Silence(profile.PreSpeechSilenceMs)); + + return buffer.ToArray(); + } + } +} diff --git a/Resgrid.Audio.Voice/ToneOut/ToneProfile.cs b/Resgrid.Audio.Voice/ToneOut/ToneProfile.cs new file mode 100644 index 0000000..5a97ff5 --- /dev/null +++ b/Resgrid.Audio.Voice/ToneOut/ToneProfile.cs @@ -0,0 +1,51 @@ +namespace Resgrid.Audio.Voice.ToneOut +{ + /// The style of alert tones played before a spoken dispatch announcement. + public enum ToneType + { + /// No alert tones; speak immediately. + None = 0, + + /// Two-tone sequential paging (QCII): tone A then tone B. + TwoToneSequential = 1, + + /// Alternating high/low "warble" attention signal. + HiLo = 2, + + /// A single steady attention tone. + SingleTone = 3 + } + + /// + /// Defines the alert tones prepended to a dispatch tone-out so personnel are + /// alerted before the spoken call details — mirroring fire/EMS station paging. + /// + public sealed class ToneProfile + { + public ToneType Type { get; set; } = ToneType.TwoToneSequential; + + /// Tone A frequency (Hz). For HiLo this is the high tone. + public double ToneAFrequency { get; set; } = 1000; + + /// Tone A duration (ms). For TwoToneSequential the "A" tone is short. + public int ToneADurationMs { get; set; } = 1000; + + /// Tone B frequency (Hz). For HiLo this is the low tone. + public double ToneBFrequency { get; set; } = 2000; + + /// Tone B duration (ms). For TwoToneSequential the "B" tone is long. + public int ToneBDurationMs { get; set; } = 3000; + + /// Output amplitude 0..1. + public double Amplitude { get; set; } = 0.5; + + /// For HiLo: number of high/low alternations. + public int HiLoCycles { get; set; } = 6; + + /// For HiLo: duration of each high or low segment (ms). + public int HiLoSegmentMs { get; set; } = 250; + + /// Silence inserted between the tones and the spoken announcement (ms). + public int PreSpeechSilenceMs { get; set; } = 500; + } +} diff --git a/Resgrid.Audio.Voice/ToneOut/TtsSettings.cs b/Resgrid.Audio.Voice/ToneOut/TtsSettings.cs new file mode 100644 index 0000000..12bd952 --- /dev/null +++ b/Resgrid.Audio.Voice/ToneOut/TtsSettings.cs @@ -0,0 +1,24 @@ +namespace Resgrid.Audio.Voice.ToneOut +{ + /// + /// Connection settings for the Resgrid Core TTS microservice + /// (POST /tts, GET /tts/audio/{hash}.wav). Matches Resgrid's TtsConfig. + /// + public sealed class TtsSettings + { + /// Base URL of the TTS service (e.g. https://tts.resgrid.com). + public string ServiceBaseUrl { get; set; } = ""; + + /// Optional separate base URL for audio playback/download. + public string PlaybackBaseUrl { get; set; } = ""; + + /// Piper voice id, e.g. "en-us+klatt4". Empty = service default. + public string Voice { get; set; } = ""; + + /// Words-per-minute speed (80–450). 0 = service default. + public int Speed { get; set; } + + /// HTTP timeout for synthesis requests. + public int RequestTimeoutSeconds { get; set; } = 20; + } +} diff --git a/Resgrid.Audio.Voice/VoiceRoomManager.cs b/Resgrid.Audio.Voice/VoiceRoomManager.cs new file mode 100644 index 0000000..ccd4392 --- /dev/null +++ b/Resgrid.Audio.Voice/VoiceRoomManager.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Audio.Voice.Abstractions; +using Serilog; + +namespace Resgrid.Audio.Voice +{ + /// + /// Owns and coordinates one or more s, enabling the + /// multi-room scenarios (record several channels, bridge multiple radios). Each + /// channel is joined at most once; callers attach their own sinks/sources to the + /// returned session. + /// + public sealed class VoiceRoomManager : IAsyncDisposable + { + private readonly IVoiceTransport _transport; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _sessions = + new ConcurrentDictionary(StringComparer.Ordinal); + + public VoiceRoomManager(IVoiceTransport transport, ILogger logger) + { + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _logger = logger; + } + + public IReadOnlyCollection Sessions => _sessions.Values.ToList(); + + public bool TryGet(string channelId, out IVoiceRoomSession session) => + _sessions.TryGetValue(channelId, out session); + + /// + /// Joins a channel (idempotent per channel id) and returns the connected + /// session. If the channel is already joined the existing session is returned. + /// + public async Task JoinAsync(VoiceChannel channel, CancellationToken cancellationToken = default) + { + if (channel == null) + throw new ArgumentNullException(nameof(channel)); + + if (_sessions.TryGetValue(channel.Id, out var existing)) + return existing; + + var session = _transport.CreateSession(channel); + if (!_sessions.TryAdd(channel.Id, session)) + { + await session.DisposeAsync().ConfigureAwait(false); + return _sessions[channel.Id]; + } + + try + { + await session.ConnectAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + _sessions.TryRemove(channel.Id, out _); + await session.DisposeAsync().ConfigureAwait(false); + throw; + } + + return session; + } + + public async Task LeaveAsync(string channelId) + { + if (_sessions.TryRemove(channelId, out var session)) + await session.DisposeAsync().ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + foreach (var session in _sessions.Values) + { + try { await session.DisposeAsync().ConfigureAwait(false); } + catch (Exception ex) { _logger?.Debug(ex, "Error disposing voice session {Channel}", session.ChannelId); } + } + _sessions.Clear(); + } + } +} diff --git a/Resgrid.Relay.Engine/Abstractions/ConnectionState.cs b/Resgrid.Relay.Engine/Abstractions/ConnectionState.cs new file mode 100644 index 0000000..3a3cb9c --- /dev/null +++ b/Resgrid.Relay.Engine/Abstractions/ConnectionState.cs @@ -0,0 +1,17 @@ +namespace Resgrid.Relay.Engine +{ + /// + /// Health of an individual external dependency a relay mode talks to + /// (Resgrid API, LiveKit, Redis, SMTP listener, TTS). + /// is used when a mode does not use that dependency at all, so the UI can grey it out. + /// + public enum ConnectionState + { + NotApplicable, + Unknown, + Connecting, + Connected, + Degraded, + Disconnected + } +} diff --git a/Resgrid.Relay.Engine/Abstractions/IRelayService.cs b/Resgrid.Relay.Engine/Abstractions/IRelayService.cs new file mode 100644 index 0000000..c52d973 --- /dev/null +++ b/Resgrid.Relay.Engine/Abstractions/IRelayService.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Relay.Engine +{ + /// + /// A single runnable relay mode (smtp, audio, radio, record, dispatch) with an + /// explicit start/stop lifecycle suited to a button-driven desktop UI as well as + /// the console. is long-lived and returns when the service + /// is cancelled or faults. + /// + public interface IRelayService : IAsyncDisposable + { + /// The mode key this service runs (e.g. "smtp", "radio"). + string Mode { get; } + + RelayServiceState State { get; } + + event EventHandler StateChanged; + + IRelayStatus Status { get; } + + /// Runs the mode until is cancelled or a fault occurs. + Task StartAsync(CancellationToken token); + + Task StopAsync(); + } +} diff --git a/Resgrid.Relay.Engine/Abstractions/IRelayStatus.cs b/Resgrid.Relay.Engine/Abstractions/IRelayStatus.cs new file mode 100644 index 0000000..cb00356 --- /dev/null +++ b/Resgrid.Relay.Engine/Abstractions/IRelayStatus.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; + +namespace Resgrid.Relay.Engine +{ + /// + /// Live, observable health and traffic snapshot for a running relay mode. + /// Implements so the desktop UI can bind + /// directly. Connection properties report + /// for dependencies the mode does not use. + /// + public interface IRelayStatus : INotifyPropertyChanged + { + ConnectionState ResgridApi { get; } + ConnectionState LiveKit { get; } + ConnectionState Redis { get; } + ConnectionState Smtp { get; } + ConnectionState Tts { get; } + + double InputDbfs { get; } + bool SquelchOpen { get; } + bool Transmitting { get; } + bool Receiving { get; } + + long CallsCreated { get; } + long MessagesProcessed { get; } + long TransmissionsRecorded { get; } + } +} diff --git a/Resgrid.Relay.Engine/Abstractions/RelayServiceState.cs b/Resgrid.Relay.Engine/Abstractions/RelayServiceState.cs new file mode 100644 index 0000000..2a7c438 --- /dev/null +++ b/Resgrid.Relay.Engine/Abstractions/RelayServiceState.cs @@ -0,0 +1,14 @@ +namespace Resgrid.Relay.Engine +{ + /// + /// Lifecycle state of a single instance. + /// + public enum RelayServiceState + { + Stopped, + Starting, + Running, + Stopping, + Faulted + } +} diff --git a/Resgrid.Relay.Engine/Abstractions/RelayStateChangedEventArgs.cs b/Resgrid.Relay.Engine/Abstractions/RelayStateChangedEventArgs.cs new file mode 100644 index 0000000..d87a943 --- /dev/null +++ b/Resgrid.Relay.Engine/Abstractions/RelayStateChangedEventArgs.cs @@ -0,0 +1,25 @@ +using System; + +namespace Resgrid.Relay.Engine +{ + /// + /// Raised by when a service transitions + /// between values. carries the + /// fault detail when is . + /// + public sealed class RelayStateChangedEventArgs : EventArgs + { + public RelayStateChangedEventArgs(RelayServiceState oldState, RelayServiceState newState, string error = null) + { + OldState = oldState; + NewState = newState; + Error = error; + } + + public RelayServiceState OldState { get; } + + public RelayServiceState NewState { get; } + + public string Error { get; } + } +} diff --git a/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs b/Resgrid.Relay.Engine/Configuration/RelayHostOptions.cs similarity index 93% rename from Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs rename to Resgrid.Relay.Engine/Configuration/RelayHostOptions.cs index 5b57f25..8dafc61 100644 --- a/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs +++ b/Resgrid.Relay.Engine/Configuration/RelayHostOptions.cs @@ -1,6 +1,7 @@ +using Resgrid.Audio.Voice.ToneOut; using Resgrid.Providers.ApiClient.V4; -namespace Resgrid.Audio.Relay.Console.Configuration +namespace Resgrid.Relay.Engine.Configuration { public sealed class RelayHostOptions { @@ -9,6 +10,13 @@ public sealed class RelayHostOptions public ResgridApiClientOptions Resgrid { get; set; } = new ResgridApiClientOptions(); public RelayTelemetryOptions Telemetry { get; set; } = new RelayTelemetryOptions(); public SmtpRelayOptions Smtp { get; set; } = new SmtpRelayOptions(); + + // ─── LiveKit voice modes (radio / record / dispatch) ─── + public VoiceConnectionOptions Voice { get; set; } = new VoiceConnectionOptions(); + public RadioModeOptions Radio { get; set; } = new RadioModeOptions(); + public RecorderModeOptions Recorder { get; set; } = new RecorderModeOptions(); + public DispatchVoiceOptions DispatchVoice { get; set; } = new DispatchVoiceOptions(); + public TtsSettings Tts { get; set; } = new TtsSettings(); } public sealed class RelayTelemetryOptions diff --git a/Resgrid.Relay.Engine/Configuration/RelayModeOptions.cs b/Resgrid.Relay.Engine/Configuration/RelayModeOptions.cs new file mode 100644 index 0000000..db6bfe1 --- /dev/null +++ b/Resgrid.Relay.Engine/Configuration/RelayModeOptions.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; +using Resgrid.Audio.Voice.Dsp; +using Resgrid.Audio.Voice.Recording; +using Resgrid.Audio.Voice.ToneOut; + +namespace Resgrid.Relay.Engine.Configuration +{ + // Option POCOs for the LiveKit voice modes (radio / record / dispatch). These are + // cross-platform — they reference only the Resgrid.Audio.Voice library, never the + // Windows-only Resgrid.Audio.Core. The radio mode maps these into Core's + // RadioSettings inside the Windows-only build. + + /// How the relay selects and connects to a PTT channel (LiveKit room). + public sealed class VoiceConnectionOptions + { + /// Department id to act on behalf of (hosted/system-key). Empty = authenticated department. + public string DepartmentId { get; set; } = ""; + + /// Channel selector: a channel id, channel name, or "default". + public string Channel { get; set; } = "default"; + + /// LiveKit publish queue depth in ms (publisher back-pressure buffer). + public int PublishQueueMs { get; set; } = 1000; + + /// Honor the department's concurrent-seat subscription limit before joining. + public bool EnforceSeatLimit { get; set; } = true; + } + + /// Emergency/MDC-1200 signaling detection options (radio mode). + public sealed class EmergencyOptions + { + public bool DetectMdc1200 { get; set; } + public bool DetectTones { get; set; } + + /// Emergency tone frequencies (Hz) to listen for. + public List ToneFrequencies { get; set; } = new List(); + + /// Create a high-priority Resgrid call when an emergency is detected. + public bool CreateCall { get; set; } + + public int CallPriority { get; set; } = 1; + + /// Optional DispatchList for the emergency call (e.g. "G:42"). + public string DispatchList { get; set; } = ""; + } + + /// Physical radio interface options (radio mode, Windows). + public sealed class RadioModeOptions + { + public int InputDevice { get; set; } + public int OutputDevice { get; set; } = -1; + + /// PTT keying: Vox | SerialRts | SerialDtr | Cm108. + public string PttMethod { get; set; } = "Vox"; + + public string SerialPort { get; set; } = ""; + public int Cm108VendorId { get; set; } = 0x0D8C; + public int Cm108ProductId { get; set; } = 0; + public int Cm108GpioPin { get; set; } = 3; + + /// Hardware carrier detect source: None | SerialCts | SerialDsr | Cm108Gpio. + public string CarrierDetect { get; set; } = "None"; + public bool CarrierDetectInverted { get; set; } + + public bool CourtesyTone { get; set; } = true; + public int TxHangMs { get; set; } = 300; + public int TxTailMs { get; set; } = 150; + public bool AntiLoop { get; set; } = true; + public double TxGain { get; set; } = 1.0; + public double RxGain { get; set; } = 1.0; + public int HighPassCutoffHz { get; set; } = 250; + + /// Receive-side squelch / anti-static settings. + public SquelchSettings Squelch { get; set; } = new SquelchSettings(); + + /// Also record the channel to disk/S3 while bridging. + public bool RecordWhileBridging { get; set; } + + public EmergencyOptions Emergency { get; set; } = new EmergencyOptions(); + } + + /// S3-compatible object storage options for recordings. + public sealed class S3StorageOptions + { + public string Endpoint { get; set; } = ""; + public string AccessKey { get; set; } = ""; + public string SecretKey { get; set; } = ""; + public string Region { get; set; } = ""; + public string Bucket { get; set; } = ""; + public string Prefix { get; set; } = "relay-recordings"; + public bool ForcePathStyle { get; set; } + public bool UseSsl { get; set; } = true; + } + + /// Compliance recorder options (record mode, or while bridging). + public sealed class RecorderModeOptions + { + /// Channel selector, or "all" to record every channel in the department. + public string Channel { get; set; } = "all"; + public string DepartmentId { get; set; } = ""; + + /// Audio storage: local | s3 | both. + public string Store { get; set; } = "local"; + public string LocalPath { get; set; } = ""; + + /// Metadata log: jsonl | sqlite | none. + public string Log { get; set; } = "jsonl"; + public string LogPath { get; set; } = ""; + + public S3StorageOptions S3 { get; set; } = new S3StorageOptions(); + + /// Transmission segmentation tunables. + public RecorderSettings Segmentation { get; set; } = new RecorderSettings(); + } + + /// Dispatch tone-out options (dispatch mode). + public sealed class DispatchVoiceOptions + { + public string DepartmentId { get; set; } = ""; + public string Channel { get; set; } = "default"; + + /// How often to poll for new calls to announce. + public int PollSeconds { get; set; } = 15; + + /// Alert tone profile played before the spoken announcement. + public ToneProfile Tone { get; set; } = new ToneProfile(); + + /// Hosted (multi-department staff) mode — designed-for, deferred. + public bool Hosted { get; set; } + } +} diff --git a/Resgrid.Relay.Engine/LoclxTunnel.cs b/Resgrid.Relay.Engine/LoclxTunnel.cs new file mode 100644 index 0000000..fecc47d --- /dev/null +++ b/Resgrid.Relay.Engine/LoclxTunnel.cs @@ -0,0 +1,110 @@ +using System; +using System.Diagnostics; +using System.IO; +using Resgrid.Relay.Engine.Configuration; +using Cli = System.Console; + +namespace Resgrid.Relay.Engine +{ + /// + /// Optionally starts a LocalXpose (loclx) TCP tunnel as a child process. This used + /// to live in docker-entrypoint.sh, but Docker Hardened Images are shell-less, so + /// the orchestration moves into the app: the container entrypoint is just the dll. + /// + /// Controlled by environment variables (unchanged from the old entrypoint): + /// LOCLX_ENABLED=true + /// LOCLX_TOKEN=<token> + /// LOCLX_RESERVED_ENDPOINT=<host:port> (optional) + /// or a mounted tunnels file at /etc/resgrid/loclx-tunnels.yaml. + /// + public sealed class LoclxTunnel : IDisposable + { + private const string TunnelsFile = "/etc/resgrid/loclx-tunnels.yaml"; + private Process _process; + + public static LoclxTunnel StartIfEnabled(RelayHostOptions options) + { + if (!string.Equals(Environment.GetEnvironmentVariable("LOCLX_ENABLED"), "true", StringComparison.OrdinalIgnoreCase)) + return null; + + var tunnel = new LoclxTunnel(); + try + { + tunnel.Start(options); + } + catch (Exception ex) + { + Cli.Error.WriteLine($"[localxpose] Failed to start tunnel: {ex.Message}"); + } + return tunnel; + } + + private void Start(RelayHostOptions options) + { + var token = Environment.GetEnvironmentVariable("LOCLX_TOKEN"); + var reserved = Environment.GetEnvironmentVariable("LOCLX_RESERVED_ENDPOINT"); + var port = options.Smtp?.Port > 0 ? options.Smtp.Port : 2525; + + if (!string.IsNullOrWhiteSpace(token)) + { + Cli.WriteLine("[localxpose] Authenticating..."); + Environment.SetEnvironmentVariable("LX_ACCESS_TOKEN", token); + using var auth = StartLoclx("auth login"); + auth?.WaitForExit(15000); + } + else + { + Cli.Error.WriteLine("[localxpose] WARNING: LOCLX_TOKEN is not set; tunnel may fail to authenticate."); + } + + string args; + if (File.Exists(TunnelsFile)) + { + Cli.WriteLine($"[localxpose] Starting tunnel from {TunnelsFile}..."); + args = $"tunnel -c {TunnelsFile}"; + } + else if (!string.IsNullOrWhiteSpace(reserved)) + { + Cli.WriteLine($"[localxpose] Starting reserved TCP tunnel to localhost:{port} via {reserved}..."); + args = $"tunnel tcp --to localhost:{port} --reserved-endpoint {reserved}"; + } + else + { + Cli.WriteLine($"[localxpose] Starting ephemeral TCP tunnel to localhost:{port}..."); + args = $"tunnel tcp --to localhost:{port}"; + } + + _process = StartLoclx(args); + if (_process != null) + Cli.WriteLine($"[localxpose] Tunnel started (PID: {_process.Id})."); + } + + private static Process StartLoclx(string arguments) + { + var psi = new ProcessStartInfo + { + FileName = "loclx", + Arguments = arguments, + UseShellExecute = false + }; + return Process.Start(psi); + } + + public void Dispose() + { + try + { + if (_process != null && !_process.HasExited) + _process.Kill(entireProcessTree: true); + } + catch + { + // best effort + } + finally + { + _process?.Dispose(); + } + } + } +} diff --git a/Resgrid.Relay.Engine/Resgrid.Relay.Engine.csproj b/Resgrid.Relay.Engine/Resgrid.Relay.Engine.csproj new file mode 100644 index 0000000..f10934e --- /dev/null +++ b/Resgrid.Relay.Engine/Resgrid.Relay.Engine.csproj @@ -0,0 +1,32 @@ + + + net10.0;net10.0-windows + Resgrid.Relay.Engine + Resgrid.Relay.Engine + false + disable + disable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resgrid.Audio.Relay.Console/Smtp/CachedLookupsService.cs b/Resgrid.Relay.Engine/Smtp/CachedLookupsService.cs similarity index 83% rename from Resgrid.Audio.Relay.Console/Smtp/CachedLookupsService.cs rename to Resgrid.Relay.Engine/Smtp/CachedLookupsService.cs index 21370bf..9f97d50 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/CachedLookupsService.cs +++ b/Resgrid.Relay.Engine/Smtp/CachedLookupsService.cs @@ -4,7 +4,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Resgrid.Audio.Relay.Console.Smtp +namespace Resgrid.Relay.Engine.Smtp { /// /// Wraps with a cache-first strategy. @@ -18,10 +18,12 @@ namespace Resgrid.Audio.Relay.Console.Smtp internal sealed class CachedLookupsService { private readonly IDispatchLookupCache _cache; + private readonly IResgridLookupsApi _lookupsApi; - public CachedLookupsService(IDispatchLookupCache cache) + public CachedLookupsService(IDispatchLookupCache cache, IResgridLookupsApi lookupsApi) { _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _lookupsApi = lookupsApi; } /// @@ -39,7 +41,7 @@ public async Task LookupGroupByDispatchCodeAsync( if (cached != null) return cached; - var result = await LookupsApi.LookupGroupByDispatchCodeAsync(code, departmentId, cancellationToken).ConfigureAwait(false); + var result = await _lookupsApi.LookupGroupByDispatchCodeAsync(code, departmentId, cancellationToken).ConfigureAwait(false); if (result != null) await _cache.SetGroupByDispatchCodeAsync(code, departmentId, result).ConfigureAwait(false); @@ -61,7 +63,7 @@ public async Task LookupGroupByMessageCodeAsync( if (cached != null) return cached; - var result = await LookupsApi.LookupGroupByMessageCodeAsync(code, departmentId, cancellationToken).ConfigureAwait(false); + var result = await _lookupsApi.LookupGroupByMessageCodeAsync(code, departmentId, cancellationToken).ConfigureAwait(false); if (result != null) await _cache.SetGroupByMessageCodeAsync(code, departmentId, result).ConfigureAwait(false); @@ -83,7 +85,7 @@ public async Task LookupUnitByNameAsync( if (cached != null) return cached; - var result = await LookupsApi.LookupUnitByNameAsync(name, departmentId, cancellationToken).ConfigureAwait(false); + var result = await _lookupsApi.LookupUnitByNameAsync(name, departmentId, cancellationToken).ConfigureAwait(false); if (result != null) await _cache.SetUnitByNameAsync(name, departmentId, result).ConfigureAwait(false); @@ -105,7 +107,7 @@ public async Task LookupRoleByNameAsync( if (cached != null) return cached; - var result = await LookupsApi.LookupRoleByNameAsync(name, departmentId, cancellationToken).ConfigureAwait(false); + var result = await _lookupsApi.LookupRoleByNameAsync(name, departmentId, cancellationToken).ConfigureAwait(false); if (result != null) await _cache.SetRoleByNameAsync(name, departmentId, result).ConfigureAwait(false); diff --git a/Resgrid.Audio.Relay.Console/Smtp/IDispatchLookupCache.cs b/Resgrid.Relay.Engine/Smtp/IDispatchLookupCache.cs similarity index 97% rename from Resgrid.Audio.Relay.Console/Smtp/IDispatchLookupCache.cs rename to Resgrid.Relay.Engine/Smtp/IDispatchLookupCache.cs index 9f04cf5..f8220a6 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/IDispatchLookupCache.cs +++ b/Resgrid.Relay.Engine/Smtp/IDispatchLookupCache.cs @@ -3,7 +3,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Resgrid.Audio.Relay.Console.Smtp +namespace Resgrid.Relay.Engine.Smtp { /// /// Caches dispatch code lookup results so that repeated lookups for the diff --git a/Resgrid.Audio.Relay.Console/Smtp/NullDispatchLookupCache.cs b/Resgrid.Relay.Engine/Smtp/NullDispatchLookupCache.cs similarity index 97% rename from Resgrid.Audio.Relay.Console/Smtp/NullDispatchLookupCache.cs rename to Resgrid.Relay.Engine/Smtp/NullDispatchLookupCache.cs index 777210a..fa01d14 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/NullDispatchLookupCache.cs +++ b/Resgrid.Relay.Engine/Smtp/NullDispatchLookupCache.cs @@ -1,7 +1,7 @@ using Resgrid.Providers.ApiClient.V4.Models; using System.Threading.Tasks; -namespace Resgrid.Audio.Relay.Console.Smtp +namespace Resgrid.Relay.Engine.Smtp { /// /// No-op cache implementation used when Redis caching is disabled. diff --git a/Resgrid.Audio.Relay.Console/Smtp/RedisDispatchLookupCache.cs b/Resgrid.Relay.Engine/Smtp/RedisDispatchLookupCache.cs similarity index 98% rename from Resgrid.Audio.Relay.Console/Smtp/RedisDispatchLookupCache.cs rename to Resgrid.Relay.Engine/Smtp/RedisDispatchLookupCache.cs index b9153fe..eb84913 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/RedisDispatchLookupCache.cs +++ b/Resgrid.Relay.Engine/Smtp/RedisDispatchLookupCache.cs @@ -1,4 +1,4 @@ -using Resgrid.Audio.Relay.Console.Configuration; +using Resgrid.Relay.Engine.Configuration; using Resgrid.Providers.ApiClient.V4.Models; using StackExchange.Redis; using System; @@ -6,7 +6,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Resgrid.Audio.Relay.Console.Smtp +namespace Resgrid.Relay.Engine.Smtp { /// /// Redis-backed implementation of . diff --git a/Resgrid.Audio.Relay.Console/Smtp/SmtpDispatchAddressParser.cs b/Resgrid.Relay.Engine/Smtp/SmtpDispatchAddressParser.cs similarity index 98% rename from Resgrid.Audio.Relay.Console/Smtp/SmtpDispatchAddressParser.cs rename to Resgrid.Relay.Engine/Smtp/SmtpDispatchAddressParser.cs index 25046b3..06b0117 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/SmtpDispatchAddressParser.cs +++ b/Resgrid.Relay.Engine/Smtp/SmtpDispatchAddressParser.cs @@ -1,10 +1,10 @@ -using Resgrid.Audio.Relay.Console.Configuration; +using Resgrid.Relay.Engine.Configuration; using Resgrid.Providers.ApiClient.V4; using System; using System.Collections.Generic; using System.Linq; -namespace Resgrid.Audio.Relay.Console.Smtp +namespace Resgrid.Relay.Engine.Smtp { /// /// Parses SMTP recipient addresses into dispatch codes, determining the diff --git a/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs b/Resgrid.Relay.Engine/Smtp/SmtpRelayInfrastructure.cs similarity index 94% rename from Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs rename to Resgrid.Relay.Engine/Smtp/SmtpRelayInfrastructure.cs index 62aaafa..f66e43b 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs +++ b/Resgrid.Relay.Engine/Smtp/SmtpRelayInfrastructure.cs @@ -1,5 +1,5 @@ using MimeKit; -using Resgrid.Audio.Relay.Console.Configuration; +using Resgrid.Relay.Engine.Configuration; using Resgrid.Providers.ApiClient.V4; using Resgrid.Providers.ApiClient.V4.Models; using SmtpServer; @@ -19,11 +19,11 @@ using System.Threading; using System.Threading.Tasks; -namespace Resgrid.Audio.Relay.Console.Smtp +namespace Resgrid.Relay.Engine.Smtp { - internal static class SmtpRelayRunner + public static class SmtpRelayRunner { - public static async Task RunAsync(SmtpRelayOptions options, ISmtpTelemetry telemetry, CancellationToken cancellationToken) + public static async Task RunAsync(SmtpRelayOptions options, ISmtpTelemetry telemetry, IResgridApiClient apiClient, CancellationToken cancellationToken) { if (options == null) throw new ArgumentNullException(nameof(options)); @@ -32,7 +32,7 @@ public static async Task RunAsync(SmtpRelayOptions options, ISmtpTelemetry telem var serviceProvider = new ServiceProvider(); serviceProvider.Add((IMailboxFilterFactory)new RelayMailboxFilter(options, telemetry)); - serviceProvider.Add(new RelayMessageStore(options, telemetry)); + serviceProvider.Add(new RelayMessageStore(options, telemetry, apiClient)); var smtpServerOptions = new SmtpServerOptionsBuilder() .ServerName(options.ServerName) @@ -65,33 +65,45 @@ public static async Task RunAsync(SmtpRelayOptions options, ISmtpTelemetry telem } } - internal interface IResgridCallsClient + public interface IResgridCallsClient { + string CurrentUserId { get; } Task SaveCallAsync(NewCallInput call, CancellationToken cancellationToken); Task SaveCallFileAsync(SaveCallFileInput file, CancellationToken cancellationToken); } internal sealed class ResgridCallsClient : IResgridCallsClient { + private readonly IResgridApiClient _apiClient; + private readonly CallsApi _callsApi; + + public ResgridCallsClient(IResgridApiClient apiClient) + { + _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + _callsApi = new CallsApi(apiClient); + } + + public string CurrentUserId => _apiClient.CurrentUserId; + public Task SaveCallAsync(NewCallInput call, CancellationToken cancellationToken) { - return CallsApi.SaveCallAsync(call, cancellationToken); + return _callsApi.SaveCallAsync(call, cancellationToken); } public Task SaveCallFileAsync(SaveCallFileInput file, CancellationToken cancellationToken) { - return CallsApi.SaveCallFileAsync(file, cancellationToken); + return _callsApi.SaveCallFileAsync(file, cancellationToken); } } - internal sealed class AttachmentPayload + public sealed class AttachmentPayload { public string Name { get; set; } public byte[] Data { get; set; } public CallFileType Type { get; set; } } - internal sealed class RelayMessageStore : MessageStore + public sealed class RelayMessageStore : MessageStore { private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { @@ -108,13 +120,14 @@ internal sealed class RelayMessageStore : MessageStore private readonly string _dataDirectory; private readonly string _messageDirectory; - public RelayMessageStore(SmtpRelayOptions options, ISmtpTelemetry telemetry, IResgridCallsClient callsClient = null, IDispatchLookupCache lookupCache = null) + public RelayMessageStore(SmtpRelayOptions options, ISmtpTelemetry telemetry, IResgridApiClient apiClient = null, IResgridCallsClient callsClient = null, IDispatchLookupCache lookupCache = null, IResgridLookupsApi lookupsApi = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); _telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); - _callsClient = callsClient ?? new ResgridCallsClient(); + _callsClient = callsClient ?? new ResgridCallsClient(apiClient); _lookupCache = lookupCache ?? CreateLookupCache(options); - _lookupService = new CachedLookupsService(_lookupCache); + lookupsApi ??= apiClient != null ? new LookupsApi(apiClient) : null; + _lookupService = new CachedLookupsService(_lookupCache, lookupsApi); _dispatchAddressParser = new SmtpDispatchAddressParser(options); _dataDirectory = ResolvePath(options.DataDirectory); _messageDirectory = Path.Combine(_dataDirectory, "messages"); @@ -236,7 +249,7 @@ public override async Task SaveAsync(ISessionContext context, IMes var uploadableAttachments = attachments.Where(x => x.Data.Length <= _options.MaxAttachmentBytes).ToList(); if (uploadableAttachments.Count > 0) { - var userId = ResgridV4ApiClient.CurrentUserId; + var userId = _callsClient.CurrentUserId; // In SystemApiKey (hosted) mode the department ID serves as // the user identifier for file uploads. In token-based modes // the user id must come from the access token JWT. @@ -486,7 +499,7 @@ private static string Trim(string value, int maxLength) } } - internal sealed class RelayMailboxFilter : IMailboxFilter, IMailboxFilterFactory + public sealed class RelayMailboxFilter : IMailboxFilter, IMailboxFilterFactory { private readonly SmtpRelayOptions _options; private readonly ISmtpTelemetry _telemetry; diff --git a/Resgrid.Audio.Relay.Console/Smtp/SmtpTelemetry.cs b/Resgrid.Relay.Engine/Smtp/SmtpTelemetry.cs similarity index 99% rename from Resgrid.Audio.Relay.Console/Smtp/SmtpTelemetry.cs rename to Resgrid.Relay.Engine/Smtp/SmtpTelemetry.cs index 0f0bda8..7989be8 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/SmtpTelemetry.cs +++ b/Resgrid.Relay.Engine/Smtp/SmtpTelemetry.cs @@ -1,5 +1,5 @@ using MimeKit; -using Resgrid.Audio.Relay.Console.Configuration; +using Resgrid.Relay.Engine.Configuration; using Resgrid.Providers.ApiClient.V4; using Sentry; using Serilog; @@ -20,9 +20,9 @@ using System.Threading.Channels; using System.Threading.Tasks; -namespace Resgrid.Audio.Relay.Console.Smtp +namespace Resgrid.Relay.Engine.Smtp { - internal interface ISmtpTelemetry : IAsyncDisposable + public interface ISmtpTelemetry : IAsyncDisposable { void RelayStarting(SmtpRelayOptions options); void RelayStopped(SmtpRelayOptions options); @@ -42,7 +42,7 @@ internal interface ISmtpTelemetry : IAsyncDisposable void MessageFailed(ISessionContext context, SmtpMessageSummary message, Exception exception, TimeSpan duration); } - internal sealed class SmtpTelemetry : ISmtpTelemetry + public sealed class SmtpTelemetry : ISmtpTelemetry { private readonly ILogger _logger; private readonly CountlyTelemetryClient _countlyClient; @@ -685,7 +685,7 @@ private static string GetDomain(string emailAddress) } } - internal sealed class SmtpMessageSummary + public sealed class SmtpMessageSummary { private SmtpMessageSummary() { diff --git a/Resgrid.Relay.Engine/Voice/DispatchVoiceMode.cs b/Resgrid.Relay.Engine/Voice/DispatchVoiceMode.cs new file mode 100644 index 0000000..dba5abf --- /dev/null +++ b/Resgrid.Relay.Engine/Voice/DispatchVoiceMode.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Audio.Voice; +using Resgrid.Audio.Voice.Connection; +using Resgrid.Audio.Voice.LiveKit; +using Resgrid.Audio.Voice.ToneOut; +using Resgrid.Providers.ApiClient.V4; +using Serilog; + +namespace Resgrid.Relay.Engine.Voice +{ + /// + /// 'dispatch' mode: watches for new Resgrid calls and tones them out (alert tones + + /// Resgrid TTS announcement) onto a department's PTT channel. Cross-platform. + /// Customer single-department now; hosted multi-department is scaffolded. + /// + public static class DispatchVoiceMode + { + public static async Task RunAsync(RelayHostOptions options, ILogger logger, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(options.Tts.ServiceBaseUrl)) + { + logger.Error("Dispatch tone-out requires RESGRID__RELAY__Tts__ServiceBaseUrl (the Resgrid TTS service URL)."); + return 1; + } + + using var apiClient = new ResgridV4ApiClient(options.Resgrid); + var voiceApi = new VoiceApi(apiClient); + var callsApi = new CallsApi(apiClient); + + var deptId = FirstNonEmpty(options.DispatchVoice.DepartmentId, options.Voice.DepartmentId); + if (options.DispatchVoice.Hosted && string.IsNullOrWhiteSpace(deptId)) + { + logger.Error("Hosted dispatch tone-out requires RESGRID__RELAY__DispatchVoice__DepartmentId."); + return 1; + } + + var transport = new LiveKitVoiceTransport(logger, options.Voice.PublishQueueMs); + var provider = new ResgridVoiceChannelProvider(logger, voiceApi); + await using var manager = new VoiceRoomManager(transport, logger); + + var channel = await provider.GetChannelAsync(options.DispatchVoice.Channel, deptId, cancellationToken).ConfigureAwait(false); + var session = await manager.JoinAsync(channel, cancellationToken).ConfigureAwait(false); + var publisher = await session.CreatePublisherAsync("dispatch", cancellationToken).ConfigureAwait(false); + + using var tts = new ResgridTtsClient(options.Tts, logger); + var service = new DispatchToneOutService(tts, new ToneGenerator(), options.DispatchVoice.Tone, logger); + + // Prime "seen" with the current backlog so startup doesn't re-announce open calls. + var seen = new HashSet(StringComparer.Ordinal); + foreach (var call in await callsApi.GetActiveCallsAsync(deptId, cancellationToken).ConfigureAwait(false)) + if (!string.IsNullOrWhiteSpace(call.CallId)) + seen.Add(call.CallId); + + var pollSeconds = Math.Max(5, options.DispatchVoice.PollSeconds); + logger.Information($"Dispatch tone-out on '{channel.Name}', polling new calls every {pollSeconds}s. Press Ctrl+C to stop."); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + var calls = await callsApi.GetActiveCallsAsync(deptId, cancellationToken).ConfigureAwait(false); + foreach (var call in calls) + { + if (string.IsNullOrWhiteSpace(call.CallId) || seen.Contains(call.CallId)) + continue; + + var text = VoiceModeRuntime.FormatCallAnnouncement(call); + logger.Information("Toning out new call {CallId}", call.CallId); + try + { + await service.AnnounceAsync(publisher, text, cancellationToken).ConfigureAwait(false); + // Only mark the call handled once the announcement actually succeeds, + // so a failed tone-out is retried on the next poll instead of being lost. + seen.Add(call.CallId); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Per-call failure: log and keep going so one bad announcement does not + // block the rest of the batch; the call stays unseen and is retried next poll. + logger.Error(ex, "Failed to tone out call {CallId}; will retry next poll", call.CallId); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + logger.Error(ex, "Dispatch tone-out poll failed"); + } + + try { await Task.Delay(TimeSpan.FromSeconds(pollSeconds), cancellationToken).ConfigureAwait(false); } + catch (TaskCanceledException) { break; } + } + + await publisher.DisposeAsync().ConfigureAwait(false); + return 0; + } + + private static string FirstNonEmpty(params string[] values) => + values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? ""; + } +} diff --git a/Resgrid.Relay.Engine/Voice/RadioMode.cs b/Resgrid.Relay.Engine/Voice/RadioMode.cs new file mode 100644 index 0000000..1b6904e --- /dev/null +++ b/Resgrid.Relay.Engine/Voice/RadioMode.cs @@ -0,0 +1,230 @@ +#if NET10_0_WINDOWS +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.IO.Ports; +using Resgrid.Audio.Core.Radio; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Audio.Voice; +using Resgrid.Audio.Voice.Abstractions; +using Resgrid.Audio.Voice.Connection; +using Resgrid.Audio.Voice.Dsp; +using Resgrid.Audio.Voice.LiveKit; +using Resgrid.Audio.Voice.Recording; +using Resgrid.Providers.ApiClient.V4; +using Serilog; + +namespace Resgrid.Relay.Engine.Voice +{ + /// + /// 'radio' mode (Windows): bidirectional bridge between a physically-attached radio + /// and a Resgrid PTT channel, with anti-static squelch, PTT keying, optional + /// recording, and emergency/MDC-1200 detection. + /// + public static class RadioMode + { + public static async Task RunAsync(RelayHostOptions options, ILogger logger, CancellationToken cancellationToken) + { + using var apiClient = new ResgridV4ApiClient(options.Resgrid); + var voiceApi = new VoiceApi(apiClient); + var callsApi = new CallsApi(apiClient); + + var deptId = string.IsNullOrWhiteSpace(options.Voice.DepartmentId) ? null : options.Voice.DepartmentId; + var transport = new LiveKitVoiceTransport(logger, options.Voice.PublishQueueMs); + var provider = new ResgridVoiceChannelProvider(logger, voiceApi); + + if (options.Voice.EnforceSeatLimit) + { + var canConnect = await provider.CanConnectAsync(deptId, cancellationToken).ConfigureAwait(false); + if (canConnect == false) + { + logger.Error("Voice seat limit reached for this department; not connecting."); + return 1; + } + } + + await using var manager = new VoiceRoomManager(transport, logger); + var channel = await provider.GetChannelAsync(options.Voice.Channel, deptId, cancellationToken).ConfigureAwait(false); + var session = await manager.JoinAsync(channel, cancellationToken).ConfigureAwait(false); + + var radioSettings = MapSettings(options.Radio, logger); + // Serial PTT and serial carrier-detect on the same COM port must share one + // SerialPort instance — opening the same port twice fails on Windows. RunAsync + // owns it (disposed last, after the borrowing controllers). + using var sharedSerialPort = TryCreateSharedSerialPort(radioSettings); + using var device = new NAudioRadioDevice(radioSettings.InputDevice, radioSettings.OutputDevice, logger); + using var ptt = CreatePtt(radioSettings, logger, sharedSerialPort); + using var carrier = CreateCarrier(radioSettings, logger, sharedSerialPort); + + var (mdc, emergency, alertSink) = BuildSignaling(options.Radio.Emergency, logger, callsApi); + + await using var bridge = new RadioBridge(device, ptt, carrier, radioSettings, logger, mdc, emergency, alertSink); + await bridge.StartAsync(session, cancellationToken).ConfigureAwait(false); + + TransmissionRecorder recorder = null; + List recorderDisposables = null; + ITransmissionLog recorderLog = null; + if (options.Radio.RecordWhileBridging) + { + var (stores, disposables) = RecordMode.BuildStores(options.Recorder, logger); + recorderDisposables = disposables; + recorderLog = RecordMode.BuildLog(options.Recorder, logger); + recorder = new TransmissionRecorder(session, options.Recorder.Segmentation, stores, recorderLog, logger); + recorder.Start(); + } + + logger.Information($"Radio bridge running on '{channel.Name}'. Press Ctrl+C to stop."); + await VoiceModeRuntime.WaitForCancellationAsync(cancellationToken).ConfigureAwait(false); + + if (recorder != null) + await recorder.DisposeAsync().ConfigureAwait(false); + if (recorderLog != null) + await recorderLog.DisposeAsync().ConfigureAwait(false); + if (recorderDisposables != null) + foreach (var d in recorderDisposables) d.Dispose(); + + await bridge.DisposeAsync().ConfigureAwait(false); + return 0; + } + + /// Live receive-level meter + squelch state to help tune the anti-static threshold. + public static async Task RunTuneAsync(RelayHostOptions options, CancellationToken cancellationToken) + { + var logger = Serilog.Core.Logger.None; + var radioSettings = MapSettings(options.Radio, logger); + var gate = new SquelchGate(radioSettings.Squelch, AudioFormat.SampleRate); + var device = new NAudioRadioDevice(radioSettings.InputDevice, radioSettings.OutputDevice, logger); + + var lastPrintTicks = 0L; + device.SamplesReceived += (_, frame) => + { + bool open = gate.Process(frame); + var now = DateTime.UtcNow.Ticks; + if (now - lastPrintTicks < TimeSpan.TicksPerMillisecond * 150) + return; + lastPrintTicks = now; + + double db = gate.LastDbfs; + int bars = Math.Clamp((int)((db + 80) / 80 * 30), 0, 30); + var meter = new string('#', bars).PadRight(30, '·'); + logger.Information($"[{meter}] {db,6:0.0} dBFS {(open ? "OPEN " : "closed")}"); + }; + + device.StartReceive(); + logger.Information($"Tuning input device {radioSettings.InputDevice}. Open={radioSettings.Squelch.OpenDbfs} dBFS, Close={radioSettings.Squelch.CloseDbfs} dBFS."); + logger.Information("Key up the radio (or feed static) and adjust OpenDbfs just above the static floor. Ctrl+C to stop."); + + await VoiceModeRuntime.WaitForCancellationAsync(cancellationToken).ConfigureAwait(false); + device.Dispose(); + return 0; + } + + private static RadioSettings MapSettings(RadioModeOptions o, ILogger logger) + { + var ptt = ParseEnumOrWarn(o.PttMethod, nameof(RadioModeOptions.PttMethod), PttKeyingMethod.Vox, logger); + var carrier = ParseEnumOrWarn(o.CarrierDetect, nameof(RadioModeOptions.CarrierDetect), CarrierDetectSource.None, logger); + + return new RadioSettings + { + InputDevice = o.InputDevice, + OutputDevice = o.OutputDevice, + Ptt = ptt, + SerialPort = o.SerialPort, + Cm108VendorId = o.Cm108VendorId, + Cm108ProductId = o.Cm108ProductId, + Cm108GpioPin = o.Cm108GpioPin, + CarrierDetect = carrier, + CarrierDetectInverted = o.CarrierDetectInverted, + CourtesyTone = o.CourtesyTone, + TxHangMs = o.TxHangMs, + TxTailMs = o.TxTailMs, + AntiLoop = o.AntiLoop, + TxGain = o.TxGain, + RxGain = o.RxGain, + HighPassCutoffHz = o.HighPassCutoffHz, + Squelch = o.Squelch + }; + } + + private static TEnum ParseEnumOrWarn(string value, string settingName, TEnum fallback, ILogger logger) + where TEnum : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) + return fallback; + + if (Enum.TryParse(value, ignoreCase: true, out var parsed) && Enum.IsDefined(typeof(TEnum), parsed)) + return parsed; + + logger?.Warning( + "Invalid Radio {Setting} value '{Value}'; expected one of [{Allowed}]. Falling back to {Fallback}.", + settingName, value, string.Join(", ", Enum.GetNames(typeof(TEnum))), fallback); + return fallback; + } + + private static IPttController CreatePtt(RadioSettings settings, ILogger logger, SerialPort sharedSerialPort = null) + { + switch (settings.Ptt) + { + case PttKeyingMethod.SerialRts: + return new SerialPttController(settings.SerialPort, useDtr: false, logger, sharedSerialPort); + case PttKeyingMethod.SerialDtr: + return new SerialPttController(settings.SerialPort, useDtr: true, logger, sharedSerialPort); + case PttKeyingMethod.Cm108: + return new Cm108PttController(settings.Cm108VendorId, settings.Cm108ProductId, settings.Cm108GpioPin, logger); + case PttKeyingMethod.Vox: + default: + return new VoxPttController(); + } + } + + private static ICarrierDetector CreateCarrier(RadioSettings settings, ILogger logger, SerialPort sharedSerialPort = null) + { + switch (settings.CarrierDetect) + { + case CarrierDetectSource.SerialCts: + return new SerialCarrierDetector(settings.SerialPort, useDsr: false, settings.CarrierDetectInverted, logger, sharedSerialPort); + case CarrierDetectSource.SerialDsr: + return new SerialCarrierDetector(settings.SerialPort, useDsr: true, settings.CarrierDetectInverted, logger, sharedSerialPort); + case CarrierDetectSource.Cm108Gpio: + logger.Warning("CM108 GPIO carrier detect is not yet implemented; using audio squelch instead."); + return new NullCarrierDetector(); + case CarrierDetectSource.None: + default: + return new NullCarrierDetector(); + } + } + + /// + /// Returns a single shared when both PTT and carrier-detect + /// are serial (they both use ), so the COM port + /// is opened only once; otherwise null so each component opens/owns its own port. + /// The caller owns and disposes the returned port. + /// + private static SerialPort TryCreateSharedSerialPort(RadioSettings settings) + { + bool serialPtt = settings.Ptt == PttKeyingMethod.SerialRts || settings.Ptt == PttKeyingMethod.SerialDtr; + bool serialCarrier = settings.CarrierDetect == CarrierDetectSource.SerialCts || settings.CarrierDetect == CarrierDetectSource.SerialDsr; + + if (serialPtt && serialCarrier && !string.IsNullOrWhiteSpace(settings.SerialPort)) + return new SerialPort(settings.SerialPort) { ReadTimeout = 200, WriteTimeout = 200 }; + + return null; + } + + private static (Mdc1200Decoder, EmergencyToneDetector, IEmergencyAlertSink) BuildSignaling(EmergencyOptions o, ILogger logger, IResgridCallsApi callsApi) + { + if (o == null || (!o.DetectMdc1200 && !o.DetectTones)) + return (null, null, null); + + var mdc = o.DetectMdc1200 ? new Mdc1200Decoder(new Mdc1200Settings(), AudioFormat.SampleRate) : null; + var emergency = o.DetectTones + ? new EmergencyToneDetector(new EmergencyToneSettings { Frequencies = o.ToneFrequencies ?? new List() }, AudioFormat.SampleRate) + : null; + var sink = new ResgridEmergencyAlertSink(logger, o.CreateCall, o.CallPriority, string.IsNullOrWhiteSpace(o.DispatchList) ? null : o.DispatchList, callsApi); + return (mdc, emergency, sink); + } + } +} +#endif diff --git a/Resgrid.Relay.Engine/Voice/RecordMode.cs b/Resgrid.Relay.Engine/Voice/RecordMode.cs new file mode 100644 index 0000000..0ce861a --- /dev/null +++ b/Resgrid.Relay.Engine/Voice/RecordMode.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Audio.Voice; +using Resgrid.Audio.Voice.Abstractions; +using Resgrid.Audio.Voice.Connection; +using Resgrid.Audio.Voice.LiveKit; +using Resgrid.Audio.Voice.Recording; +using Resgrid.Providers.ApiClient.V4; +using Serilog; + +namespace Resgrid.Relay.Engine.Voice +{ + /// + /// 'record' mode: joins one or all PTT channels for a department and records every + /// transmission (audio + metadata) for compliance. Cross-platform — runs on the + /// desktop or in a Linux/Docker container. + /// + public static class RecordMode + { + public static async Task RunAsync(RelayHostOptions options, ILogger logger, CancellationToken cancellationToken) + { + using var apiClient = new ResgridV4ApiClient(options.Resgrid); + var voiceApi = new VoiceApi(apiClient); + + var deptId = FirstNonEmpty(options.Recorder.DepartmentId, options.Voice.DepartmentId); + var transport = new LiveKitVoiceTransport(logger, options.Voice.PublishQueueMs); + var provider = new ResgridVoiceChannelProvider(logger, voiceApi); + await using var manager = new VoiceRoomManager(transport, logger); + + IReadOnlyList channels; + if (string.Equals(options.Recorder.Channel, "all", StringComparison.OrdinalIgnoreCase)) + channels = await provider.GetChannelsAsync(deptId, cancellationToken).ConfigureAwait(false); + else + channels = new[] { await provider.GetChannelAsync(options.Recorder.Channel, deptId, cancellationToken).ConfigureAwait(false) }; + + var (stores, disposableStores) = BuildStores(options.Recorder, logger); + var log = BuildLog(options.Recorder, logger); + var recorders = new List(); + + try + { + foreach (var channel in channels) + { + var session = await manager.JoinAsync(channel, cancellationToken).ConfigureAwait(false); + var recorder = new TransmissionRecorder(session, options.Recorder.Segmentation, stores, log, logger); + recorder.Start(); + recorders.Add(recorder); + } + + logger.Information($"Recording {channels.Count} channel(s) to {options.Recorder.Store}. Press Ctrl+C to stop."); + await VoiceModeRuntime.WaitForCancellationAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + // Always tear down so a mid-loop JoinAsync failure or cancellation does not + // leak already-created recorders, the metadata log, or disposable stores. + foreach (var recorder in recorders) + await recorder.DisposeAsync().ConfigureAwait(false); + if (log != null) + await log.DisposeAsync().ConfigureAwait(false); + foreach (var disposable in disposableStores) + disposable.Dispose(); + } + + return 0; + } + + internal static (IReadOnlyList stores, List disposables) BuildStores(RecorderModeOptions options, ILogger logger) + { + var stores = new List(); + var disposables = new List(); + var kind = (options.Store ?? "local").ToLowerInvariant(); + + if (kind == "local" || kind == "both") + stores.Add(new LocalFileTransmissionStore(options.LocalPath)); + + if (kind == "s3" || kind == "both") + { + var s3 = options.S3; + if (string.IsNullOrWhiteSpace(s3.Bucket)) + throw new InvalidOperationException("Recorder S3 store selected but RESGRID__RELAY__Recorder__S3__Bucket is not set."); + + var store = S3TransmissionStore.Create( + s3.Endpoint, s3.AccessKey, s3.SecretKey, s3.Region, s3.Bucket, s3.Prefix, s3.ForcePathStyle, s3.UseSsl); + stores.Add(store); + disposables.Add(store); + logger.Information("Recorder S3 store: bucket={Bucket} prefix={Prefix}", s3.Bucket, s3.Prefix); + } + + if (stores.Count == 0) + stores.Add(new LocalFileTransmissionStore(options.LocalPath)); + + return (stores, disposables); + } + + internal static ITransmissionLog BuildLog(RecorderModeOptions options, ILogger logger) + { + switch ((options.Log ?? "jsonl").ToLowerInvariant()) + { + case "none": return null; + case "sqlite": return new SqliteTransmissionLog(options.LogPath); + case "jsonl": + default: return new JsonlTransmissionLog(options.LogPath); + } + } + + private static string FirstNonEmpty(params string[] values) => + values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? ""; + } +} diff --git a/Resgrid.Relay.Engine/Voice/VoiceModeRuntime.cs b/Resgrid.Relay.Engine/Voice/VoiceModeRuntime.cs new file mode 100644 index 0000000..9a6e782 --- /dev/null +++ b/Resgrid.Relay.Engine/Voice/VoiceModeRuntime.cs @@ -0,0 +1,51 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Providers.ApiClient.V4.Models; + +namespace Resgrid.Relay.Engine.Voice +{ + /// Shared helpers for the cross-platform voice modes. + public static class VoiceModeRuntime + { + /// Awaits until cancellation without throwing. + public static async Task WaitForCancellationAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + catch (TaskCanceledException) + { + // expected on shutdown + } + } + + /// Builds a speakable dispatch announcement from a call. + public static string FormatCallAnnouncement(CallResultData call) + { + if (call == null) + return string.Empty; + + var sb = new StringBuilder(); + sb.Append("Attention. "); + + if (!string.IsNullOrWhiteSpace(call.Name)) + sb.Append(call.Name).Append(". "); + + if (!string.IsNullOrWhiteSpace(call.Nature)) + sb.Append(call.Nature).Append(". "); + + if (!string.IsNullOrWhiteSpace(call.Address)) + sb.Append("Location, ").Append(call.Address).Append(". "); + + sb.Append("Priority ").Append(call.Priority).Append('.'); + + if (!string.IsNullOrWhiteSpace(call.Number)) + sb.Append(" Call number ").Append(call.Number).Append('.'); + + return sb.ToString(); + } + } +} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh deleted file mode 100644 index 623f094..0000000 --- a/docker-entrypoint.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/bin/bash -# Entrypoint for the Resgrid Relay container. -# -# Configuration is driven entirely by environment variables (prefixed RESGRID__RELAY__). -# See the Dockerfile for the complete list of available settings and defaults. -# The bundled appsettings.json acts as a reference template only — it contains -# no operational values. Everything must come from docker run -e or docker-compose. -# -# Optional: LocalXpose TCP tunnel -# LOCLX_ENABLED=true enable the tunnel (default: false) -# LOCLX_TOKEN= LocalXpose access token (required) -# LOCLX_RESERVED_ENDPOINT= optional reserved endpoint, e.g. smtp.loclx.io:25 -# -# Alternatively, mount a tunnels YAML file at /etc/resgrid/loclx-tunnels.yaml -# and set LOCLX_ENABLED=true. -set -e - -# ─── Validation ─────────────────────────────────────────────────────────── -echo "[relay] Resgrid Relay starting..." -echo "[relay] Mode: ${RESGRID__RELAY__Mode:-smtp}" -echo "[relay] API: ${RESGRID__RELAY__Resgrid__BaseUrl:-(NOT SET — required)}" -echo "[relay] Port: ${RESGRID__RELAY__Smtp__Port:-2525}" - -if [ "${RESGRID__RELAY__Smtp__RedisCache__Enabled:-false}" = "true" ]; then - echo "[relay] Redis: ${RESGRID__RELAY__Smtp__RedisCache__ConnectionString:-not set}" -fi - -if [ -z "${RESGRID__RELAY__Resgrid__BaseUrl}" ]; then - echo "[relay] ERROR: RESGRID__RELAY__Resgrid__BaseUrl is required." - exit 1 -fi - -if [ -z "${RESGRID__RELAY__Resgrid__ClientId}" ]; then - echo "[relay] ERROR: RESGRID__RELAY__Resgrid__ClientId is required." - exit 1 -fi - -if [ -z "${RESGRID__RELAY__Resgrid__ClientSecret}" ]; then - echo "[relay] ERROR: RESGRID__RELAY__Resgrid__ClientSecret is required." - exit 1 -fi - -grant_type="${RESGRID__RELAY__Resgrid__GrantType:-RefreshToken}" -case "${grant_type}" in - RefreshToken) - if [ -z "${RESGRID__RELAY__Resgrid__RefreshToken}" ]; then - echo "[relay] ERROR: RESGRID__RELAY__Resgrid__RefreshToken is required when GrantType=RefreshToken." - exit 1 - fi - ;; - SystemApiKey) - if [ -z "${RESGRID__RELAY__Resgrid__SystemApiKey}" ]; then - echo "[relay] ERROR: RESGRID__RELAY__Resgrid__SystemApiKey is required when GrantType=SystemApiKey." - exit 1 - fi - ;; -esac - -# ─── LocalXpose tunnel ──────────────────────────────────────────────────── -if [ "${LOCLX_ENABLED:-false}" = "true" ]; then - SMTP_PORT="${RESGRID__RELAY__Smtp__Port:-2525}" - - if [ -n "${LOCLX_TOKEN}" ]; then - echo "[localxpose] Authenticating..." - export LX_ACCESS_TOKEN="${LOCLX_TOKEN}" - loclx auth login - else - echo "[localxpose] WARNING: LOCLX_TOKEN is not set; tunnel may fail to authenticate." - fi - - if [ -f "/etc/resgrid/loclx-tunnels.yaml" ]; then - echo "[localxpose] Starting tunnel from /etc/resgrid/loclx-tunnels.yaml..." - loclx tunnel -c /etc/resgrid/loclx-tunnels.yaml & - elif [ -n "${LOCLX_RESERVED_ENDPOINT}" ]; then - echo "[localxpose] Starting reserved TCP tunnel to localhost:${SMTP_PORT} via ${LOCLX_RESERVED_ENDPOINT}..." - loclx tunnel tcp --to "localhost:${SMTP_PORT}" --reserved-endpoint "${LOCLX_RESERVED_ENDPOINT}" & - else - echo "[localxpose] Starting ephemeral TCP tunnel to localhost:${SMTP_PORT}..." - loclx tunnel tcp --to "localhost:${SMTP_PORT}" & - fi - - echo "[localxpose] Tunnel started (PID: $!)." -fi - -exec dotnet /app/Resgrid.Audio.Relay.Console.dll diff --git a/scripts/download-livekit-ffi.sh b/scripts/download-livekit-ffi.sh new file mode 100755 index 0000000..ef3323d --- /dev/null +++ b/scripts/download-livekit-ffi.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Download the LiveKit FFI native library (the Rust client core used by +# Livekit.Rtc.Dotnet) for a given platform into a target directory. +# +# The native library is NOT bundled in the Livekit.Rtc.Dotnet NuGet, so it must be +# provisioned alongside the published app for the 'record' and 'dispatch' voice +# modes. The Dockerfile fetches the linux build itself; CI uses this for win-x64. +# +# Usage: +# scripts/download-livekit-ffi.sh [ffi-version] +# platform: linux-x64 | linux-arm64 | win-x64 | win-arm64 | osx-x64 | osx-arm64 +# ffi-version: defaults to the version validated against Livekit.Rtc.Dotnet 0.1.3 +set -euo pipefail + +PLATFORM="${1:?platform required (e.g. win-x64)}" +OUTDIR="${2:?output directory required}" +FFI_VERSION="${3:-livekit-ffi/v0.12.65}" + +case "${PLATFORM}" in + linux-x64) ASSET="ffi-linux-x86_64"; LIB="liblivekit_ffi.so" ;; + linux-arm64) ASSET="ffi-linux-arm64"; LIB="liblivekit_ffi.so" ;; + win-x64) ASSET="ffi-windows-x86_64"; LIB="livekit_ffi.dll" ;; + win-arm64) ASSET="ffi-windows-arm64"; LIB="livekit_ffi.dll" ;; + osx-x64) ASSET="ffi-macos-x86_64"; LIB="liblivekit_ffi.dylib" ;; + osx-arm64) ASSET="ffi-macos-arm64"; LIB="liblivekit_ffi.dylib" ;; + *) echo "Unsupported platform: ${PLATFORM}" >&2; exit 1 ;; +esac + +ENC="${FFI_VERSION//\//%2F}" +URL="https://github.com/livekit/rust-sdks/releases/download/${ENC}/${ASSET}.zip" + +mkdir -p "${OUTDIR}" +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +echo "Downloading ${FFI_VERSION} (${ASSET}) ..." +curl -sSL -o "${TMP}/ffi.zip" "${URL}" +unzip -q "${TMP}/ffi.zip" -d "${TMP}/ffi" + +SRC="$(find "${TMP}/ffi" -name "${LIB}" | head -n1)" +[ -n "${SRC}" ] || { echo "ERROR: ${LIB} not found in archive" >&2; exit 1; } +install -m 755 "${SRC}" "${OUTDIR}/${LIB}" +echo "Installed ${OUTDIR}/${LIB}"