18 Commits
Author SHA1 Message Date
devsyncwrld ce6bba0510 fix(ci): pulseaudio headers, and an ASCII release payload
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Linux got past pipewire and stopped on pulse/pulseaudio.h — the module
compiles both backends, so both sets of headers are needed.

Windows reached the upload and Gitea rejected the release creation with HTTP
422, 'invalid UTF-8 within /name': the JSON body carried accented text and the
Windows runner's shell mangles the encoding on the way out. The payload is now
ASCII, with a comment saying why, so it does not get 'improved' back into
Portuguese.
2026-09-01 16:04:01 -03:00
devsyncwrld 9ea399ded5 fix(ci): install pipewire headers, and make the Gitea upload diagnosable
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Two separate failures on the first run.

Linux could not compile the native module: it needs libpipewire-0.3-dev, which
its README states and the apt list omitted.

Windows built everything, native module verified, then died on the upload with
a bare JSONDecodeError. The cause was curl -sf, which discards the error body,
so a failed release creation surfaced as an empty pipe and no reason at all.
The step now captures status and body and prints them, treats a failed create
as possibly the other matrix job having just created it, and passes
target_commitish so the tag can be created.

Embedded python gave way to jq: a multi-line heredoc inside a YAML literal
block ends the block at the first unindented line, which is how the file became
invalid YAML in the first place.
2026-09-01 15:58:26 -03:00
devsyncwrld 9f7723d104 ci: build on GitHub, publish updates from Gitea
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
GitHub stays the build machine — it has the Windows runners the native audio
module needs — but the update feed moves to this fork's own Gitea.

The GitHub repository is private, and electron-updater against a private GitHub
repo needs a token inside the shipped app, which is a leaked token. Gitea
serves release assets to anyone, so the installer carries no credential. The
flow was verified end to end against the live instance before writing this:
create release, upload asset, download anonymously.

The release tag is fixed at 'latest' because electron-updater fetches
latest.yml before it knows which version exists, so the URL cannot carry a
version; CI replaces that release's assets each publish. electron-builder runs
with --publish never since it cannot upload to Gitea, but still emits the
latest.yml the updater reads.

Needs a GITEA_TOKEN secret on the GitHub repository.
2026-09-01 15:49:25 -03:00
devsyncwrld 5b84843217 fix(screenshare): make shared system audio audible
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Listeners had to run the participant at 200% with attenuation off. Two causes,
both on the publishing side.

WASAPI returns the process mix at a level well below what a microphone track
arrives at, so the track was quiet before it ever left the machine. A gain
stage now boosts it, followed by a limiter — a compressor with a high ratio and
fast attack — so the boost cannot clip loud passages.

And the track was published with LiveKit's defaults, which are tuned for
speech: mono, low bitrate, and DTX enabled, which stops transmitting during
silence. On game and music audio that reads as thin and clipped. It now
publishes with the music stereo preset, stereo forced, and DTX and RED off.

Lives in packages/web, so it reaches the desktop app through a normal deploy —
no new installer.
2026-09-01 15:36:55 -03:00
devsyncwrld bd8decb4b3 fix(ci): actually compile the native audio module, and prove it
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The previous run went green and produced an installer with no system-audio
capture in it — the exact silent failure this module exists to prevent.

Two causes. electron-rebuild takes -w as one comma-separated list, not a
repeated flag; passing it twice made argv.w an array and the CLI threw
'argv.w.split is not a function', which also broke uiohook-napi's rebuild that
had been working. And pnpm 10 refuses to run a dependency's build script unless
it is listed in onlyBuiltDependencies, so node-gyp never ran for it at all —
the log said 'Ignored build scripts' and nothing else complained.

Neither surfaced because desktop's postinstall ends in , which
exists so contributors without build tools can install. That is reasonable
locally and dangerous in CI, so the workflow now asserts a compiled .node
exists and fails loudly when it does not, instead of trusting an exit code that
was designed to lie.
2026-09-01 15:13:26 -03:00
devsyncwrld 52f43d4d61 build: record electron-native-screenshare in the lockfile
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The dependency was added to packages/desktop/package.json without regenerating
the lockfile, so CI's --frozen-lockfile refused the install — correctly: a
build that resolves fresh versions is a build that differs from what was
tested.
2026-09-01 15:06:59 -03:00
devsyncwrld 7eaba5e3f3 ci: build only the architectures this group runs
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Windows arm64 doubled the slowest job in the matrix — two Electron
distributions downloaded, native modules compiled twice — and nobody here runs
Windows on ARM. Linux arm64 went for the same reason.

Removing arm64 also retires the fpm step: electron-builder's bundled fpm is
x86_64-only and failed on the arm64 runner, which is the only reason a native
fpm was installed (ruby, ruby-dev, build-essential and a gem with native
extensions, on every run). On an x86_64 runner the bundled one works.

Matrix is now Windows x64 and Linux x64.
2026-09-01 14:02:54 -03:00
devsyncwrld 124db82a0c ci: drop the macOS build from the release matrix
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Nobody in this group runs macOS, and on a private repository the macOS runner
bills at 10x — it was the most expensive platform in the matrix, compiled on
every tag for zero users.

This edits a file inherited from upstream, so it will conflict on merges. That
is the accepted cost: the alternative is paying for a macOS build indefinitely.
2026-09-01 14:00:12 -03:00
devsyncwrld b4d001eb26 ci: add a fast Windows-only desktop build
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
release.yml builds four platforms and publishes a release, which is right for a
release and slow for 'give me an exe to test'. Wall time is set by the Windows
job, which compiles native modules and downloads Electron twice because it
targets x64 and arm64 together — and Windows-on-ARM is not something this group
runs.

This path builds one architecture, caches the Electron and electron-builder
downloads (~100MB re-fetched every run otherwise), and uploads the installer as
an artifact, so it needs neither a tag nor a version bump.

Kept as a separate file: release.yml comes from upstream and takes merges, so
editing it would conflict on every update.

Cost matters too on a private repository: macOS runners bill at 10x and Windows
at 2x, so the full matrix was paying for macOS builds on every tag.
2026-09-01 13:29:18 -03:00
devsyncwrld 60c51a1635 chore: version 1.1.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
First release built from the fork. electron-builder names artifacts and the
GitHub release from package.json rather than the git tag, so the two have to
agree or electron-updater compares mismatched versions and never offers the
update. v1.0.0 is already taken by upstream's tag in this history.
2026-09-01 13:15:13 -03:00
devsyncwrld d208b0c277 build(desktop): publish releases to syncwrld/resenhacord
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-09-01 13:14:43 -03:00
devsyncwrld 2f836cd366 build(desktop): correct the fork's GitHub owner to syncwrld
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-09-01 13:11:49 -03:00
devsyncwrld f7f2cf75ab build(desktop): point releases at the fork and rebuild the new native module
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
release.yml already builds and publishes desktop installers for every platform
on a v* tag, so no new workflow was needed — it needed adapting to the fork.

electron-builder published to upstream's repository, so releases (and with them
the electron-updater feed, which had no source at all) went nowhere useful.

More consequential: postinstall ran electron-rebuild against uiohook-napi only.
electron-native-screenshare would have been compiled for Node's ABI rather than
Electron's and failed to load at runtime — and since the loader degrades
quietly by design, the symptom would have been screen sharing with no sound and
no error, which is the exact bug this module exists to fix.

Documented in docs/build-desktop.md, including why the build has to run on
GitHub (Gitea Actions provides no hosted runners, and the module needs MSVC)
and why the Windows runner stays pinned to windows-2022.
2026-09-01 13:10:10 -03:00
devsyncwrld 3deed92dd9 feat(sounds): replace call and stream effects
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The originals were all 1.14s — long enough to be intrusive for events that
fire whenever anyone joins, leaves, or watches a stream.

Synthesised rather than sourced: no third-party file, so no licensing question
in a public repository. The parameters are measured, not guessed — envelope,
spectral peaks and decay taken from two reference sounds the instance owner
supplied. The timbre is fundamental plus octave at near-equal strength
(1.00 / 0.85 / 0.10 / 0.02) decaying to 1/e in 0.19s, with no reverb.

Call join rises C4→G4, leave falls D4→G3, matching the references' intervals
and their 100ms spacing. The stream pair reuses those resolution notes as
single tones at ~55% the volume: they fire far more often during a broadcast,
so they have to sit under the call sounds rather than beside them.

Stream start is the one event that happens once per broadcast, so it can be a
chord — with a low thump and a short air layer that both die inside 150ms,
adding weight without length. Stream end mirrors it descending, quieter, and
without the air, since brightness reads as arrival.

Generators and the measured parameters are kept in tools/sfx so these can be
retuned without redoing the analysis. Total size drops from 150KB to 41KB.
2026-09-01 13:01:06 -03:00
devsyncwrld d525bbb8c5 feat(desktop): capture system audio with process isolation
Electron's audio: 'loopback' captures the whole output mix, this app's own
playback included — so everyone else's voices went back out inside the share
and each listener heard themselves. Not acoustic echo but a digital copy of
the output, which is why headphones never helped, and why shareAudio already
defaulted to off in the desktop app.

Electron offers no way to exclude our own audio: the docs allow only
'loopback' or 'loopbackWithMute', and the handler discards the renderer's
constraints (restrictOwnAudio never arrives). electron-native-screenshare does
it at the OS level — WASAPI process loopback on Windows — capturing only the
shared window when its pid resolves, and otherwise everything except us.

The module hands raw PCM to the main process, so it crosses IPC and is
scheduled onto a running cursor in Web Audio to become a MediaStreamTrack,
published as ScreenShareAudio. Loading is optional and failure degrades to a
silent share rather than blocking the app or the screen share.

The browser path is untouched: Chrome honours restrictOwnAudio and has no echo.

Verified by typecheck (web and Electron main) and the web suite. The audio path
itself cannot be exercised here — no Windows, no Electron, no audio device.
2026-09-01 00:56:34 -03:00
devsyncwrld 310e9b86be docs: diagnose the screen-share audio echo
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 22:58:59 -03:00
devsyncwrld e291b5e411 docs: drop delivered rows from the pending sections
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 22:40:44 -03:00
devsyncwrld c899253e52 fix: soundboard upload in Electron, and Spotify sync/disappearing/progress
Soundboard: naming a clip used window.prompt, which Electron does not
implement — it returned nothing, the flow aborted in silence, and adding a
sound worked in the browser while doing nothing at all in the desktop app.
Replaced with a two-step field inside the popover, identical in both.

Spotify, three separate defects behind the two symptoms reported:

