mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 17:05:53 +08:00
fix(voice-pool): avalanche hash bits before pool modulo
fnv1a's low bits barely mix: for the live-atc persona keys (shared session prefix, ':TYPE:freq' suffix) hash % 4 was identical for Delivery, Ground and Tower in 100% of sessions — every position spoke with one voice. A murmur3 fmix32 finalizer before the modulo restores uniform assignment; verified in the browser (three stations, three voices, three paces). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,23 @@ export function fnv1a(input: string): number {
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Murmur3 finalizer (fmix32). FNV-1a's low bits barely avalanche: for the
|
||||
* live-atc persona keys (shared session prefix, `:TYPE:freq` suffix) the raw
|
||||
* hash mod 4 was identical for every position of a session — one voice for
|
||||
* Delivery, Ground and Tower, 100% of the time. Mixing before the modulo
|
||||
* makes every input bit reach the low bits.
|
||||
*/
|
||||
export function mix32(hash: number): number {
|
||||
let h = hash >>> 0
|
||||
h ^= h >>> 16
|
||||
h = Math.imul(h, 0x85ebca6b) >>> 0
|
||||
h ^= h >>> 13
|
||||
h = Math.imul(h, 0xc2b6ae35) >>> 0
|
||||
h ^= h >>> 16
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a voice from `pool` for `key`, skipping anything in `reserved`.
|
||||
* Walks forward from the hashed index so a collision with a reserved voice
|
||||
@@ -46,7 +63,7 @@ export function voiceFromPool(
|
||||
const usable = pool.filter(v => !reserved.includes(v))
|
||||
const candidates = usable.length ? usable : pool
|
||||
if (!candidates.length) throw new Error('voiceFromPool: empty voice pool')
|
||||
return candidates[fnv1a(key) % candidates.length]!
|
||||
return candidates[mix32(fnv1a(key)) % candidates.length]!
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +100,7 @@ export function controllerPersonaFor(positionKey: string): ControllerPersona {
|
||||
const key = positionKey.toUpperCase()
|
||||
const voice = voiceFromPool(key, CONTROLLER_VOICES, [])
|
||||
// Independent hash stream for speed so voice and pace don't correlate.
|
||||
const baseSpeed = 1.1 + (fnv1a(`speed:${key}`) % 21) / 100
|
||||
const baseSpeed = 1.1 + (mix32(fnv1a(`speed:${key}`)) % 21) / 100
|
||||
return { voice, baseSpeed: Math.round(baseSpeed * 100) / 100 }
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,26 @@ describe('voicePool — controller personas', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not hand every position of a session the same voice (FNV low-bit bias)', () => {
|
||||
// Real /live-atc key shapes: shared session prefix, position+frequency
|
||||
// suffix. Raw fnv1a % poolSize collapses here — every position got the
|
||||
// same voice in 100% of sessions.
|
||||
let allSame = 0
|
||||
const sessions = 200
|
||||
for (let i = 0; i < sessions; i++) {
|
||||
const s = `sess${i}-${(i * 2654435761 % 1e9).toString(36)}`
|
||||
const voices = [
|
||||
`${s}:FRANKFURT MAIN:DEL:122.035`,
|
||||
`${s}:FRANKFURT MAIN:GND:121.805`,
|
||||
`${s}:FRANKFURT MAIN:TWR:118.780`,
|
||||
].map(k => controllerPersonaFor(k).voice)
|
||||
if (voices[0] === voices[1] && voices[1] === voices[2]) allSame++
|
||||
}
|
||||
// Uniform hashing would give ~6.3% — allow slack, but the 100% failure
|
||||
// mode must never come back.
|
||||
assert.ok(allSame / sessions < 0.2, `${allSame}/${sessions} sessions had one voice for all positions`)
|
||||
})
|
||||
|
||||
it('gives different sessions a different shift of controllers', () => {
|
||||
const keys = ['EDDF:DEL', 'EDDF:GND', 'EDDF:TWR', 'EDDF:APP']
|
||||
const shiftA = keys.map(k => controllerPersonaFor(`sessionA:${k}`))
|
||||
|
||||
Reference in New Issue
Block a user