diff --git a/.env.example b/.env.example index d2f860d..40b0059 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Copy this file to .env and configure your MQTT feed. No map key or build is needed. # Keep .env and any optional .secrets files private. -CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.6.3 +CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.6.4 CARTOLITE_BIND_ADDR=127.0.0.1 CARTOLITE_PORT=8080 # Optional CARTO override: see compose.carto.yml and docs/deployment.md. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 669da3e..753a864 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,8 @@ concurrency: env: IMAGE_NAME: ghcr.io/n30nex/cartolite-server - # Exact published 0.6.2, in addition to the legacy 0.4.1 source migration below. - UPGRADE_FROM_IMAGE: ghcr.io/n30nex/cartolite-server@sha256:acb8a95101655cc0ded3971d1999390c5aff3dd276c97d3dc1f44dbfd408331d + # Exact published 0.6.3, in addition to the legacy 0.4.1 source migration below. + UPGRADE_FROM_IMAGE: ghcr.io/n30nex/cartolite-server@sha256:2d4c247e1d5245cfaac0e5e70e489ebe215edc3d735f4e6f601aa8ed084071cd jobs: backend: diff --git a/CHANGELOG.md b/CHANGELOG.md index a897e58..fa19e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.6.4 - Dial-up packet sound + +- Add **Dial-up** under Sound → Voice → Signals & percussion: a short modem carrier, answer sweep and scratchy handshake chatter. +- Expand the shared Map and Netgraph catalog to 31 voices, with explicit preview and saved selection. +- Preserve opt-in sound, volume, visible-hop timing, stereo positioning and one oscillator per hop. No samples, downloads or audio dependencies. +- Extend Actions audio checks and retain a synthetic Dial-up WAV for listening review. +- Update the Go build/runtime to 1.26.9 and x/net to 0.60.0 for the October HTTP/TLS security fixes, retaining the existing image scan gate. + ## 0.6.3 - Reliability and ingest performance - Replace full-table node identity scans on every update with an incremental index; retain known positions across coordinate-free region aliases and prevent stale alias events from moving nodes backwards. diff --git a/Dockerfile b/Dockerfile index ac86e4c..4587d82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY web/ ./ # Published images contain no operator or CI browser credentials. RUN npm run build -FROM --platform=$BUILDPLATFORM golang:1.25.13-bookworm AS go-build +FROM --platform=$BUILDPLATFORM golang:1.26.9-bookworm AS go-build ARG APP_VERSION=dev ARG GIT_SHA=unknown ARG BUILD_TIME=unknown diff --git a/README.md b/README.md index eb085b1..64b5885 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ CartoLite Server renders public MeshCore activity as a living map: packet trails | **Live topology** | Vector geography, six route styles, packet-type heat, clusters, terrain and desktop 3D buildings. | | **Make it yours** | Shared Map/Netgraph themes, preset and advanced line controls, readable light/dark palettes, and saved display choices. | | **Find and inspect** | Search downloaded node labels, open node details, and browse neighbours sorted by last heard. | -| **Musical traffic** | A grouped choice of [30 sound voices](docs/sound-voices.md), using opt-in native browser audio and visible live hops only. | +| **Musical traffic** | A grouped choice of [31 sound voices](docs/sound-voices.md), using opt-in native browser audio and visible live hops only. | | **Works worldwide** | Accept valid coordinates anywhere, or apply an exact region allowlist for a shared broker. | | **Responsive and resilient** | Desktop and phone layouts, Live Follow, saved views, and automatic recovery after sleep or network loss. | | **Small operational footprint** | One dependency-light Go service, one atomic checkpoint, no database, and a hardened container. | diff --git a/VERSION b/VERSION index 844f6a9..d2b13eb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.3 +0.6.4 diff --git a/backend/go.mod b/backend/go.mod index 718d1eb..40f6517 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,11 +1,11 @@ module github.com/n30nex/cartolite-server/backend -go 1.25.13 +go 1.26.9 require github.com/eclipse/paho.mqtt.golang v1.5.1 require ( github.com/gorilla/websocket v1.5.3 // indirect - golang.org/x/net v0.56.0 // indirect + golang.org/x/net v0.60.0 // indirect golang.org/x/sync v0.22.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 2bca15d..0f17efd 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -2,7 +2,7 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= diff --git a/compose.yml b/compose.yml index 0fcc608..d9e9181 100644 --- a/compose.yml +++ b/compose.yml @@ -2,7 +2,7 @@ name: cartolite-server services: cartolite: - image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.6.3} + image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.6.4} restart: unless-stopped ports: - "${CARTOLITE_BIND_ADDR:-127.0.0.1}:${CARTOLITE_PORT:-8080}:8080" diff --git a/docs/sound-and-animation.md b/docs/sound-and-animation.md index c1e1a07..9572cc9 100644 --- a/docs/sound-and-animation.md +++ b/docs/sound-and-animation.md @@ -1,6 +1,6 @@ # Sound and animation -CartoLite offers [30 packet sound voices](sound-voices.md) using native Web Audio with no samples or audio dependency. All 30 sound voices use cached `PeriodicWave` definitions with stable packet, route, hop, and scene variation. Every route hop crossing the current viewport schedules one articulation and one oscillator. Off-screen hops and observer-only traffic stay silent. +CartoLite offers [31 packet sound voices](sound-voices.md) using native Web Audio with no samples or audio dependency. All 31 sound voices use cached `PeriodicWave` definitions with stable packet, route, hop, and scene variation. Every route hop crossing the current viewport schedules one articulation and one oscillator. Off-screen hops and observer-only traffic stay silent. Sound is opt-in. A browser gesture is always required to create or resume output, including after mobile sleep. The panel shows On, Off, or Tap to Resume and stores only `{enabled, volume, scene}` locally. diff --git a/docs/sound-voices.md b/docs/sound-voices.md index 8a9441c..0369c94 100644 --- a/docs/sound-voices.md +++ b/docs/sound-voices.md @@ -1,6 +1,6 @@ # Packet sound voices -CartoLite provides **30 synthesized, instrument-inspired sound voices** using native Web Audio, with no samples, downloads, or audio dependency. Choose **Sound → Voice** on Map or Netgraph. The native selector groups the library by character and the panel explains the selected sound. +CartoLite provides **31 synthesized sound voices** using native Web Audio, with no samples, downloads, or audio dependency. Choose **Sound → Voice** on Map or Netgraph. The native selector groups the library by character and the panel explains the selected sound. | Group | Voices | |---|---| @@ -8,10 +8,12 @@ CartoLite provides **30 synthesized, instrument-inspired sound voices** using na | Keys & mallets | Felt Piano, Electric Piano, Marimba, Kalimba, Vibraphone, Celesta, Music Box | | Strings & bells | Harp, Koto, Dulcimer, Glass Bells, Temple Bells, Steel Pan | | Synths & air | Soft Organ, Reed, Flute, Choir, Velvet Pad, Analog Pluck, 8-bit | -| Signals & percussion | Sonar, Radar, Bubble, Droplet, Soft Kick, Tom, Rim Click | +| Signals & percussion | Sonar, Radar, Dial-up, Bubble, Droplet, Soft Kick, Tom, Rim Click | Aurora, Wood, and Chimes retain their existing profiles and saved identifiers. The new voices vary cached harmonic tables, attack/sustain/release, note-length caps, register, pitch bends, filter shape/sweep, and a dry or lightly echoed finish. These are synthesized interpretations rather than sampled acoustic instruments or speech. Packet kind still controls the musical scale, and each scene keeps deterministic packet/route/hop variation. Selecting a voice never enables sound or plays an unsolicited preview. The existing enabled state and 0–100% volume remain local to the browser and are shared across views. All voices keep one oscillator per visible route hop, the same hop starts and stereo positioning, pause/hidden-tab muting, mobile gesture unlock, and silence for off-screen or observer-only activity. The catalog is shared by synthesis and the picker. GitHub Actions validates every voice with native `OfflineAudioContext` rendering: finite, audible, distinct output; conservative peaks; one oscillator; and a silent tail after completion. Synthetic browser journeys cover saved choices and the visible controls. These checks do not claim physical phone-speaker or headphone testing. + +**Dial-up** adds a short modem-style carrier, answer sweep and rapid data chatter. It uses bounded frequency automation on the existing oscillator, fits within each hop's envelope, and keeps the same start time and stereo position. Choose it under **Signals & percussion**, then use **Preview voice** to hear it without enabling live sound. It is a synthesized impression of a handshake, not a recording or decoded packet data. diff --git a/web/e2e/audio-voices.spec.ts b/web/e2e/audio-voices.spec.ts index 241e4b4..93f82f6 100644 --- a/web/e2e/audio-voices.spec.ts +++ b/web/e2e/audio-voices.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { createServer } from 'vite'; import { resolve } from 'node:path'; +import { visualFixture } from './visualFixtures'; test('renders audible, distinct, finite output for every packet voice', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'desktop', 'The native offline renderer is independent of viewport size.'); @@ -19,7 +20,7 @@ test('renders audible, distinct, finite output for every packet voice', async ({ const address = server.httpServer!.address(); if (!address || typeof address === 'string') throw new Error('Audio harness did not bind a local port'); await page.goto('http://127.0.0.1:' + address.port + '/audio-check'); - const report = await page.evaluate(async () => { + const { report, dialupPCM } = await page.evaluate(async () => { const audioPath = '/src/audio.ts'; const catalogPath = '/src/soundScenes.ts'; const { RouteSonifier } = await import(audioPath) as typeof import('../src/audio'); @@ -44,6 +45,7 @@ test('renders audible, distinct, finite output for every packet voice', async ({ } Object.defineProperty(window, 'AudioContext', { configurable: true, value: RenderContext }); const reports = []; + let dialupPCM: number[] = []; try { for (const scene of SOUND_SCENE_IDS) { const storage = { getItem: () => null, setItem: () => {} } as unknown as Storage; @@ -59,6 +61,9 @@ test('renders audible, distinct, finite output for every packet voice', async ({ const buffer = await context.startRendering(); await new Promise((done) => setTimeout(done, 0)); const samples = buffer.getChannelData(0); + if (scene === 'dial-up') { + dialupPCM = Array.from(samples.subarray(0, 48_000), (value) => Math.round(Math.max(-1, Math.min(1, value)) * 32_767)); + } let peak = 0, energy = 0, tailPeak = 0, signature = 2166136261; let finite = true; for (let i = 0; i < samples.length; i += 1) { @@ -75,11 +80,21 @@ test('renders audible, distinct, finite output for every packet voice', async ({ } finally { Object.defineProperty(window, 'AudioContext', { configurable: true, value: original }); } - return reports; + return { report: reports, dialupPCM }; }); + // Retain the exact native-rendered synthetic hop for listening review. + const wav = Buffer.alloc(44 + dialupPCM.length * 2); + wav.write('RIFF', 0); wav.writeUInt32LE(wav.length - 8, 4); + wav.write('WAVEfmt ', 8); wav.writeUInt32LE(16, 16); + wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); + wav.writeUInt32LE(48_000, 24); wav.writeUInt32LE(96_000, 28); + wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34); + wav.write('data', 36); wav.writeUInt32LE(dialupPCM.length * 2, 40); + dialupPCM.forEach((value, index) => wav.writeInt16LE(value, 44 + index * 2)); + await testInfo.attach('dial-up-hop', { body: wav, contentType: 'audio/wav' }); await testInfo.attach('voice-measurements', { body: JSON.stringify(report, null, 2), contentType: 'application/json' }); - expect(report).toHaveLength(30); - expect(new Set(report.map((voice) => voice.signature)).size).toBe(30); + expect(report).toHaveLength(31); + expect(new Set(report.map((voice) => voice.signature)).size).toBe(31); for (const voice of report) { expect(voice.finite, voice.scene).toBe(true); expect(voice.count, voice.scene).toBe(1); @@ -94,22 +109,24 @@ test('renders audible, distinct, finite output for every packet voice', async ({ } }); -test('shares the larger voice library and saved selection between map and Netgraph', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'desktop', 'Responsive sound controls are covered by the full map and Netgraph journeys.'); +test('shares the larger voice library and saved selection between map and Netgraph', async ({ page }) => { + await visualFixture(page); await page.goto('/'); await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle'); await page.locator('#sound-button').click(); const picker = page.getByRole('combobox', { name: 'Sound voice', exact: true }); - await expect(picker.locator('option')).toHaveCount(30); + await expect(picker.locator('option')).toHaveCount(31); await expect(picker.locator('optgroup')).toHaveCount(5); - await picker.selectOption('electric-piano'); - await expect(page.locator('#sound-button')).toHaveAttribute('title', 'Sound off — Electric Piano · 80%'); - await expect(page.locator('#sound-description')).toContainText('metallic attack'); + await picker.selectOption('dial-up'); + await expect(page.locator('#sound-button')).toHaveAttribute('title', 'Sound off — Dial-up · 80%'); + await expect(page.locator('#sound-description')).toContainText('handshake'); + await page.getByRole('button', { name: 'Preview voice', exact: true }).click(); + await expect(page.locator('#sound-toggle')).toHaveText('Turn sound on'); await page.goto('/netgraph/'); await expect(page.locator('#connected-count')).not.toHaveText('—'); await page.locator('#sound-button').click(); - await expect(picker).toHaveValue('electric-piano'); - await expect(picker.locator('option')).toHaveCount(30); + await expect(picker).toHaveValue('dial-up'); + await expect(picker.locator('option')).toHaveCount(31); await picker.selectOption('sonar'); await expect(page.locator('#sound-description')).toContainText('underwater'); await page.goto('/'); diff --git a/web/index.html b/web/index.html index 8732aee..e8b4d40 100644 --- a/web/index.html +++ b/web/index.html @@ -209,7 +209,7 @@