Out of sync — a 20s poll stacked on the activity store's 5s debounce left
everyone else on the previous track for up to 25s. The next poll is now
scheduled just past the current track's end instead of on a fixed interval,
and a track change bypasses the debounce (it happens once every few minutes;
the debounce exists for chatty producers).

Vanishing — a paused track, and the silent gap Spotify reports between two
songs, both cleared the activity outright. Pausing is now carried as state
rather than absence, and an empty answer is tolerated for 25s before the
block comes down.

Progress bar — timestamps are computed with the server's clock and were drawn
against the viewer's, so any drift displaced the bar; and it kept advancing
after a pause until the next poll. The ready payload now carries server time
so each client can correct its own offset, and the bar freezes when paused.

Tray, native notifications and system audio in screen share were all found
already implemented and wired end to end; recorded in the roadmap rather than
built again.
2026-08-31 22:39:28 -03:00
38 changed files with 1147 additions and 72 deletions
+97
View File
@@ -0,0 +1,97 @@
# Caminho rápido: só Windows x64, disparado à mão.
#
# O `release.yml` continua sendo o release de verdade (todas as plataformas,
# publica release e alimenta o electron-updater). Este aqui existe para quando
# você só quer um .exe para testar, sem marcar versão.
#
# Arquivo separado de propósito: o release.yml veio do upstream e recebe merges;
# mexer nele criaria conflito a cada atualização.
name: Build Windows (rápido)
on:
workflow_dispatch:
inputs:
arch:
description: Arquitetura
required: false
default: x64
type: choice
options: [x64, arm64]
permissions:
contents: read
jobs:
build:
# windows-2022, não windows-latest: a imagem latest traz o Visual Studio 18,
# que o node-gyp embutido no electron-rebuild não detecta ao compilar os
# módulos nativos. Mesma razão documentada no release.yml.
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
# ~100MB de binários do Electron e das ferramentas do NSIS, baixados a
# cada execução sem isto. É o maior ganho depois de cortar o arm64.
- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: electron-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared package
run: pnpm --filter @backspace/shared build
# O postinstall termina em `|| console.warn` para que quem não tem
# ferramentas de build consiga instalar. No CI isso transforma falha em
# sucesso silencioso: o instalador sai sem o módulo nativo e o app
# compartilha tela sem som, sem erro nenhum. Então verifica-se o
# resultado em vez de confiar no código de saída.
- name: Verify native audio module compiled
shell: bash
run: |
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' 2>/dev/null | head -5)
if [ -z "$found" ]; then
echo "::error::electron-native-screenshare has no compiled .node — the installer would ship without system-audio capture"
find node_modules/.pnpm -maxdepth 1 -name 'electron-native-screenshare*' -printf '%p\n' 2>/dev/null || true
exit 1
fi
echo "OK:"; echo "$found"
- name: Compile desktop TypeScript
working-directory: packages/desktop
run: pnpm exec tsc
# --publish never: sem release, sem precisar de tag nem bump de versão.
# O instalador sai como artefato desta execução.
- name: Build installer
working-directory: packages/desktop
run: pnpm exec electron-builder --win --${{ inputs.arch || 'x64' }} --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload installer
uses: actions/upload-artifact@v4
with:
name: backspace-windows-${{ inputs.arch || 'x64' }}
path: packages/desktop/dist-electron/*.exe
retention-days: 14
if-no-files-found: error
+183
View File
@@ -0,0 +1,183 @@
# Compila no GitHub, publica no Gitea.
#
# O GitHub entra só como máquina de build — é dele que vêm os runners Windows
# de que o módulo nativo de áudio precisa. A distribuição fica no Gitea, que
# serve os arquivos a qualquer um: assim o electron-updater não precisa de
# credencial embutida no app, o que aconteceria com um repositório privado no
# GitHub.
name: Publicar no Gitea
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: Tag a publicar (ex. v1.1.0)
required: true
type: string
permissions:
contents: read
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-2022
args: --win --x64
- os: ubuntu-latest
args: --linux --x64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libx11-dev libxtst-dev libxt-dev \
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
libxrandr-dev libxinerama-dev libx11-xcb-dev \
libpipewire-0.3-dev libpulse-dev
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~/.cache/electron
~/.cache/electron-builder
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: electron-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared package
run: pnpm --filter @backspace/shared build
# O postinstall termina em `|| console.warn` para quem não tem ferramentas
# de build. No CI isso esconde falha: o instalador sairia sem captura de
# áudio do sistema e ninguém saberia. Verifica-se o resultado.
- name: Verify native audio module compiled
if: runner.os == 'Windows'
shell: bash
run: |
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' | head -5)
[ -n "$found" ] || { echo "::error::sem .node compilado — instalador sairia sem áudio do sistema"; exit 1; }
echo "$found"
- name: Compile desktop TypeScript
working-directory: packages/desktop
run: pnpm exec tsc
# --publish never: o electron-builder não sabe enviar para o Gitea. Ele
# gera os instaladores e o latest.yml (o índice que o app consulta), e o
# passo seguinte faz o upload.
- name: Build installers
working-directory: packages/desktop
run: pnpm exec electron-builder ${{ matrix.args }} --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload to Gitea release
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_API: https://git.resenha.website/api/v1/repos/devsyncwrld/backspace
TAG: latest
run: |
set -uo pipefail
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::segredo GITEA_TOKEN não configurado"
exit 1
fi
# Sem -f e imprimindo o corpo: a versão anterior usava `curl -sf`, que
# engole a resposta de erro, então uma falha aqui só aparecia como um
# JSONDecodeError sem dizer o motivo.
api() {
local method=$1 path=$2; shift 2
curl -s -w '\n%{http_code}' -X "$method" \
-H "Authorization: token $GITEA_TOKEN" "$GITEA_API$path" "$@"
}
body() { sed '$d' <<<"$1"; }
code() { tail -n1 <<<"$1"; }
# jq em vez de python embutido: um heredoc multilinha dentro de um
# bloco literal de YAML encerra o bloco na primeira linha sem recuo.
json_id() { jq -r '.id // empty'; }
find_release() {
local r; r=$(api GET "/releases/tags/$TAG")
[ "$(code "$r")" = "200" ] && body "$r" | json_id || echo ""
}
# O corpo do POST fica em ASCII de proposito: o shell do runner
# Windows corrompe UTF-8 na requisicao e o Gitea recusa com
# "invalid UTF-8 within /name" (HTTP 422).
ID=$(find_release)
if [ -z "$ID" ]; then
R=$(api POST "/releases" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Latest build\",\"target_commitish\":\"main\",\"body\":\"Installers published automatically by CI.\"}")
if [ "$(code "$R")" = "201" ]; then
ID=$(body "$R" | json_id)
echo "release criada: $ID"
else
echo "criação retornou HTTP $(code "$R"): $(body "$R")"
# O outro job da matriz pode tê-la criado no mesmo instante.
ID=$(find_release)
[ -n "$ID" ] || { echo "::error::não foi possível obter nem criar a release"; exit 1; }
echo "release encontrada após corrida: $ID"
fi
else
echo "release existente: $ID"
fi
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
# não apagarem o trabalho um do outro.
R=$(api GET "/releases/$ID/assets")
if [ "$(code "$R")" = "200" ]; then
body "$R" | jq -r '.[] | "\(.id) \(.name)"' | while read -r aid aname; do
case "$aname" in
${{ runner.os == 'Windows' && '*.exe|latest.yml' || '*.AppImage|*.deb|latest-linux.yml' }})
echo "removendo anexo antigo: $aname"
api DELETE "/releases/$ID/assets/$aid" > /dev/null ;;
esac
done
fi
shopt -s nullglob
sent=0
for f in packages/desktop/dist-electron/*.exe \
packages/desktop/dist-electron/*.AppImage \
packages/desktop/dist-electron/*.deb \
packages/desktop/dist-electron/latest*.yml; do
name=$(basename "$f")
R=$(api POST "/releases/$ID/assets?name=$name" -F "attachment=@$f")
if [ "$(code "$R")" != "201" ]; then
echo "::error::falha ao enviar $name — HTTP $(code "$R"): $(body "$R")"
exit 1
fi
echo "enviado: $name"
sent=$((sent+1))
done
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
echo "$sent arquivo(s) publicados"
+11 -22
View File
@@ -14,22 +14,26 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
# macOS — universal build (arm64 + x64) on Apple Silicon runner # Fork: só as plataformas que o grupo usa. Windows arm64 saiu porque
- os: macos-latest # dobrava o job mais lento (duas distribuições do Electron, módulos
args: --mac --arm64 --x64 # nativos compilados duas vezes) e ninguém roda Windows em ARM.
# Linux arm64 saiu pelo mesmo motivo. Com o arm64 fora, o passo que
# instalava o fpm nativo perdeu a razão de existir: ele só era
# necessário porque o fpm embutido no electron-builder é x86_64 e
# falhava no runner arm64.
# macOS removido neste fork: ninguém do grupo usa, e o runner macOS
# é cobrado a 10x num repositório privado — era a plataforma mais cara
# da matriz, compilada em toda tag para zero usuários.
# Windows — x64 + arm64 on x64 runner. # Windows — x64 + arm64 on x64 runner.
# Pinned to windows-2022 (VS 2022 / v17): the windows-latest image # Pinned to windows-2022 (VS 2022 / v17): the windows-latest image
# ships VS "18", which the node-gyp bundled with electron-rebuild # ships VS "18", which the node-gyp bundled with electron-rebuild
# cannot detect ("unknown version undefined") when compiling # cannot detect ("unknown version undefined") when compiling
# uiohook-napi in postinstall. # uiohook-napi in postinstall.
- os: windows-2022 - os: windows-2022
args: --win --x64 --arm64 args: --win --x64
# Linux — x64 on x64 runner # Linux — x64 on x64 runner
- os: ubuntu-latest - os: ubuntu-latest
args: --linux --x64 args: --linux --x64
# Linux — arm64 on arm64 runner
- os: ubuntu-24.04-arm
args: --linux --arm64
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@@ -59,17 +63,6 @@ jobs:
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \ libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
libxrandr-dev libxinerama-dev libx11-xcb-dev libxrandr-dev libxinerama-dev libx11-xcb-dev
- name: Install fpm for .deb packaging
if: runner.os == 'Linux'
# electron-builder bundles an x86_64-only fpm; on the arm64 runner it
# aborts the .deb target with "cannot execute binary file: Exec format
# error". Install fpm natively and set USE_SYSTEM_FPM (below) so both
# arches package their .deb with a host-native fpm. ruby-dev + make are
# needed for fpm's native gem dependencies.
run: |
sudo apt-get install -y ruby ruby-dev build-essential
sudo gem install --no-document fpm
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with: with:
@@ -97,7 +90,3 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"
# Use the host-native fpm installed above instead of electron-builder's
# bundled x86_64 fpm (which can't run on the arm64 runner). No-op on
# macOS/Windows, which don't build .deb.
USE_SYSTEM_FPM: "true"
+56
View File
@@ -0,0 +1,56 @@
# Gerar o instalador do app desktop
O workflow **`.github/workflows/release.yml`** (herdado do upstream) já compila
para macOS, Windows e Linux e publica os instaladores como release. Ele dispara
ao empurrar uma tag `v*`.
Não foi preciso escrever workflow novo — foi preciso **adaptá-lo ao fork**.
## Por que GitHub e não Gitea
O Gitea desta instância não tem Actions habilitado, e mesmo habilitado ele
**não oferece máquinas hospedadas**: seria preciso registrar um PC Windows
como runner e mantê-lo ligado. O GitHub fornece runners Windows prontos, que é
exatamente o que falta — o módulo nativo do compartilhamento de áudio usa
WASAPI e só compila no Windows, com o compilador da Microsoft.
O Gitea continua sendo o repositório principal. O GitHub entra apenas como
espelho para compilar.
## Passos (uma vez)
1. Criar um repositório no GitHub — o `electron-builder.yml` está apontado para
`syncwrld/resenhacord`. **Se o seu for outro nome, ajuste lá.**
2. Adicionar o espelho e empurrar:
```
cd /opt/backspace
git remote add github git@github.com:syncwrld/resenhacord.git
git push github main
```
3. Marcar uma versão e empurrar a tag — é ela que dispara a compilação:
```
git tag v1.0.1 && git push github v1.0.1
```
4. Os instaladores aparecem na aba Releases do GitHub em ~15 minutos.
## O que isso resolve de quebra
O `electron-updater` já estava instalado no projeto mas **sem feed** — o app
não se atualizava sozinho. Como o `publish` agora aponta para as releases do
fork, o app passa a encontrar versões novas por conta própria. Ninguém do grupo
precisa reinstalar na mão de novo.
## Adaptações feitas para o fork
| O quê | Por quê |
|---|---|
| `publish.owner` → `syncwrld` | Apontava para o repositório do upstream |
| `postinstall` reconstrói também `electron-native-screenshare` | Rodava `electron-rebuild` só no `uiohook-napi`. O módulo novo seria compilado para a ABI do Node em vez da do Electron e falharia ao carregar — e como o carregamento degrada em silêncio, o sintoma seria "compartilha sem som", sem erro visível |
| Nota sobre `asarUnpack` | O `uiohook-napi` tem `build/` excluído do asar porque distribui binários prontos. O módulo novo **não** distribui: `build/Release/*.node` é a única cópia e não pode ser excluída |
## Detalhe herdado que vale preservar
O runner do Windows está fixado em `windows-2022`, não `windows-latest`. O
comentário no workflow explica: a imagem `latest` traz o Visual Studio 18, que
o node-gyp embutido no `electron-rebuild` não detecta ("unknown version
undefined"). Não mude isso sem testar.
+13 -10
View File
@@ -51,8 +51,6 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
| Feature | Tamanho | Observação técnica | | Feature | Tamanho | Observação técnica |
|---|---|---| |---|---|---|
| Soundboard | Média | `AudioManager` já carrega e toca `.ogg` sob demanda; falta upload por espaço, permissão e disparo na sala LiveKit |
| Estatísticas do grupo | Grande | Horas em call, quem mais falou, ranking. **Depende do mesmo registro de eventos da auditoria (#9)** |
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes | | Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem | | Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
@@ -86,21 +84,26 @@ modais de convite · configurações restantes · telas de erro
| Problema | Causa provável | Correção | | Problema | Causa provável | Correção |
|---|---|---| |---|---|---|
| **Enviar som ao soundboard não funciona no app desktop** (funciona no navegador) | `SoundboardPopover` pede o nome do som com `window.prompt`, que o **Electron não implementa** — não abre nada e devolve vazio, então o fluxo aborta em silêncio, sem erro. `window.prompt` aparece em exatamente um lugar no projeto: esse. O resto do código já o evitava | Trocar por um campo de texto dentro do próprio popover (ou um modal reutilizável). Some o prompt e passa a funcionar igual nos dois. **Vale criar o modal de entrada genérico**, já que não existe nenhum e outras features vão precisar | | **Enviar som ao soundboard não funciona no app desktop** (funciona no navegador) | `SoundboardPopover` pede o nome do som com `window.prompt`, que o **Electron não implementa** — não abre nada e devolve vazio, então o fluxo aborta em silêncio, sem erro. `window.prompt` aparece em exatamente um lugar no projeto: esse. O resto do código já o evitava | Trocar por um campo de texto dentro do próprio popover (ou um modal reutilizável). Some o prompt e passa a funcionar igual nos dois. **Vale criar o modal de entrada genérico**, já que não existe nenhum e outras features vão precisar |
| **Eco absurdo ao compartilhar tela com som** (app desktop) | O `loopback` do Electron captura a mistura de saída do sistema **inteiro**, que inclui o próprio Backspace tocando a voz dos outros. Essa voz volta para eles dentro da transmissão, com atraso. Não é eco acústico — é digital, então **fone não resolve**. As constraints do web (`restrictOwnAudio`) nunca chegam: o handler ignora o `_request` e monta o stream a partir do enum. É também por isso que `shareAudio` já vinha desligado por padrão no app | **Implementado (2026-09-01), não testado.** Trocado por `electron-native-screenshare`: captura com isolamento por processo — só a janela compartilhada (include) ou tudo menos este app (exclude). O PCM vai do processo principal ao renderer por IPC e vira faixa publicada no LiveKit. **Exige gerar instalador novo e reinstalar**, e só pode ser confirmado numa máquina Windows |
| **Bloco do Spotify dessincronizado** (mostra a faixa errada por um tempo) | Soma de duas esperas: a consulta roda a cada **20s** (`useSpotifyActivity`) e o envio pelo WebSocket ainda passa por um **debounce de 5s** no `activityStore`. Na pior hipótese os outros veem a música anterior por ~25s | Consultar de novo perto do fim da faixa (a duração é conhecida) em vez de só por intervalo fixo, e encurtar o debounce para esta fonte | | **Bloco do Spotify dessincronizado** (mostra a faixa errada por um tempo) | Soma de duas esperas: a consulta roda a cada **20s** (`useSpotifyActivity`) e o envio pelo WebSocket ainda passa por um **debounce de 5s** no `activityStore`. Na pior hipótese os outros veem a música anterior por ~25s | Consultar de novo perto do fim da faixa (a duração é conhecida) em vez de só por intervalo fixo, e encurtar o debounce para esta fonte |
| **Bloco do Spotify some sozinho** | Três caminhos apagam a atividade inteira: faixa **pausada** (`is_playing: false` devolve `null`), o **vão entre faixas** (o Spotify responde `204`) e qualquer falha transitória. Some e volta = a piscada que você viu | Manter a última faixa conhecida por alguns segundos antes de apagar, e enviar um estado *pausado* explícito em vez de sumir com o bloco | | **Bloco do Spotify some sozinho** | Três caminhos apagam a atividade inteira: faixa **pausada** (`is_playing: false` devolve `null`), o **vão entre faixas** (o Spotify responde `204`) e qualquer falha transitória. Some e volta = a piscada que você viu | Manter a última faixa conhecida por alguns segundos antes de apagar, e enviar um estado *pausado* explícito em vez de sumir com o bloco |
| **Barra de progresso errada / andando pausada** | Duas causas independentes: (1) o progresso é derivado de carimbos calculados com o relógio do **servidor** e desenhado contra o relógio de **quem olha** — se os relógios divergem, a barra fica deslocada; (2) a barra continua avançando localmente depois que a pessoa pausa, até a próxima consulta | Enviar o horário do servidor junto no payload para o cliente corrigir a diferença, e congelar a barra quando o estado for pausado | | **Barra de progresso errada / andando pausada** | Duas causas independentes: (1) o progresso é derivado de carimbos calculados com o relógio do **servidor** e desenhado contra o relógio de **quem olha** — se os relógios divergem, a barra fica deslocada; (2) a barra continua avançando localmente depois que a pessoa pausa, até a próxima consulta | Enviar o horário do servidor junto no payload para o cliente corrigir a diferença, e congelar a barra quando o estado for pausado |
## Aprovadas para o app desktop (2026-08-31) ## App desktop — já existia (verificado 2026-08-31)
Só entram aqui coisas que o navegador **não consegue** fazer — o resto seria As três pedidas já estão implementadas e ligadas de ponta a ponta. **Não
trabalho dobrado sem ganho. **Nenhuma iniciada.** construir de novo.**
| Feature | Tamanho | Observação técnica | - **Bandeja** — `createTray()` em `desktop/src/main.ts`, chamada na inicialização;
|---|---|---| fechar a janela esconde em vez de sair (`mainWindow.on('close')`).
| **Áudio do sistema no compartilhamento de tela** | Média | Hoje o som do jogo/vídeo não vai junto com a tela. O Electron captura áudio do sistema; o navegador não. **É pré-requisito da watch party** — sem isso, assistir junto é assistir mudo. No Linux depende do servidor de áudio (PipeWire/PulseAudio), então vale confirmar o alvo antes | - **Notificações nativas** — `showNotification()` no processo principal, canal
| **Bandeja do sistema** | Pequena | Fechar minimiza em vez de sair; ícone com menu de mudo/silenciar e sair de verdade. Cuidado clássico: sem um "sair" explícito no menu, a pessoa não consegue fechar o app | IPC `show-notification`, e o web já chama por `platform/notifications.ts`.
| **Notificações nativas do sistema** | Pequena | Mais confiáveis que as do navegador e funcionam com a janela minimizada. Já existe um `NotificationController` no web; a parte desktop é rotear pelo processo principal | - **Áudio do sistema no compartilhamento** — `setDisplayMediaRequestHandler`
devolve `audio: 'loopback'`; existe caixa de seleção no `ScreenSharePicker`
ligada a `screenShareConfig.shareAudio`, que atravessa o IPC.
Se algum não se manifestar em uso, o trabalho é **depuração**, não construção.
## Aprovadas, a fazer depois (2026-08-31) ## Aprovadas, a fazer depois (2026-08-31)
+4 -3
View File
@@ -1,8 +1,8 @@
{ {
"name": "backspace", "name": "backspace",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"description": "Open, self-hosted communication platform — text, voice, video, and federation", "description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"author": "Jannis Braun", "author": "Jannis Braun",
"homepage": "https://github.com/TheZwiss/backspace", "homepage": "https://github.com/TheZwiss/backspace",
@@ -32,8 +32,9 @@
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"better-sqlite3", "better-sqlite3",
"esbuild",
"electron", "electron",
"electron-native-screenshare",
"esbuild",
"sharp" "sharp"
], ],
"patchedDependencies": { "patchedDependencies": {
+16 -3
View File
@@ -14,14 +14,27 @@ files:
- "!**/node_modules/uiohook-napi/build/**" - "!**/node_modules/uiohook-napi/build/**"
- "!**/node_modules/uiohook-napi/build.bak/**" - "!**/node_modules/uiohook-napi/build.bak/**"
- "!**/node_modules/uiohook-napi/bin/**" - "!**/node_modules/uiohook-napi/bin/**"
# electron-native-screenshare has no prebuilds — build/Release/*.node is the
# only copy, so it must NOT be excluded the way uiohook-napi's is. asarUnpack
# below takes it out of the archive so the loader can find it.
asarUnpack: asarUnpack:
- "**/*.node" - "**/*.node"
npmRebuild: false npmRebuild: false
afterPack: ./scripts/afterPack.js afterPack: ./scripts/afterPack.js
publish: publish:
- provider: github # Updates are served from this fork's own Gitea, not from GitHub.
owner: TheZwiss #
repo: backspace # GitHub is only the build machine — it has the Windows runners the native
# audio module needs. Its repository is private, and electron-updater against
# a private GitHub repo would need a token shipped inside the app, which is a
# leaked token. Gitea serves release assets to anyone, so no credential ends
# up in the installer.
#
# The tag is fixed at `latest` on purpose: electron-updater fetches
# latest.yml before it knows which version exists, so the URL cannot contain
# a version. CI replaces that release's assets on every publish.
- provider: generic
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
protocols: protocols:
- name: Backspace - name: Backspace
schemes: schemes:
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@backspace/desktop", "name": "@backspace/desktop",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"description": "Backspace", "description": "Backspace",
@@ -18,9 +18,10 @@
"clean": "rm -rf dist dist-electron", "clean": "rm -rf dist dist-electron",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"postinstall": "electron-rebuild -f -w uiohook-napi || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\"" "postinstall": "electron-rebuild -f -w uiohook-napi,electron-native-screenshare || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
}, },
"dependencies": { "dependencies": {
"electron-native-screenshare": "^1.2.0",
"electron-updater": "^6.3.0", "electron-updater": "^6.3.0",
"uiohook-napi": "^1.5.5" "uiohook-napi": "^1.5.5"
}, },
+113 -1
View File
@@ -265,6 +265,102 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
} }
} }
// ─── Native system-audio capture ────────────────────────────────────────────
//
// Electron's own `audio: 'loopback'` captures the whole output mix, which
// includes this app playing everyone else's voices — so those voices went back
// out inside the screen share and every listener heard themselves. Not acoustic
// echo: it is a digital copy of the output, so headphones never helped.
//
// This module captures with process-level isolation instead: only the shared
// window (include mode), or everything except this app (exclude mode).
interface NativeAudioMeta {
sampleRate: number;
channels: number;
bitsPerSample: number;
isFloat: boolean;
}
interface NativeScreenShareAudio {
startCapture(processId?: number, isIncludeMode?: boolean, onData?: (data: Buffer, meta: NativeAudioMeta) => void): boolean;
stopCapture(): boolean;
getPidFromWindowHandle(windowHandle: number): number;
isAvailable(): boolean;
getLoadError(): string | null;
}
let nativeAudio: NativeScreenShareAudio | null = null;
try {
// Required lazily and defensively: a native module that fails to load must
// degrade to sharing without audio, never stop the app from starting.
nativeAudio = require('electron-native-screenshare') as NativeScreenShareAudio;
if (!nativeAudio.isAvailable()) {
console.warn('[Main:ScreenShare] Native audio unavailable:', nativeAudio.getLoadError());
nativeAudio = null;
}
} catch (err) {
console.warn('[Main:ScreenShare] Native audio module missing:', err);
nativeAudio = null;
}
let nativeAudioActive = false;
/**
* Windows and Linux hand desktopCapturer ids of the form `window:<handle>:<n>`.
* Recovering the handle lets us capture only that window's audio, which is
* better than excluding ourselves: a game's sound goes out, the rest of the
* desktop does not.
*/
function windowHandleFromSourceId(sourceId: string): number | null {
const match = /^window:(\d+)/.exec(sourceId);
if (!match) return null;
const handle = Number(match[1]);
return Number.isFinite(handle) && handle > 0 ? handle : null;
}
function startNativeAudioCapture(sourceId: string): boolean {
if (!nativeAudio || nativeAudioActive) return false;
let targetPid = process.pid;
let includeMode = false;
const handle = windowHandleFromSourceId(sourceId);
if (handle !== null) {
const pid = nativeAudio.getPidFromWindowHandle(handle);
// pid 0 means the handle did not resolve; fall back to excluding ourselves
// rather than capturing nothing.
if (pid > 0) {
targetPid = pid;
includeMode = true;
}
}
try {
const started = nativeAudio.startCapture(targetPid, includeMode, (data, meta) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('native-audio-data', data, meta);
});
nativeAudioActive = started;
console.log('[Main:ScreenShare] Native audio', started ? 'started' : 'failed',
includeMode ? `(only pid ${targetPid})` : '(excluding self)');
return started;
} catch (err) {
console.error('[Main:ScreenShare] Native audio start failed:', err);
nativeAudioActive = false;
return false;
}
}
function stopNativeAudioCapture(): void {
if (!nativeAudio || !nativeAudioActive) return;
try {
nativeAudio.stopCapture();
} catch (err) {
console.warn('[Main:ScreenShare] Native audio stop failed:', err);
}
nativeAudioActive = false;
}
// ─── Tray Icon ────────────────────────────────────────────────────────────── // ─── Tray Icon ──────────────────────────────────────────────────────────────
function generateFallbackTrayIcon(): Electron.NativeImage { function generateFallbackTrayIcon(): Electron.NativeImage {
@@ -594,6 +690,10 @@ function registerIpcHandlers(): void {
ipcMain.handle('get-app-version', () => app.getVersion()); ipcMain.handle('get-app-version', () => app.getVersion());
// Screen share picker coordination (used by setDisplayMediaRequestHandler) // Screen share picker coordination (used by setDisplayMediaRequestHandler)
ipcMain.on('native-audio-stop', () => {
stopNativeAudioCapture();
});
ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => { ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => {
// Handled via ipcMain.once in the display media handler — this is just // Handled via ipcMain.once in the display media handler — this is just
// a safety net to prevent unhandled-message warnings // a safety net to prevent unhandled-message warnings
@@ -953,7 +1053,19 @@ if (!gotTheLock) {
// `PulseaudioLoopbackForScreenShare` feature flag we enable above. // `PulseaudioLoopbackForScreenShare` feature flag we enable above.
// Fails on PipeWire-only systems without pulse compat — the // Fails on PipeWire-only systems without pulse compat — the
// renderer catches that and toasts the user. // renderer catches that and toasts the user.
callback({ video: selected, ...(shareAudio ? { audio: 'loopback' } : {}) }); // Audio no longer rides on the Electron stream: `loopback` would put
// this app's own output (everyone else's voices) back into the share.
// The native module captures it separately, isolated by process, and
// the renderer turns it into the track LiveKit publishes.
if (shareAudio) {
const started = startNativeAudioCapture(sourceId);
if (!started) {
// Tell the renderer so it can say the share is going out silently,
// instead of the user assuming sound is included.
mainWindow?.webContents.send('native-audio-unavailable');
}
}
callback({ video: selected });
} catch (err) { } catch (err) {
console.error('[Main:ScreenShare] Handler error:', err); console.error('[Main:ScreenShare] Handler error:', err);
// @ts-ignore — deny the request without crashing // @ts-ignore — deny the request without crashing
+18
View File
@@ -65,6 +65,24 @@ contextBridge.exposeInMainWorld('backspace', {
onScreenShareSources: (callback: (sources: unknown[]) => void) => { onScreenShareSources: (callback: (sources: unknown[]) => void) => {
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources)); ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
}, },
// Raw PCM from the native capture. Arrives ~50x/second; the renderer turns it
// into a MediaStreamTrack for LiveKit.
onNativeAudioData: (callback: (data: ArrayBuffer, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => void) => {
const listener = (_event: unknown, data: Uint8Array, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => {
// Copied out of the transferred buffer: reusing it across IPC messages
// would let a later chunk overwrite one still being read.
callback(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer, meta);
};
ipcRenderer.on('native-audio-data', listener);
return () => ipcRenderer.off('native-audio-data', listener);
},
onNativeAudioUnavailable: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on('native-audio-unavailable', listener);
return () => ipcRenderer.off('native-audio-unavailable', listener);
},
stopNativeAudio: () => ipcRenderer.send('native-audio-stop'),
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => { selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true); ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
}, },
+13 -6
View File
@@ -110,14 +110,21 @@ interface SpotifyTrack {
} | null; } | null;
} }
/** Maps Spotify's payload onto the Activity shape the profile card renders. */ /**
* Maps Spotify's payload onto the Activity shape the profile card renders.
*
* A paused track is still reported, marked `paused`. Returning null for it made
* the block disappear on every pause — and, together with the silent gap
* between two songs, produced the flicker of it vanishing and coming back.
*/
function toActivity(track: SpotifyTrack): Activity | null { function toActivity(track: SpotifyTrack): Activity | null {
if (!track.is_playing || !track.item) return null; if (!track.item) return null;
const now = Date.now(); const now = Date.now();
const progress = track.progress_ms ?? 0; const progress = track.progress_ms ?? 0;
return { return {
type: 'listening', type: 'listening',
name: 'Spotify', name: 'Spotify',
paused: !track.is_playing,
details: track.item.name, details: track.item.name,
state: track.item.artists.map((a) => a.name).join(', '), state: track.item.artists.map((a) => a.name).join(', '),
timestamps: { start: now - progress, end: now - progress + track.item.duration_ms }, timestamps: { start: now - progress, end: now - progress + track.item.duration_ms },
@@ -192,16 +199,16 @@ export async function spotifyRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/connections/spotify/now-playing', { preHandler: authenticate }, async (request, reply) => { app.get('/api/connections/spotify/now-playing', { preHandler: authenticate }, async (request, reply) => {
const token = await getAccessToken(request.userId); const token = await getAccessToken(request.userId);
if (!token) return reply.code(200).send({ activity: null, connected: false }); if (!token) return reply.code(200).send({ activity: null, connected: false, serverTime: Date.now() });
const res = await fetch(SPOTIFY_NOW_PLAYING, { headers: { Authorization: `Bearer ${token}` } }); const res = await fetch(SPOTIFY_NOW_PLAYING, { headers: { Authorization: `Bearer ${token}` } });
// 204 means "nothing playing"; anything else non-OK is a transient problem // 204 means "nothing playing"; anything else non-OK is a transient problem
// and must not be reported as a lost connection. // and must not be reported as a lost connection.
if (res.status === 204) return reply.code(200).send({ activity: null, connected: true }); if (res.status === 204) return reply.code(200).send({ activity: null, connected: true, serverTime: Date.now() });
if (!res.ok) return reply.code(200).send({ activity: null, connected: res.status !== 401 }); if (!res.ok) return reply.code(200).send({ activity: null, connected: res.status !== 401, serverTime: Date.now() });
const track = await res.json() as SpotifyTrack; const track = await res.json() as SpotifyTrack;
return reply.code(200).send({ activity: toActivity(track), connected: true }); return reply.code(200).send({ activity: toActivity(track), connected: true, serverTime: Date.now() });
}); });
app.delete('/api/connections/spotify', { preHandler: authenticate }, async (request, reply) => { app.delete('/api/connections/spotify', { preHandler: authenticate }, async (request, reply) => {
+4
View File
@@ -474,6 +474,10 @@ function validateActivities(raw: unknown): Activity[] | null {
if (ts.start !== undefined || ts.end !== undefined) activity.timestamps = ts; if (ts.start !== undefined || ts.end !== undefined) activity.timestamps = ts;
} }
// Preserved through validation: without it the paused flag is stripped on
// its way to everyone else, and the block resumes ticking on their screens.
if (obj.paused === true) activity.paused = true;
if (obj.assets && typeof obj.assets === 'object') { if (obj.assets && typeof obj.assets === 'object') {
const aObj = obj.assets as Record<string, unknown>; const aObj = obj.assets as Record<string, unknown>;
const assets: ActivityAssets = {}; const assets: ActivityAssets = {};
+4 -1
View File
@@ -1118,7 +1118,7 @@ class ConnectionManager {
if (connections.size === 0) return; if (connections.size === 0) return;
const readyData = buildReadyPayload(userId); const readyData = buildReadyPayload(userId);
const message = JSON.stringify({ type: 'ready', ...readyData }); const message = JSON.stringify({ type: 'ready', serverTime: Date.now(), ...readyData });
for (const ws of connections) { for (const ws of connections) {
if (ws.readyState === 1) { if (ws.readyState === 1) {
ws.send(message); ws.send(message);
@@ -1792,6 +1792,9 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
const readyData = buildReadyPayload(userId); const readyData = buildReadyPayload(userId);
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: 'ready', type: 'ready',
// Lets each client measure its own offset from this server, so activity
// timestamps computed here render correctly on a machine whose clock drifts.
serverTime: Date.now(),
...readyData, ...readyData,
})); }));
+7 -1
View File
@@ -369,6 +369,12 @@ export interface ActivityAssets {
export interface Activity { export interface Activity {
type: ActivityType; type: ActivityType;
name: string; name: string;
/**
* Playback is paused. Kept as a state rather than dropping the activity:
* pausing a track used to remove the block entirely, so it vanished and
* reappeared on every pause and every gap between songs.
*/
paused?: boolean;
details?: string; details?: string;
state?: string; state?: string;
timestamps?: ActivityTimestamps; timestamps?: ActivityTimestamps;
@@ -427,7 +433,7 @@ export type ClientEvent =
// Server → Client Events // Server → Client Events
export type ServerEvent = export type ServerEvent =
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceRoomStarts?: Record<string, number>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number } | { type: 'ready'; serverTime?: number; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceRoomStarts?: Record<string, number>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
| { type: 'message_created'; message: MessageWithUser } | { type: 'message_created'; message: MessageWithUser }
| { type: 'message_updated'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser }
| { type: 'message_deleted'; messageId: string; channelId: string } | { type: 'message_deleted'; messageId: string; channelId: string }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -328,7 +328,7 @@ export class BackspaceApiClient {
readonly spotify: { readonly spotify: {
status: () => Promise<{ configured: boolean; connected: boolean }>; status: () => Promise<{ configured: boolean; connected: boolean }>;
authorizeUrl: () => Promise<{ url: string }>; authorizeUrl: () => Promise<{ url: string }>;
nowPlaying: () => Promise<{ activity: Activity | null; connected: boolean }>; nowPlaying: () => Promise<{ activity: Activity | null; connected: boolean; serverTime?: number }>;
disconnect: () => Promise<void>; disconnect: () => Promise<void>;
}; };
@@ -764,7 +764,7 @@ export class BackspaceApiClient {
this.spotify = { this.spotify = {
status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'), status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'),
authorizeUrl: () => request<{ url: string }>('GET', '/connections/spotify/authorize'), authorizeUrl: () => request<{ url: string }>('GET', '/connections/spotify/authorize'),
nowPlaying: () => request<{ activity: Activity | null; connected: boolean }>('GET', '/connections/spotify/now-playing'), nowPlaying: () => request<{ activity: Activity | null; connected: boolean; serverTime?: number }>('GET', '/connections/spotify/now-playing'),
disconnect: () => request<void>('DELETE', '/connections/spotify'), disconnect: () => request<void>('DELETE', '/connections/spotify'),
}; };
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import type { Activity } from '@backspace/shared'; import type { Activity } from '@backspace/shared';
import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
import { useT, type TranslationKey } from '../../i18n'; import { useT, type TranslationKey } from '../../i18n';
import { serverNow } from '../../utils/serverTime';
interface ProfileActivityProps { interface ProfileActivityProps {
activities: Activity[]; activities: Activity[];
@@ -39,13 +40,21 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
const start = primary?.timestamps?.start; const start = primary?.timestamps?.start;
const end = primary?.timestamps?.end; const end = primary?.timestamps?.end;
// Re-render once a second only while there is a clock to advance. const paused = primary?.paused === true;
const [now, setNow] = useState(() => Date.now());
// Ticks only while something is actually advancing: a paused track kept
// counting until the next poll, so the bar walked past where the listener
// had stopped.
const [now, setNow] = useState(() => serverNow());
useEffect(() => { useEffect(() => {
if (!start) return; if (!start || paused) return;
const id = setInterval(() => setNow(Date.now()), 1000); const id = setInterval(() => setNow(serverNow()), 1000);
return () => clearInterval(id); return () => clearInterval(id);
}, [start]); }, [start, paused]);
// Recompute once when playback resumes or the track changes, so the frozen
// value is not what gets drawn.
useEffect(() => { setNow(serverNow()); }, [start, paused]);
if (!primary || primary.type === 'custom') return null; if (!primary || primary.type === 'custom') return null;
@@ -19,6 +19,12 @@ export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPop
const [sounds, setSounds] = useState<SoundboardSound[]>([]); const [sounds, setSounds] = useState<SoundboardSound[]>([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
// Two-step add: pick the file, then name it in a field right here. The first
// version asked with window.prompt, which Electron does not implement — it
// returned nothing and the flow aborted in silence, so adding a sound worked
// in the browser and did nothing at all in the desktop app.
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingName, setPendingName] = useState('');
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -50,28 +56,41 @@ export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPop
// including the server's refusal when the cooldown is still running. // including the server's refusal when the cooldown is still running.
const play = (soundId: string) => wsSend({ type: 'soundboard_play', soundId }); const play = (soundId: string) => wsSend({ type: 'soundboard_play', soundId });
const handleFile = async (file: File) => { const pickFile = (file: File) => {
setError(''); setError('');
if (file.size > MAX_SOUND_BYTES) { if (file.size > MAX_SOUND_BYTES) {
setError(t('soundboard.tooLarge')); setError(t('soundboard.tooLarge'));
return; return;
} }
const name = window.prompt(t('soundboard.namePrompt'), file.name.replace(/\.[^.]+$/, '')); setPendingFile(file);
if (!name) return; setPendingName(file.name.replace(/\.[^.]+$/, '').slice(0, 32));
};
const cancelPending = () => {
setPendingFile(null);
setPendingName('');
if (fileRef.current) fileRef.current.value = '';
};
const confirmPending = async () => {
const file = pendingFile;
const name = pendingName.trim();
if (!file || !name) return;
setUploading(true); setUploading(true);
setError('');
try { try {
const tid = await useTransferStore.getState().startUpload(file, { tray: false }); const tid = await useTransferStore.getState().startUpload(file, { tray: false });
const { filename } = await waitForTransferAttachment(tid); const { filename } = await waitForTransferAttachment(tid);
const created = await api.soundboard.add(spaceId, name, filename); const created = await api.soundboard.add(spaceId, name, filename);
setSounds((prev) => [...prev, created]); setSounds((prev) => [...prev, created]);
cancelPending();
} catch { } catch {
// Distinct from the size check above: reporting every failure as "too // Distinct from the size check above: reporting every failure as "too
// large" sends people to shrink a file that was never the problem. // large" sends people to shrink a file that was never the problem.
setError(t('soundboard.uploadFailed')); setError(t('soundboard.uploadFailed'));
} finally { } finally {
setUploading(false); setUploading(false);
if (fileRef.current) fileRef.current.value = '';
} }
}; };
@@ -111,7 +130,7 @@ export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPop
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) void handleFile(file); if (file) pickFile(file);
}} }}
/> />
</> </>
@@ -120,6 +139,46 @@ export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPop
{error && <div className="text-[11px] text-txt-danger mb-2">{error}</div>} {error && <div className="text-[11px] text-txt-danger mb-2">{error}</div>}
{pendingFile && (
<div className="mb-2 p-2 rounded-lg bg-surface-elevated/60">
<label className="block text-[11px] text-txt-tertiary mb-1">
{t('soundboard.namePrompt')}
</label>
<input
type="text"
value={pendingName}
maxLength={32}
autoFocus
onChange={(e) => setPendingName(e.target.value)}
onKeyDown={(e) => {
// Scoped here so Enter does not reach the composer behind the popover.
e.stopPropagation();
if (e.key === 'Enter' && pendingName.trim()) void confirmPending();
if (e.key === 'Escape') cancelPending();
}}
className="input-search w-full mb-2"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => void confirmPending()}
disabled={uploading || !pendingName.trim()}
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-accent-primary text-white disabled:opacity-50"
>
{uploading ? t('soundboard.adding') : t('soundboard.confirm')}
</button>
<button
type="button"
onClick={cancelPending}
disabled={uploading}
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-interactive-muted text-txt-secondary disabled:opacity-50"
>
{t('soundboard.cancel')}
</button>
</div>
</div>
)}
{sounds.length === 0 ? ( {sounds.length === 0 ? (
<p className="text-[12px] text-txt-tertiary py-2">{t('soundboard.empty')}</p> <p className="text-[12px] text-txt-tertiary py-2">{t('soundboard.empty')}</p>
) : ( ) : (
+53 -5
View File
@@ -1,11 +1,27 @@
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import type { Activity } from '@backspace/shared';
import { api } from '../api/client'; import { api } from '../api/client';
import { useActivityStore } from '../stores/activityStore'; import { useActivityStore } from '../stores/activityStore';
import { setServerTime } from '../utils/serverTime';
/** While a track is playing. Short enough that a track change shows up quickly. */ /** Ceiling between checks while a track is playing. */
const POLL_CONNECTED_MS = 20_000; const POLL_CONNECTED_MS = 20_000;
/** While the account is not linked — cheap heartbeat that notices a new link. */ /** While the account is not linked — cheap heartbeat that notices a new link. */
const POLL_IDLE_MS = 60_000; const POLL_IDLE_MS = 60_000;
/** Never hammer the API, however close the track end looks. */
const MIN_POLL_MS = 4_000;
/**
* How long a silent answer is tolerated before the block is taken down.
*
* Spotify reports "nothing playing" in the gap between two songs, so clearing
* on the first empty answer made the block vanish and reappear between every
* track.
*/
const EMPTY_GRACE_MS = 25_000;
function trackKey(activity: Activity | null): string {
return activity ? `${activity.details ?? ''}|${activity.state ?? ''}` : '';
}
/** /**
* Publishes what the user is listening to on Spotify as an activity. * Publishes what the user is listening to on Spotify as an activity.
@@ -16,6 +32,8 @@ const POLL_IDLE_MS = 60_000;
*/ */
export function useSpotifyActivity(): void { export function useSpotifyActivity(): void {
const showActivity = useActivityStore((s) => s.showActivity); const showActivity = useActivityStore((s) => s.showActivity);
const lastKeyRef = useRef('');
const emptySinceRef = useRef(0);
useEffect(() => { useEffect(() => {
const setSource = useActivityStore.getState().setSourceActivities; const setSource = useActivityStore.getState().setSourceActivities;
@@ -35,16 +53,46 @@ export function useSpotifyActivity(): void {
// Polling a hidden tab burns Spotify's rate limit for a screen nobody // Polling a hidden tab burns Spotify's rate limit for a screen nobody
// is looking at; the next visible tick catches up. // is looking at; the next visible tick catches up.
if (typeof document === 'undefined' || !document.hidden) { if (typeof document === 'undefined' || !document.hidden) {
const { activity, connected } = await api.spotify.nowPlaying(); const { activity, connected, serverTime } = await api.spotify.nowPlaying();
if (cancelled) return; if (cancelled) return;
setSource('spotify', activity ? [activity] : []); if (serverTime) setServerTime(serverTime);
delay = connected ? POLL_CONNECTED_MS : POLL_IDLE_MS;
if (activity) {
emptySinceRef.current = 0;
const key = trackKey(activity);
// A new track goes out at once; progress-only updates can wait for
// the debounce, which is what it is there for.
const immediate = key !== lastKeyRef.current;
lastKeyRef.current = key;
setSource('spotify', [activity], { immediate });
// Check back just after this track should end, rather than landing
// mid-song and showing everyone the previous one for another
// twenty seconds.
const end = activity.timestamps?.end;
const remaining = end ? end - Date.now() + 1_000 : POLL_CONNECTED_MS;
delay = Math.max(MIN_POLL_MS, Math.min(POLL_CONNECTED_MS, remaining));
} else if (connected) {
const now = Date.now();
if (!emptySinceRef.current) emptySinceRef.current = now;
if (now - emptySinceRef.current >= EMPTY_GRACE_MS) {
lastKeyRef.current = '';
setSource('spotify', []);
}
delay = MIN_POLL_MS;
} else {
lastKeyRef.current = '';
emptySinceRef.current = 0;
setSource('spotify', []);
delay = POLL_IDLE_MS;
}
} else { } else {
delay = POLL_CONNECTED_MS; delay = POLL_CONNECTED_MS;
} }
} catch { } catch {
// Network hiccup or a logged-out session: keep the last known state and // Network hiccup or a logged-out session: keep the last known state and
// retry, rather than reporting "stopped listening" on a transient error. // retry, rather than reporting "stopped listening" on a transient error.
delay = POLL_CONNECTED_MS;
} }
if (!cancelled) timer = setTimeout(() => void tick(), delay); if (!cancelled) timer = setTimeout(() => void tick(), delay);
}; };
+3
View File
@@ -12,6 +12,7 @@ import type { ServerEvent, ClientEvent, ActiveCallInfo, Activity, User } from '@
import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls'; import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls';
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice'; import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice';
import { applySpaceVoiceState } from '../utils/voiceStateSync'; import { applySpaceVoiceState } from '../utils/voiceStateSync';
import { setServerTime } from '../utils/serverTime';
import { sortDmChannels } from '../utils/dmSorting'; import { sortDmChannels } from '../utils/dmSorting';
import { registerSelfId } from '../utils/identity'; import { registerSelfId } from '../utils/identity';
import { getActiveRoom } from './useLiveKit'; import { getActiveRoom } from './useLiveKit';
@@ -281,6 +282,8 @@ function handleEvent(origin: string, event: ServerEvent): void {
} }
} }
if (event.serverTime) setServerTime(event.serverTime);
// Clear voice state only for the reconnecting origin before repopulating // Clear voice state only for the reconnecting origin before repopulating
clearVoiceUsersForOrigin(origin); clearVoiceUsersForOrigin(origin);
if (event.voiceStates) { if (event.voiceStates) {
+2
View File
@@ -40,6 +40,8 @@ export const en = {
'soundboard.adding': 'Uploading...', 'soundboard.adding': 'Uploading...',
'soundboard.remove': 'Remove', 'soundboard.remove': 'Remove',
'soundboard.namePrompt': 'Name for this sound', 'soundboard.namePrompt': 'Name for this sound',
'soundboard.confirm': 'Add',
'soundboard.cancel': 'Cancel',
'soundboard.joinFirst': 'Join a voice channel to use the soundboard.', 'soundboard.joinFirst': 'Join a voice channel to use the soundboard.',
'soundboard.tooLarge': 'Sound must be under 2 MB and a few seconds long.', 'soundboard.tooLarge': 'Sound must be under 2 MB and a few seconds long.',
'soundboard.uploadFailed': 'Could not upload that file. Try a different one.', 'soundboard.uploadFailed': 'Could not upload that file. Try a different one.',
+2
View File
@@ -39,6 +39,8 @@ export const ptBR: Partial<Dictionary> = {
'soundboard.adding': 'Enviando...', 'soundboard.adding': 'Enviando...',
'soundboard.remove': 'Remover', 'soundboard.remove': 'Remover',
'soundboard.namePrompt': 'Nome deste som', 'soundboard.namePrompt': 'Nome deste som',
'soundboard.confirm': 'Adicionar',
'soundboard.cancel': 'Cancelar',
'soundboard.joinFirst': 'Entre num canal de voz para usar o soundboard.', 'soundboard.joinFirst': 'Entre num canal de voz para usar o soundboard.',
'soundboard.tooLarge': 'O som precisa ter menos de 2 MB e poucos segundos.', 'soundboard.tooLarge': 'O som precisa ter menos de 2 MB e poucos segundos.',
'soundboard.uploadFailed': 'Não foi possível enviar esse arquivo. Tente outro.', 'soundboard.uploadFailed': 'Não foi possível enviar esse arquivo. Tente outro.',
+5
View File
@@ -61,6 +61,11 @@ interface BackspaceElectronAPI {
// Screen share picker coordination // Screen share picker coordination
onScreenShareSources: (callback: (sources: ElectronScreenSource[]) => void) => void; onScreenShareSources: (callback: (sources: ElectronScreenSource[]) => void) => void;
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => void; selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => void;
onNativeAudioData?: (
callback: (data: ArrayBuffer, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => void,
) => () => void;
onNativeAudioUnavailable?: (callback: () => void) => () => void;
stopNativeAudio?: () => void;
// Instance URL management // Instance URL management
getInstanceUrl: () => Promise<string | null>; getInstanceUrl: () => Promise<string | null>;
+15 -3
View File
@@ -15,7 +15,7 @@ interface ActivityState {
initActivities: (activityMap: Record<string, Activity[]>) => void; initActivities: (activityMap: Record<string, Activity[]>) => void;
setShowActivity: (show: boolean) => void; setShowActivity: (show: boolean) => void;
pushActivities: (activities: Activity[]) => void; pushActivities: (activities: Activity[]) => void;
setSourceActivities: (source: string, activities: Activity[]) => void; setSourceActivities: (source: string, activities: Activity[], opts?: { immediate?: boolean }) => void;
reset: () => void; reset: () => void;
} }
@@ -85,13 +85,25 @@ export const useActivityStore = create<ActivityState>((set, get) => ({
}, 5000); }, 5000);
}, },
setSourceActivities: (source, activities) => { setSourceActivities: (source, activities, opts) => {
if (activities.length === 0) bySource.delete(source); if (activities.length === 0) bySource.delete(source);
else bySource.set(source, activities); else bySource.set(source, activities);
const merged = Array.from(bySource.values()) const merged = Array.from(bySource.values())
.flat() .flat()
.slice(0, ACTIVITY_LIMITS.MAX_ACTIVITIES_PER_USER); .slice(0, ACTIVITY_LIMITS.MAX_ACTIVITIES_PER_USER);
get().pushActivities(merged);
if (!opts?.immediate) {
get().pushActivities(merged);
return;
}
// Skips the 5s debounce. That delay exists to coalesce a chatty producer,
// but a track change happens once every few minutes and stacking it on top
// of the poll interval is what made everyone else see the previous song.
if (!get().showActivity) return;
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
set({ myActivities: merged });
wsSendAll({ type: 'activity_update', activities: merged });
}, },
reset: () => { reset: () => {
+160
View File
@@ -0,0 +1,160 @@
import { Room, Track, LocalAudioTrack, AudioPresets } from 'livekit-client';
import { isElectron } from '../platform/platform';
/**
* Turns the desktop app's native system-audio capture into a track LiveKit can
* publish.
*
* The native module hands the main process raw PCM, which arrives here in
* chunks over IPC. Web Audio has no "push samples" input, so each chunk is
* scheduled as a buffer source on a running cursor — the standard way to play a
* live stream without gaps.
*
* Exists because Electron's `audio: 'loopback'` captures the whole output mix,
* this app's own playback included, so every listener heard themselves echo.
*/
class NativeScreenAudio {
private ctx: AudioContext | null = null;
private destination: MediaStreamAudioDestinationNode | null = null;
private gain: GainNode | null = null;
private limiter: DynamicsCompressorNode | null = null;
private track: LocalAudioTrack | null = null;
private unsubscribe: (() => void) | null = null;
private nextStartTime = 0;
/**
* Held ahead of the cursor so a late chunk does not land in the past and get
* dropped. Small enough not to be noticeable against the video.
*/
private static readonly BUFFER_AHEAD_S = 0.08;
/** Beyond this the stream has stalled; resync rather than drift forever. */
private static readonly MAX_DRIFT_S = 0.5;
/**
* WASAPI hands back the process's own mix, which sits far below the level a
* microphone track arrives at — listeners had to run the participant at 200%
* to hear anything. Boosted here rather than asking every listener to
* compensate, with a limiter after it so loud passages do not clip.
*/
private static readonly BOOST = 3.0;
isActive(): boolean {
return this.track !== null;
}
async start(room: Room): Promise<boolean> {
if (!isElectron() || !window.backspace?.onNativeAudioData) return false;
if (this.track) return true;
this.ctx = new AudioContext();
this.destination = this.ctx.createMediaStreamDestination();
// source → gain → limiter → destination. The limiter is a compressor with
// a high ratio and fast attack: it only engages on peaks the boost would
// otherwise clip, and is transparent below that.
this.gain = this.ctx.createGain();
this.gain.gain.value = NativeScreenAudio.BOOST;
this.limiter = this.ctx.createDynamicsCompressor();
this.limiter.threshold.value = -6;
this.limiter.knee.value = 0;
this.limiter.ratio.value = 20;
this.limiter.attack.value = 0.003;
this.limiter.release.value = 0.1;
this.gain.connect(this.limiter);
this.limiter.connect(this.destination);
this.nextStartTime = 0;
this.unsubscribe = window.backspace.onNativeAudioData((data, meta) => {
this.enqueue(data, meta);
});
const mediaTrack = this.destination.stream.getAudioTracks()[0];
if (!mediaTrack) {
await this.stop();
return false;
}
this.track = new LocalAudioTrack(mediaTrack);
try {
await room.localParticipant.publishTrack(this.track, {
source: Track.Source.ScreenShareAudio,
// Defaults are tuned for speech: mono, low bitrate, and DTX on, which
// stops transmitting during silence. On game and music audio that
// reads as clipped and thin.
audioPreset: AudioPresets.musicHighQualityStereo,
forceStereo: true,
dtx: false,
red: false,
});
return true;
} catch (err) {
console.error('[NativeScreenAudio] publish failed', err);
await this.stop();
return false;
}
}
private enqueue(data: ArrayBuffer, meta: { sampleRate: number; channels: number; isFloat: boolean; bitsPerSample: number }): void {
const ctx = this.ctx;
const destination = this.destination;
if (!ctx || !destination) return;
// Only float32 is handled: it is what every supported platform reports.
// Anything else is dropped rather than played as noise.
if (!meta.isFloat || meta.bitsPerSample !== 32) return;
const samples = new Float32Array(data);
const channels = Math.max(1, meta.channels);
const frames = Math.floor(samples.length / channels);
if (frames === 0) return;
const buffer = ctx.createBuffer(channels, frames, meta.sampleRate);
for (let channel = 0; channel < channels; channel++) {
const channelData = buffer.getChannelData(channel);
// Interleaved in, planar out. The bounds are computed from the buffer's
// own length, but the index signature is still optional under
// noUncheckedIndexedAccess — a truncated final chunk reads as silence
// rather than NaN, which would click.
for (let frame = 0; frame < frames; frame++) {
channelData[frame] = samples[frame * channels + channel] ?? 0;
}
}
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(this.gain ?? destination);
const now = ctx.currentTime;
if (this.nextStartTime < now || this.nextStartTime > now + NativeScreenAudio.MAX_DRIFT_S) {
this.nextStartTime = now + NativeScreenAudio.BUFFER_AHEAD_S;
}
source.start(this.nextStartTime);
this.nextStartTime += buffer.duration;
}
async stop(): Promise<void> {
this.unsubscribe?.();
this.unsubscribe = null;
window.backspace?.stopNativeAudio?.();
if (this.track) {
try {
this.track.stop();
} catch { /* already gone */ }
this.track = null;
}
this.gain = null;
this.limiter = null;
this.destination = null;
if (this.ctx) {
try {
await this.ctx.close();
} catch { /* already closed */ }
this.ctx = null;
}
this.nextStartTime = 0;
}
}
export const nativeScreenAudio = new NativeScreenAudio();
+17 -1
View File
@@ -1,5 +1,7 @@
import { Room, Track, BackupCodecPolicy } from 'livekit-client'; import { Room, Track, BackupCodecPolicy } from 'livekit-client';
import { useVoiceStore } from '../stores/voiceStore'; import { useVoiceStore } from '../stores/voiceStore';
import { nativeScreenAudio } from './nativeScreenAudio';
import { isElectron } from '../platform/platform';
import type { ScreenShareConfig } from '../stores/voiceStore'; import type { ScreenShareConfig } from '../stores/voiceStore';
import { getStreamingLimits } from '../stores/settingsStore'; import { getStreamingLimits } from '../stores/settingsStore';
import { getPublisherPC, getMediaStreamTrack } from './livekitInternals'; import { getPublisherPC, getMediaStreamTrack } from './livekitInternals';
@@ -253,7 +255,10 @@ export async function startScreenShare(room: Room): Promise<boolean> {
try { try {
// For native mode: omit resolution constraint to capture at display's full native resolution // For native mode: omit resolution constraint to capture at display's full native resolution
const captureOptions: any = { const captureOptions: any = {
audio: config.shareAudio ? { // In the desktop app audio never comes through this stream: Electron
// ignores these constraints and its loopback would carry our own output
// back into the share. The native capture below supplies it instead.
audio: config.shareAudio && !isElectron() ? {
// Chrome 141+: exclude this tab's own audio from system audio capture // Chrome 141+: exclude this tab's own audio from system audio capture
// @ts-ignore — restrictOwnAudio is not yet in all TS type definitions // @ts-ignore — restrictOwnAudio is not yet in all TS type definitions
restrictOwnAudio: true, restrictOwnAudio: true,
@@ -287,6 +292,14 @@ export async function startScreenShare(room: Room): Promise<boolean> {
return false; return false;
} }
// Desktop only, and only after the video track exists: publishing the audio
// first would briefly show a share with sound and no picture. A failure
// here leaves the share running silently rather than tearing it down.
if (isElectron() && config.shareAudio) {
const ok = await nativeScreenAudio.start(room);
if (!ok) console.warn('[SS] native system audio unavailable — sharing without sound');
}
// Set content hint from builder (motion for gaming, detail for text) // Set content hint from builder (motion for gaming, detail for text)
const screenPub = room.localParticipant.getTrackPublications() const screenPub = room.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.ScreenShare); .find(p => p.source === Track.Source.ScreenShare);
@@ -416,6 +429,9 @@ function scheduleEncoderDetection(room: Room): void {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function stopScreenShare(room: Room): Promise<void> { export async function stopScreenShare(room: Room): Promise<void> {
// Stopped first: leaving the native capture running would keep reading system
// audio after the share is gone.
await nativeScreenAudio.stop();
try { try {
await room.localParticipant.setScreenShareEnabled(false); await room.localParticipant.setScreenShareEnabled(false);
} catch (err) { } catch (err) {
+19
View File
@@ -0,0 +1,19 @@
/**
* Offset between this machine's clock and the server's, in milliseconds.
*
* Activity timestamps (a Spotify track's start and end, for instance) are
* computed on the server and rendered here. A machine whose clock is a minute
* off would draw the progress bar a minute out of place — or past the end of
* the track — with nothing obviously wrong on screen.
*/
let offsetMs = 0;
/** Called on every `ready`: the round trip is short enough to ignore. */
export function setServerTime(serverTime: number): void {
offsetMs = Date.now() - serverTime;
}
/** `Date.now()` as the server would report it. */
export function serverNow(): number {
return Date.now() - offsetMs;
}
+21 -2
View File
@@ -25,6 +25,9 @@ importers:
packages/desktop: packages/desktop:
dependencies: dependencies:
electron-native-screenshare:
specifier: ^1.2.0
version: 1.2.0
electron-updater: electron-updater:
specifier: ^6.3.0 specifier: ^6.3.0
version: 6.8.3 version: 6.8.3
@@ -876,11 +879,11 @@ packages:
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is' deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild-kit/esm-loader@2.6.5': '@esbuild-kit/esm-loader@2.6.5':
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
deprecated: 'Merged into tsx: https://tsx.is' deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild/aix-ppc64@0.19.12': '@esbuild/aix-ppc64@0.19.12':
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
@@ -2119,6 +2122,7 @@ packages:
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@vitejs/plugin-react@4.7.0': '@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
@@ -2158,6 +2162,7 @@ packages:
'@xmldom/xmldom@0.8.11': '@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
abbrev@1.1.1: abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
@@ -2953,6 +2958,11 @@ packages:
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
hasBin: true hasBin: true
electron-native-screenshare@1.2.0:
resolution: {integrity: sha512-O7m+/AeE0UWFq8Z18THGSrPHRIxYiYtjdMKtVhGcNRE/sUN6Fj9EgXDOmmJnORCh303NWaNgmAnNz+JbeLa48Q==}
engines: {node: '>=18.0.0'}
os: [win32, linux, darwin]
electron-publish@25.1.7: electron-publish@25.1.7:
resolution: {integrity: sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==} resolution: {integrity: sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==}
@@ -4184,6 +4194,9 @@ packages:
node-addon-api@1.7.2: node-addon-api@1.7.2:
resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-api-version@0.2.1: node-api-version@0.2.1:
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
@@ -8257,6 +8270,10 @@ snapshots:
- electron-builder-squirrel-windows - electron-builder-squirrel-windows
- supports-color - supports-color
electron-native-screenshare@1.2.0:
dependencies:
node-addon-api: 7.1.1
electron-publish@25.1.7: electron-publish@25.1.7:
dependencies: dependencies:
'@types/fs-extra': 9.0.13 '@types/fs-extra': 9.0.13
@@ -9929,6 +9946,8 @@ snapshots:
node-addon-api@1.7.2: node-addon-api@1.7.2:
optional: true optional: true
node-addon-api@7.1.1: {}
node-api-version@0.2.1: node-api-version@0.2.1:
dependencies: dependencies:
semver: 7.7.4 semver: 7.7.4
+32
View File
@@ -0,0 +1,32 @@
# Geradores dos efeitos sonoros
Os `.ogg` em `packages/web/public/sounds/` para call e transmissão são
sintetizados por estes scripts — nenhum arquivo de terceiros, nenhuma questão
de licença num repositório público.
Os parâmetros não foram escolhidos a gosto: saíram da **medição** de dois sons
de referência fornecidos pelo dono da instância (análise de envelope, picos
espectrais e tempo de decaimento). O timbre resultante é fundamental + oitava
quase na mesma intensidade, com pouco acima disso:
| Harmônico | Proporção medida |
|---|---|
| 1x (fundamental) | 1.00 |
| 2x (oitava) | 0.85 |
| 3x | 0.10 |
| 4x | 0.02 |
Decaimento a 1/e: ~0,19 s. Sem reverberação.
| Script | Gera | Notas |
|---|---|---|
| `build.py` | `user_join`, `user_leave` | C4→G4 subindo / D4→G3 descendo |
| `stream.py` | `stream_user_joined`, `stream_user_left` | nota única (G4 / G3), ~55% do volume |
| `started4.py` | `stream_started` | acorde de dó + golpe grave + sopro de ar |
| `ended.py` | `stream_ended` | arpejo descendo, resolve na raiz grave |
Para regerar: `python3 <script>.py` produz `.wav`, depois converter com
`ffmpeg -i X.wav -c:a libvorbis -q:a 5 -ar 48000 X.ogg`.
Ajustes comuns: `HARM` muda o timbre, `tau` a duração, `step` a velocidade do
arpejo, e o alvo em `write()` o volume (pico RMS).
+47
View File
@@ -0,0 +1,47 @@
import math, wave, struct
SR=48000
# Proporcoes medidas nas referencias: fundamental, oitava, 3o, 4o
HARM=(1.00, 0.85, 0.10, 0.02)
TAU=0.19 # decaimento a 1/e medido
ATTACK=0.004
def note(f, dur):
n=int(SR*dur); o=[]
for i in range(n):
t=i/SR
s=sum(a*math.sin(2*math.pi*f*(k+1)*t) for k,a in enumerate(HARM))
o.append(s*min(1.0,t/ATTACK)*math.exp(-t/TAU))
return o
def mix(layers,total):
b=[0.0]*total
for off,s in layers:
for i,v in enumerate(s):
if off+i<total: b[off+i]+=v
return b
def peak_rms(x, win=int(SR*0.01)):
best=0.0
for i in range(0,len(x)-win,win):
seg=x[i:i+win]
best=max(best, math.sqrt(sum(v*v for v in seg)/len(seg)))
return best
def write(name, buf, target_rms):
cur=peak_rms(buf)
g=target_rms/max(1e-9,cur)
# nao deixa estourar mesmo casando o volume da referencia
pk=max(abs(s*g) for s in buf)
if pk>0.97: g*=0.97/pk
with wave.open(name,'w') as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR)
w.writeframes(b''.join(struct.pack('<h',int(max(-1,min(1,s*g))*32767)) for s in buf))
print(f"{name}: pico RMS {peak_rms([s*g for s in buf]):.3f}, {len(buf)/SR:.2f}s")
C4,D4,G3,G4 = 261.63, 293.66, 196.00, 392.00
GAP=int(SR*0.10) # 100ms entre as notas, medido
# join: quinta subindo — mesmas notas da referencia
write('user_join.wav', mix([(0,note(C4,0.55)),(GAP,note(G4,0.55))], int(SR*0.62)), 0.365)
# leave: quinta descendo
write('user_leave.wav', mix([(0,note(D4,0.55)),(GAP,note(G3,0.55))], int(SR*0.62)), 0.440)
+53
View File
@@ -0,0 +1,53 @@
import math, wave, struct
SR=48000
HARM=(1.00, 0.85, 0.10, 0.02)
def note(f, dur, tau, amp=1.0):
n=int(SR*dur); o=[]
for i in range(n):
t=i/SR
s=sum(a*math.sin(2*math.pi*f*(k+1)*t) for k,a in enumerate(HARM))
o.append(s*amp*min(1.0,t/0.004)*math.exp(-t/tau))
return o
def thump(f, dur, amp):
n=int(SR*dur); o=[]; ph=0.0
for i in range(n):
t=i/SR
ph += 2*math.pi*(f*(1+1.2*math.exp(-t/0.02)))/SR
o.append(math.sin(ph)*amp*math.exp(-t/(dur*0.30)))
return o
def mix(layers,total):
b=[0.0]*total
for off,s in layers:
for i,v in enumerate(s):
if off+i<total: b[off+i]+=v
return b
def peak_rms(x, win=int(SR*0.01)):
return max(math.sqrt(sum(v*v for v in x[i:i+win])/win) for i in range(0,len(x)-win,win))
def write(name, buf, target):
g=target/max(1e-9,peak_rms(buf))
pk=max(abs(s*g) for s in buf)
if pk>0.97: g*=0.97/pk
with wave.open(name,'w') as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR)
w.writeframes(b''.join(struct.pack('<h',int(max(-1,min(1,s*g))*32767)) for s in buf))
print(f"{name}: pico RMS {peak_rms([s*g for s in buf]):.3f}, {len(buf)/SR:.2f}s")
C3,C4,E4,G4,C5 = 130.81,261.63,329.63,392.00,523.25
step=int(SR*0.033)
# Espelho do inicio: o arpejo DESCE e resolve na raiz grave, que entra por
# ultimo. Sem o sopro de ar — brilho sugere chegada, nao encerramento.
# Mais curto e mais baixo: encerrar merece menos destaque que comecar.
write('stream_ended.wav', mix([
(0, note(C5, 0.42, 0.15, 0.70)),
(step, note(G4, 0.44, 0.16)),
(2*step, note(E4, 0.46, 0.17)),
(3*step, note(C4, 0.48, 0.19)),
(3*step, note(C3, 0.50, 0.21, 0.80)),
(3*step, thump(85, 0.13, 0.40)),
], int(SR*0.55)), 0.34)
+65
View File
@@ -0,0 +1,65 @@
import math, random, wave, struct
SR=48000; random.seed(11)
HARM=(1.00, 0.85, 0.10, 0.02)
def note(f, dur, tau, amp=1.0):
n=int(SR*dur); o=[]
for i in range(n):
t=i/SR
s=sum(a*math.sin(2*math.pi*f*(k+1)*t) for k,a in enumerate(HARM))
o.append(s*amp*min(1.0,t/0.004)*math.exp(-t/tau))
return o
def thump(f, dur, amp):
"""Instrumento 2: golpe grave com queda de tom. Da impacto no ataque e
morre em 90ms — engorda sem alongar."""
n=int(SR*dur); o=[]; ph=0.0
for i in range(n):
t=i/SR
ph += 2*math.pi*(f*(1+1.2*math.exp(-t/0.02)))/SR
o.append(math.sin(ph)*amp*math.exp(-t/(dur*0.30)))
return o
def air(dur, amp, cutoff=5000):
"""Instrumento 3: sopro curto de ruido filtrado. Brilho no inicio, sem cauda."""
n=int(SR*dur)
y=0.0; a=1-math.exp(-2*math.pi*cutoff/SR); o=[]
for i in range(n):
t=i/SR
y += a*(random.uniform(-1,1)-y)
o.append(y*amp*min(1.0,t/0.008)*math.exp(-t/(dur*0.22)))
return o
def mix(layers,total):
b=[0.0]*total
for off,s in layers:
for i,v in enumerate(s):
if off+i<total: b[off+i]+=v
return b
def peak_rms(x, win=int(SR*0.01)):
return max(math.sqrt(sum(v*v for v in x[i:i+win])/win) for i in range(0,len(x)-win,win))
def write(name, buf, target):
g=target/max(1e-9,peak_rms(buf))
pk=max(abs(s*g) for s in buf)
if pk>0.97: g*=0.97/pk
with wave.open(name,'w') as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR)
w.writeframes(b''.join(struct.pack('<h',int(max(-1,min(1,s*g))*32767)) for s in buf))
print(f"{name}: pico RMS {peak_rms([s*g for s in buf]):.3f}, {len(buf)/SR:.2f}s")
C3,G3,C4,E4,G4,C5 = 130.81,196.00,261.63,329.63,392.00,523.25
step=int(SR*0.033) # arpejo bem rapido: acorde montado em 100ms
# Decaimentos ~metade dos anteriores (0.20 vs 0.38). Total 0.62s vs 1.08s.
write('stream_started.wav', mix([
(0, thump(85, 0.14, 0.55)), # impacto
(0, air(0.13, 0.16)), # brilho
(0, note(C3, 0.60, 0.22, 0.80)),
(0, note(G3, 0.58, 0.20, 0.45)),
(0, note(C4, 0.56, 0.20)),
(step, note(E4, 0.54, 0.19)),
(2*step, note(G4, 0.52, 0.18)),
(3*step, note(C5, 0.50, 0.16, 0.70)),
], int(SR*0.62)), 0.42)
+31
View File
@@ -0,0 +1,31 @@
import math, wave, struct
SR=48000
HARM=(1.00, 0.85, 0.10, 0.02) # mesmo timbre medido nas referencias
ATTACK=0.004
def note(f, dur, tau):
n=int(SR*dur); o=[]
for i in range(n):
t=i/SR
s=sum(a*math.sin(2*math.pi*f*(k+1)*t) for k,a in enumerate(HARM))
o.append(s*min(1.0,t/ATTACK)*math.exp(-t/tau))
return o
def peak_rms(x, win=int(SR*0.01)):
return max(math.sqrt(sum(v*v for v in x[i:i+win])/win)
for i in range(0,len(x)-win,win))
def write(name, buf, target):
g=target/max(1e-9,peak_rms(buf))
pk=max(abs(s*g) for s in buf)
if pk>0.97: g*=0.97/pk
with wave.open(name,'w') as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR)
w.writeframes(b''.join(struct.pack('<h',int(max(-1,min(1,s*g))*32767)) for s in buf))
print(f"{name}: pico RMS {peak_rms([s*g for s in buf]):.3f}, {len(buf)/SR:.2f}s")
G3, G4 = 196.00, 392.00
# Decaimento mais curto (0.13 vs 0.19) e volume ~55% do som de call:
# entrar/sair da tela dispara muitas vezes durante uma transmissao.
write('stream_user_joined.wav', note(G4, 0.42, 0.13), 0.20)
write('stream_user_left.wav', note(G3, 0.42, 0.13), 0.20)