fix: three real sensor-lifecycle and honesty bugs (verified, not assumed)
From the audit whose verifier agents all died on session limits — so I
checked each claim myself rather than trusting it. One was WRONG and is
left alone; three were real.
REFUTED, deliberately unchanged: "the EMF support check is a false
positive, dead on iOS". The iOS gesture flow is correctly implemented
(EmfSensorListener.needsPermission/requestPermission) and the panel calls
it before start(). Nothing to fix; "fixing" it would have broken working
code.
1. Microphone never released when the panel unmounts mid-getUserMedia.
`this.stream` is only assigned after the await, so stop() during the
permission prompt found null and released nothing — then the promise
resolved, set running = true, and the mic went live *after* teardown,
staying on for the page's life with the recording indicator lit and an
orphaned rAF loop burning battery. Fixed with a generation counter that
makes the await cancellable. Proven: the new test fails without the
guard and passes with it (verified by reverting it).
2. Same bug class in the RTL-SDR panel: sdrRef.current is assigned after
requestDevice()+open(), so unmounting during the device picker left the
dongle claimed AND started a sweep against a dead component — only a
tab close would free it. Added a mountedRef check, mirroring the guard
EmfPanel already had.
3. EVP blamed the seeker for refusals that never happened. A bare
`catch {}` set "you refused the microphone" for every failure, so an
insecure http:// origin, a machine with no mic, and a mic held by
another app all told the user to go fix a permission that was never
denied. Now classified from the DOMException name into four honest
causes (denied / insecure / absent / busy), each with its own copy and
a working alternative, in both languages.
375 frontend tests pass; i18n parity gate passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,8 @@ const RULES = [
|
|||||||
]],
|
]],
|
||||||
['seance.conditions.veil.', ['thin', 'veiled', 'heavy']],
|
['seance.conditions.veil.', ['thin', 'veiled', 'heavy']],
|
||||||
['seance.views.', ['board', 'matrix', 'graphs']],
|
['seance.views.', ['board', 'matrix', 'graphs']],
|
||||||
|
// Base causes only — the checker derives the matching *Title keys itself.
|
||||||
|
['seance.evp.fail.', ['denied', 'insecure', 'absent', 'busy']],
|
||||||
['seance.entity.rarity.', RARITY],
|
['seance.entity.rarity.', RARITY],
|
||||||
['codex.rarity.', RARITY],
|
['codex.rarity.', RARITY],
|
||||||
['codex.sort.', ['recent', 'contacted']],
|
['codex.sort.', ['recent', 'contacted']],
|
||||||
|
|||||||
@@ -192,11 +192,19 @@
|
|||||||
"start": "open the microphone",
|
"start": "open the microphone",
|
||||||
"stop": "close the microphone",
|
"stop": "close the microphone",
|
||||||
"listening": "listening to the voice band…",
|
"listening": "listening to the voice band…",
|
||||||
"deniedTitle": "the microphone stays shut",
|
|
||||||
"denied": "This vessel refused the microphone. Grant audio permission in your browser's site settings to practice EVP — or turn to the ouija board or the Wire Ghost instead; the board needs no ear, and the wire only the network you already fear.",
|
|
||||||
"floor": "room floor",
|
"floor": "room floor",
|
||||||
"band": "voice band 300–3400 Hz",
|
"band": "voice band 300–3400 Hz",
|
||||||
"scopeLabel": "live audio spectrum from the microphone"
|
"scopeLabel": "live audio spectrum from the microphone",
|
||||||
|
"fail": {
|
||||||
|
"deniedTitle": "the microphone stays shut",
|
||||||
|
"denied": "This vessel refused the microphone. Grant audio permission in your browser's site settings to practice EVP — or turn to the ouija board or the Wire Ghost instead; the board needs no ear, and the wire only the network you already fear.",
|
||||||
|
"insecureTitle": "unconsecrated ground",
|
||||||
|
"insecure": "The dead only speak through a secured channel. Open this site by its https address — or over localhost — and the microphone will answer. Until then, the ouija board and the Wire Ghost need no ear at all.",
|
||||||
|
"absentTitle": "no ear to listen with",
|
||||||
|
"absent": "This vessel has no microphone the veil can find. Attach one and try again — or turn to the ouija board or the Wire Ghost, which hear by other means.",
|
||||||
|
"busyTitle": "the ear is already spoken for",
|
||||||
|
"busy": "Another rite already holds the microphone. Close whatever else is listening — a call, a recorder, another tab — then open it here again."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"radio": {
|
"radio": {
|
||||||
"start": "sweep the band",
|
"start": "sweep the band",
|
||||||
|
|||||||
@@ -192,11 +192,19 @@
|
|||||||
"start": "abrir el micrófono",
|
"start": "abrir el micrófono",
|
||||||
"stop": "cerrar el micrófono",
|
"stop": "cerrar el micrófono",
|
||||||
"listening": "escuchando la banda de voz…",
|
"listening": "escuchando la banda de voz…",
|
||||||
"deniedTitle": "el micrófono sigue cerrado",
|
|
||||||
"denied": "Este recipiente rechazó el micrófono. Concede el permiso de audio en los ajustes del sitio de tu navegador para practicar EVP — o acude al tablero ouija o al Fantasma del Cable; el tablero no necesita oído, y el cable solo la red que ya temes.",
|
|
||||||
"floor": "suelo de la sala",
|
"floor": "suelo de la sala",
|
||||||
"band": "banda de voz 300–3400 Hz",
|
"band": "banda de voz 300–3400 Hz",
|
||||||
"scopeLabel": "espectro de audio en vivo desde el micrófono"
|
"scopeLabel": "espectro de audio en vivo desde el micrófono",
|
||||||
|
"fail": {
|
||||||
|
"deniedTitle": "el micrófono permanece cerrado",
|
||||||
|
"denied": "Este recipiente rechazó el micrófono. Concede permiso de audio en los ajustes del navegador para practicar PVE — o acude al tablero ouija o al Fantasma del Cable; el tablero no necesita oído, y el cable solo la red que ya temes.",
|
||||||
|
"insecureTitle": "tierra sin consagrar",
|
||||||
|
"insecure": "Los muertos solo hablan por un canal seguro. Abre este sitio por su dirección https — o desde localhost — y el micrófono responderá. Hasta entonces, el tablero ouija y el Fantasma del Cable no necesitan oído alguno.",
|
||||||
|
"absentTitle": "ningún oído con que escuchar",
|
||||||
|
"absent": "Este recipiente no tiene micrófono que el velo pueda hallar. Conecta uno e inténtalo de nuevo — o acude al tablero ouija o al Fantasma del Cable, que oyen por otros medios.",
|
||||||
|
"busyTitle": "el oído ya está tomado",
|
||||||
|
"busy": "Otro rito ya sostiene el micrófono. Cierra lo que esté escuchando — una llamada, una grabadora, otra pestaña — y ábrelo aquí de nuevo."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"radio": {
|
"radio": {
|
||||||
"start": "barrer la banda",
|
"start": "barrer la banda",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
EVP_BASE_THRESHOLD_DB,
|
EVP_BASE_THRESHOLD_DB,
|
||||||
EVP_LISTENING_TOOL_THRESHOLD_DB,
|
EVP_LISTENING_TOOL_THRESHOLD_DB,
|
||||||
EvpDetectorCore,
|
EvpDetectorCore,
|
||||||
|
EvpListener,
|
||||||
evpThresholdDb,
|
evpThresholdDb,
|
||||||
} from './evp'
|
} from './evp'
|
||||||
import type { EvpAnomaly } from './evp'
|
import type { EvpAnomaly } from './evp'
|
||||||
@@ -204,3 +205,104 @@ describe('listening_tool threshold changes real detector behavior', () => {
|
|||||||
expect(anomaly!.magnitude).toBeCloseTo(6, 6)
|
expect(anomaly!.magnitude).toBeCloseTo(6, 6)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// --- microphone lifecycle -------------------------------------------------
|
||||||
|
|
||||||
|
describe('EvpListener microphone release', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A getUserMedia we control the timing of, plus track-stop spies. */
|
||||||
|
function stubMedia() {
|
||||||
|
const stopped: string[] = []
|
||||||
|
const tracks = [
|
||||||
|
{ stop: () => stopped.push('a') },
|
||||||
|
{ stop: () => stopped.push('b') },
|
||||||
|
]
|
||||||
|
let release: (() => void) | null = null
|
||||||
|
const pending = new Promise<MediaStream>((resolve) => {
|
||||||
|
release = () =>
|
||||||
|
resolve({ getTracks: () => tracks } as unknown as MediaStream)
|
||||||
|
})
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
mediaDevices: { getUserMedia: () => pending },
|
||||||
|
})
|
||||||
|
// AudioContext should never be constructed on an abandoned start, but
|
||||||
|
// provide one so a regression fails on the mic assertion rather than
|
||||||
|
// blowing up on a missing global.
|
||||||
|
vi.stubGlobal(
|
||||||
|
'AudioContext',
|
||||||
|
class {
|
||||||
|
sampleRate = 48000
|
||||||
|
state = 'running'
|
||||||
|
createMediaStreamSource() {
|
||||||
|
return { connect: () => undefined }
|
||||||
|
}
|
||||||
|
createAnalyser() {
|
||||||
|
return {
|
||||||
|
fftSize: 2048,
|
||||||
|
smoothingTimeConstant: 0,
|
||||||
|
frequencyBinCount: 1024,
|
||||||
|
getFloatFrequencyData: () => undefined,
|
||||||
|
connect: () => undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', () => undefined)
|
||||||
|
return { stopped, release: () => release!() }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('releases the microphone when stopped while getUserMedia is still pending', async () => {
|
||||||
|
// The real-world path: the seeker switches mode (or leaves) while the
|
||||||
|
// browser is still showing the permission prompt. Before the generation
|
||||||
|
// guard, stop() found this.stream === null, released nothing, and the
|
||||||
|
// mic went live *after* teardown — staying on for the page's lifetime.
|
||||||
|
const { stopped, release } = stubMedia()
|
||||||
|
const listener = new EvpListener()
|
||||||
|
|
||||||
|
const starting = listener.start({ onAnomaly: () => undefined })
|
||||||
|
await listener.stop() // unmount lands mid-prompt
|
||||||
|
release() // permission finally granted
|
||||||
|
await starting
|
||||||
|
|
||||||
|
expect(stopped).toHaveLength(2)
|
||||||
|
expect(listener.isRunning).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not start the render loop for an abandoned start', async () => {
|
||||||
|
const { release } = stubMedia()
|
||||||
|
const listener = new EvpListener()
|
||||||
|
let frames = 0
|
||||||
|
const starting = listener.start({
|
||||||
|
onAnomaly: () => undefined,
|
||||||
|
onFrame: () => {
|
||||||
|
frames++
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await listener.stop()
|
||||||
|
release()
|
||||||
|
await starting
|
||||||
|
expect(frames).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still runs normally when nobody interrupts it', async () => {
|
||||||
|
const { stopped, release } = stubMedia()
|
||||||
|
const listener = new EvpListener()
|
||||||
|
const starting = listener.start({ onAnomaly: () => undefined })
|
||||||
|
release()
|
||||||
|
await starting
|
||||||
|
|
||||||
|
expect(listener.isRunning).toBe(true)
|
||||||
|
expect(stopped).toHaveLength(0)
|
||||||
|
|
||||||
|
await listener.stop()
|
||||||
|
expect(stopped).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ export class EvpListener {
|
|||||||
private raf = 0
|
private raf = 0
|
||||||
private core: EvpDetectorCore | null = null
|
private core: EvpDetectorCore | null = null
|
||||||
private running = false
|
private running = false
|
||||||
|
/** Bumped by every start() and every stop(); lets a start() suspended on
|
||||||
|
* getUserMedia detect that it was abandoned. See start(). */
|
||||||
|
private generation = 0
|
||||||
|
|
||||||
get isRunning(): boolean {
|
get isRunning(): boolean {
|
||||||
return this.running
|
return this.running
|
||||||
@@ -196,7 +199,25 @@ export class EvpListener {
|
|||||||
|
|
||||||
async start(cb: EvpListenerCallbacks, opts: EvpListenerStartOptions = {}): Promise<void> {
|
async start(cb: EvpListenerCallbacks, opts: EvpListenerStartOptions = {}): Promise<void> {
|
||||||
if (this.running) return
|
if (this.running) return
|
||||||
|
|
||||||
|
// getUserMedia can take seconds (the browser may be showing a
|
||||||
|
// permission prompt), and the panel can unmount in that window — mode
|
||||||
|
// switch, leaving the séance, a remount. stop() called during the await
|
||||||
|
// could not release anything, because `this.stream` is only assigned
|
||||||
|
// once the promise resolves: the mic would go live *after* teardown and
|
||||||
|
// stay live for the rest of the page's life, with the browser's
|
||||||
|
// recording indicator on and an orphaned rAF loop still running.
|
||||||
|
//
|
||||||
|
// A generation counter makes the await cancellable: stop() bumps it, so
|
||||||
|
// when the promise finally resolves we can tell that this attempt was
|
||||||
|
// abandoned and hand the stream straight back instead of wiring it up.
|
||||||
|
const generation = ++this.generation
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
if (generation !== this.generation) {
|
||||||
|
stream.getTracks().forEach((t) => t.stop())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const ctx = new AudioContext()
|
const ctx = new AudioContext()
|
||||||
const src = ctx.createMediaStreamSource(stream)
|
const src = ctx.createMediaStreamSource(stream)
|
||||||
const analyser = ctx.createAnalyser()
|
const analyser = ctx.createAnalyser()
|
||||||
@@ -227,6 +248,9 @@ export class EvpListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
|
// Invalidates any start() currently suspended on getUserMedia, so a
|
||||||
|
// late-resolving stream is released rather than going live post-teardown.
|
||||||
|
this.generation++
|
||||||
this.running = false
|
this.running = false
|
||||||
cancelAnimationFrame(this.raf)
|
cancelAnimationFrame(this.raf)
|
||||||
this.stream?.getTracks().forEach((t) => t.stop())
|
this.stream?.getTracks().forEach((t) => t.stop())
|
||||||
|
|||||||
@@ -543,7 +543,11 @@ function EvpPanel() {
|
|||||||
const hasListeningTool = user?.unlocks?.includes('listening_tool') ?? false
|
const hasListeningTool = user?.unlocks?.includes('listening_tool') ?? false
|
||||||
const [listening, setListening] = useState(false)
|
const [listening, setListening] = useState(false)
|
||||||
const [pending, setPending] = useState(false)
|
const [pending, setPending] = useState(false)
|
||||||
const [denied, setDenied] = useState(false)
|
// Why the mic isn't open, so the notice can be truthful about the cause
|
||||||
|
// instead of always claiming the seeker refused it.
|
||||||
|
const [micFailure, setMicFailure] = useState<
|
||||||
|
'denied' | 'insecure' | 'absent' | 'busy' | null
|
||||||
|
>(null)
|
||||||
const [bandDb, setBandDb] = useState<number | null>(null)
|
const [bandDb, setBandDb] = useState<number | null>(null)
|
||||||
const [nyquist, setNyquist] = useState(24000)
|
const [nyquist, setNyquist] = useState(24000)
|
||||||
const [markers, setMarkers] = useState<ScopeAnomaly[]>([])
|
const [markers, setMarkers] = useState<ScopeAnomaly[]>([])
|
||||||
@@ -569,7 +573,7 @@ function EvpPanel() {
|
|||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
if (pending || listening) return
|
if (pending || listening) return
|
||||||
setDenied(false)
|
setMicFailure(null)
|
||||||
setPending(true)
|
setPending(true)
|
||||||
const listener = listenerRef.current ?? new EvpListener()
|
const listener = listenerRef.current ?? new EvpListener()
|
||||||
listenerRef.current = listener
|
listenerRef.current = listener
|
||||||
@@ -628,9 +632,26 @@ function EvpPanel() {
|
|||||||
}
|
}
|
||||||
setListening(true)
|
setListening(true)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
if (listenerRef.current === listener) listenerRef.current = null
|
if (listenerRef.current === listener) listenerRef.current = null
|
||||||
setDenied(true)
|
// Blaming the seeker for a refusal that never happened sends them to
|
||||||
|
// fix the wrong thing. getUserMedia's DOMException names distinguish
|
||||||
|
// the real causes, so each one gets truthful, actionable copy:
|
||||||
|
// NotAllowedError is a genuine denial; NotFoundError means there's no
|
||||||
|
// microphone at all; NotReadableError means another app holds it; and
|
||||||
|
// on a bare http:// origin mediaDevices is simply absent.
|
||||||
|
const name = err instanceof DOMException ? err.name : ''
|
||||||
|
const noMediaApi =
|
||||||
|
typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia
|
||||||
|
setMicFailure(
|
||||||
|
noMediaApi || name === 'SecurityError'
|
||||||
|
? 'insecure'
|
||||||
|
: name === 'NotFoundError' || name === 'OverconstrainedError'
|
||||||
|
? 'absent'
|
||||||
|
: name === 'NotReadableError' || name === 'AbortError'
|
||||||
|
? 'busy'
|
||||||
|
: 'denied',
|
||||||
|
)
|
||||||
setBandDb(null)
|
setBandDb(null)
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false)
|
setPending(false)
|
||||||
@@ -677,10 +698,10 @@ function EvpPanel() {
|
|||||||
{t('seance.evp.listening')}
|
{t('seance.evp.listening')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{denied && (
|
{micFailure && (
|
||||||
<div className="panel-notice error">
|
<div className="panel-notice error">
|
||||||
<h4>{t('seance.evp.deniedTitle')}</h4>
|
<h4>{t(`seance.evp.fail.${micFailure}Title`)}</h4>
|
||||||
<p>{t('seance.evp.denied')}</p>
|
<p>{t(`seance.evp.fail.${micFailure}`)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -716,9 +737,15 @@ function RadioPanel() {
|
|||||||
const lastTuneUiRef = useRef(0)
|
const lastTuneUiRef = useRef(0)
|
||||||
const sonifierRef = useRef(new SpectrumSonifier())
|
const sonifierRef = useRef(new SpectrumSonifier())
|
||||||
|
|
||||||
|
// False once this panel unmounts, so an open sequence still awaiting the
|
||||||
|
// device picker can tell it was abandoned — sdrRef alone can't cover that
|
||||||
|
// window, because it isn't assigned until after the awaits complete.
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
// Power down the dongle on unmount (mode switch / leaving the page).
|
// Power down the dongle on unmount (mode switch / leaving the page).
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
|
mountedRef.current = false
|
||||||
const sdr = sdrRef.current
|
const sdr = sdrRef.current
|
||||||
sdrRef.current = null
|
sdrRef.current = null
|
||||||
detectorRef.current = null
|
detectorRef.current = null
|
||||||
@@ -769,6 +796,15 @@ function RadioPanel() {
|
|||||||
void sdr.close()
|
void sdr.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// requestDevice() waits on the browser's device picker and open() runs
|
||||||
|
// ~20 sequential control transfers, so the seeker has ample time to
|
||||||
|
// switch modes or leave. Without this the dongle would be left claimed
|
||||||
|
// and sweeping forever against an unmounted panel, and could only be
|
||||||
|
// freed by closing the tab.
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
void sdr.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
sdrRef.current = sdr
|
sdrRef.current = sdr
|
||||||
detectorRef.current = new SpectrumAnomalyDetector()
|
detectorRef.current = new SpectrumAnomalyDetector()
|
||||||
setPhase('sweeping')
|
setPhase('sweeping')
|
||||||
|
|||||||
Reference in New Issue
Block a user