feat: cm Add audio encodeur generator
Some checks failed
Deploy Production Server / if_merged (pull_request) Failing after 34s

This commit is contained in:
Thibaut Valentin 2026-03-09 13:06:05 +01:00
parent 812d873d5d
commit ee0a7d87e9
6 changed files with 640 additions and 408 deletions

File diff suppressed because it is too large Load Diff

View File

@ -13,42 +13,42 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0", "@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.1.1", "@fortawesome/react-fontawesome": "^3.2.0",
"axios": "^1.13.2", "axios": "^1.13.6",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"i18next": "^25.8.0", "i18next": "^25.8.14",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"jspdf": "^4.1.0", "jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"obs-websocket-js": "^5.0.7", "obs-websocket-js": "^5.0.7",
"proj4": "^2.20.2", "proj4": "^2.20.3",
"react": "^19.2.3", "react": "^19.2.4",
"react-dom": "^19.2.3", "react-dom": "^19.2.4",
"react-i18next": "^16.5.3", "react-i18next": "^16.5.5",
"react-is": "^19.2.3", "react-is": "^19.2.4",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-loader-spinner": "^8.0.2", "react-loader-spinner": "^8.0.2",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.13.1",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"xlsx-js-style": "^1.2.0" "xlsx-js-style": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.9", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.2", "eslint": "^10.0.2",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.5.2",
"vite": "^7.3.1" "vite": "^7.3.1"
} }
} }

View File

@ -0,0 +1,84 @@
class ProcessorDTMF extends AudioWorkletProcessor {
constructor() {
super();
this.sampleRate = sampleRate;
this.symbolDuration = 0.03; // 50 ms par symbole
this.samplesPerSymbol = Math.floor(this.sampleRate * this.symbolDuration);
this.encodeLowPrio = [];
this.symbolSamples = [];
this.lastBlackStep = 0;
this.port.onmessage = (e) => {
if (e.data.type === 'encode') {
this.symbolSamples.push(...this.encodeSymbols(e.data.symbols));
this.symbolSamples.push(...this.encodeBlack(this.sampleRate * 0.02));
}
if (e.data.type === 'encodeLowPrio') {
this.encodeLowPrio.push(e.data.symbols);
}
};
}
dtmfFrequencies = [
[697, 770, 852, 941], // Fréquences basses
[1209, 1336, 1477, 1633] // Fréquences hautes
];
encodeSymbols(symbols) {
const samples = [];
for (const symbol of symbols) {
const lf = this.dtmfFrequencies[0][symbol % 4]; // Fréquence basse
const hf = this.dtmfFrequencies[1][Math.floor(symbol / 4)]; // Fréquence haute
// console.log(`Symbol: ${symbol}, LF: ${lf} Hz, HF: ${hf} Hz`);
for (let i = 0; i < this.samplesPerSymbol; i++) {
const t = i / this.sampleRate;
const t2 = (this.lastBlackStep + i) / this.sampleRate;
samples.push(0.5 * Math.sin(2 * Math.PI * lf * t) + 0.5 * Math.sin(2 * Math.PI * hf * t) // Signal DTMF
+ Math.sin(2 * Math.PI * 150 * t2) * (0.0625 * (Math.sin(2 * Math.PI * 0.5 * t2) + 1))); // Ajouter un signal à 150 Hz pour le "black"
}
this.lastBlackStep += this.samplesPerSymbol;
// ajouter un silence de 10 ms entre les symboles
samples.push(...this.encodeBlack(this.sampleRate * 0.01)); // Silence
}
return samples;
}
encodeBlack(size) {
const samples = [];
for (let i = 0; i < size; i++) {
const t = (this.lastBlackStep + i) / this.sampleRate;
samples.push(Math.sin(2 * Math.PI * 150 * t) * (0.0625 * (Math.sin(2 * Math.PI * 0.5 * t) + 1))); // Signal à 350 Hz pour le "black"
}
this.lastBlackStep += size;
this.lastBlackStep %= this.sampleRate * 2; // Réinitialiser tous les 2 secondes pour éviter les débordements
return samples;
}
process(inputs, outputs, parameters) {
const output = outputs[0]; // output est un tableau de canaux (ex: [Float32Array, ...])
const channelData = output[0]; // Accéder au premier canal (mono)
if (this.symbolSamples.length === 0 && this.encodeLowPrio.length > 0) {
this.symbolSamples.push(...this.encodeSymbols(this.encodeLowPrio.shift()));
this.symbolSamples.push(...this.encodeBlack(this.sampleRate * 0.02));
}
if (this.symbolSamples.length === 0) {
const samples = this.encodeBlack(channelData.length)
for (let i = 0; i < channelData.length; i++) {
channelData[i] = samples[i] || 0;
}
return true;
}
for (let i = 0; i < channelData.length; i++) {
channelData[i] = this.symbolSamples.shift() || 0; // Prendre le prochain échantillon ou 0 si vide
}
return true;
}
}
registerProcessor('dtmf-processor', ProcessorDTMF);

View File

@ -0,0 +1,207 @@
import React, {useEffect, useRef, useState} from 'react';
import {useTablesState} from "../../pages/competition/editor/StateWindow.jsx";
import {timePrint} from "../../utils/Tools.js";
let initialized = false;
const AudioEncoder = () => {
const audioContextRef = useRef(null);
const qpskProcessorRef = useRef(null);
const [isReady, setIsReady] = useState(false);
const [table, setTable] = useState('1');
const lastSend = useRef({id: 0});
const {state} = useTablesState();
// Initialisation de l'AudioContext et du AudioWorklet
useEffect(() => {
const initAudio = async () => {
if (initialized)
return;
console.log("Initialisation de l'audio après interaction utilisateur");
initialized = true;
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
console.log("AudioContext state:", audioContext.state);
if (audioContext.state === 'suspended') {
await audioContext.resume(); // Nécessaire pour démarrer le contexte audio
console.log("AudioContext resumed");
}
await audioContext.audioWorklet.addModule('/processor-dtmf.js');
await new Promise(r => setTimeout(r, 100));
const processor = new AudioWorkletNode(audioContext, 'dtmf-processor');
processor.connect(audioContext.destination);
qpskProcessorRef.current = processor;
audioContextRef.current = audioContext;
await new Promise(r => setTimeout(r, 100));
setIsReady(true);
} catch (err) {
initialized = false;
console.error("Erreur d'initialisation AudioWorklet:", err);
}
};
// Initialiser après un clic utilisateur (pour contourner les restrictions des navigateurs)
const handleUserInteraction = () => {
document.removeEventListener('click', handleUserInteraction);
initAudio();
};
document.addEventListener('click', handleUserInteraction);
return () => {
if (audioContextRef.current?.state !== 'closed') {
audioContextRef.current?.close();
}
};
}, []);
// Fonction pour encoder et envoyer un message
const encodeAndSend = (data, lowPrio = false) => {
if (!isReady) return;
const symbols = Array.from(data).flatMap(byte => [byte >> 4, byte & 0x0F]);
console.log("Bits :", symbols);
// 5. Envoyer les symboles au processeur audio
if (lowPrio) {
qpskProcessorRef.current.port.postMessage({type: 'encodeLowPrio', symbols});
} else {
qpskProcessorRef.current.port.postMessage({type: 'encode', symbols});
}
}
useEffect(() => {
const t = state.find(o => o.liceName === table)
if (!t)
return
// console.log("Data for table 1:", t, t.selectedMatch)
const last = lastSend.current;
if (t.selectedMatch !== last.id) {
clearTimeout(last.time_id)
last.time_id = setTimeout(() => {
last.time_id = null
if (t.selectedMatch === null) {
encodeAndSend(new Uint8Array([0, 0, 0, 0, 0, 0, 0]));
} else {
const data = [];
for (let i = 0; i < 7; i++) { // MaxSafeInteger est sur 7 bytes (53 bits de précision)
data.unshift(Number((BigInt(t.selectedMatch) >> BigInt(i * 8)) & 0xFFn))
}
data[0] = data[0] & 0x1F // 3 premiers bits à 0 pour différencier des autres types de messages (ex: score, chrono, etc.)
// console.log("Data to send (selectedMatch):", data)
encodeAndSend(new Uint8Array(data), false);
}
}, 250)
last.id = t.selectedMatch
}
const isRunning = (c) => c.startTime !== 0
const getTime = (c) => {
if (c.startTime === 0)
return c.time
return c.time + Date.now() - c.startTime
}
const timeStr = last.chronoState ? timePrint((last.chronoState.state === 2) ? last.chronoState.configPause : last.chronoState.configTime - getTime(last.chronoState)) : "-"
const timeStr2 = timePrint((t.chronoState.state === 2) ? t.chronoState.configPause : t.chronoState.configTime - getTime(t.chronoState))
if (timeStr !== timeStr2) {
clearInterval(lastSend.current.time_chronoInter)
clearTimeout(lastSend.current.time_chronoText)
lastSend.current.time_chronoText = setTimeout(() => {
let time = (t.chronoState.state === 2) ? t.chronoState.configPause : t.chronoState.configTime - getTime(t.chronoState)
const ms = time % 1000
time = (time - ms) / 1000
const data = [((time >> 8) & 0x1F) + 0x20, time & 0xFF];
// console.log("Data to send (time):", data)
encodeAndSend(new Uint8Array(data));
lastSend.current.time_chronoInter = setInterval(() => {
let time = (t.chronoState.state === 2) ? t.chronoState.configPause : t.chronoState.configTime - getTime(t.chronoState)
const ms = time % 1000
time = (time - ms) / 1000
const data = [((time >> 8) & 0x1F) + 0x20, time & 0xFF];
// console.log("Data to send (time-auto):", data)
encodeAndSend(new Uint8Array(data), true);
}, 10000);
}, 150)
}
if (!last.chronoState || last.chronoState.state !== t.chronoState.state || isRunning(last.chronoState) !== isRunning(t.chronoState)) {
let time = (t.chronoState.state === 2) ? t.chronoState.configPause : t.chronoState.configTime - getTime(t.chronoState)
const ms = Math.round((time % 1000) / 250)
const data = [0x40 + (t.chronoState.state << 3) + (isRunning(t.chronoState) << 2) + (ms & 0x03)];
// console.log("Data to send (chrono state):", data)
encodeAndSend(new Uint8Array(data));
}
last.chronoState = {...t.chronoState}
// console.log(timeStr, timeStr2)
// console.log(last.chronoState, t.chronoState)
if (last.scoreRouge !== t.scoreState.scoreRouge) {
clearTimeout(last.time_sr)
last.time_sr = setTimeout(() => {
if (last.scoreRouge !== t.scoreState.scoreRouge) {
const b = t.scoreState.scoreRouge < 0
const s = b ? -t.scoreState.scoreRouge : t.scoreState.scoreRouge
const data = [0x60 + (b << 3) + ((s >> 8) & 0x07), (s & 0xFF)];
console.log("Data to send (score r):", data)
encodeAndSend(new Uint8Array(data), true);
last.scoreRouge = t.scoreState.scoreRouge
}
}, 250)
}
if (last.scoreBleu !== t.scoreState.scoreBleu) {
clearTimeout(last.time_sb)
last.time_sb = setTimeout(() => {
if (last.scoreBleu !== t.scoreState.scoreBleu) {
const b = t.scoreState.scoreBleu < 0
const s = b ? -t.scoreState.scoreBleu : t.scoreState.scoreBleu
const data = [0x60 + 0x10 + (b << 3) + ((s >> 8) & 0x07), (s & 0xFF)];
console.log("Data to send (score b):", data)
encodeAndSend(new Uint8Array(data), true);
last.scoreBleu = t.scoreState.scoreBleu
}
}, 250)
}
}, [state])
useEffect(() => {
const last = lastSend.current;
clearTimeout(last.scoreBleu)
clearTimeout(last.scoreRouge)
clearTimeout(last.time_id)
clearTimeout(last.time_chronoText)
clearInterval(last.time_chronoInter)
last.id = 0
last.scoreBleu = 0
last.scoreRouge = 0
last.chronoState = null
}, [table])
return (
<div>
<input
type="text"
value={table}
onChange={(e) => setTable(e.target.value)}
placeholder="Nom de la zone"
/>
<span>{isReady ? 'Actif' : "Zone non configurée"}</span>
</div>
);
};
export default AudioEncoder;

View File

@ -11,6 +11,7 @@ import {AxiosError} from "../../../components/AxiosError.jsx";
import {useFetch} from "../../../hooks/useFetch.js"; import {useFetch} from "../../../hooks/useFetch.js";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {CardsProvider} from "../../../hooks/useCard.jsx"; import {CardsProvider} from "../../../hooks/useCard.jsx";
import AudioEncoder from "../../../components/cm/AudioEncoder.jsx";
const vite_url = import.meta.env.VITE_URL; const vite_url = import.meta.env.VITE_URL;
@ -73,6 +74,7 @@ function HomeComp() {
<Route path="/" element={<Home2 perm={perm}/>}/> <Route path="/" element={<Home2 perm={perm}/>}/>
<Route path="/admin" element={<CMAdmin compUuid={compUuid}/>}/> <Route path="/admin" element={<CMAdmin compUuid={compUuid}/>}/>
<Route path="/table" element={<CMTable/>}/> <Route path="/table" element={<CMTable/>}/>
<Route path="/view/audio" element={<AudioEncoder/>}/>
</Routes> </Routes>
</LoadingProvider> </LoadingProvider>
</CardsProvider> </CardsProvider>

View File

@ -19,7 +19,7 @@ function reducer(state, action) {
} }
} }
export function StateWindow({document}) { export function useTablesState() {
const {sendRequest, dispatch} = useWS(); const {sendRequest, dispatch} = useWS();
const [state, dispatchState] = useReducer(reducer, []) const [state, dispatchState] = useReducer(reducer, [])
@ -58,6 +58,12 @@ export function StateWindow({document}) {
} }
}, []) }, [])
return {state};
}
export function StateWindow({document}) {
const {state} = useTablesState();
document.title = "État des tables de marque"; document.title = "État des tables de marque";
document.body.className = "overflow-hidden"; document.body.className = "overflow-hidden";
@ -66,7 +72,7 @@ export function StateWindow({document}) {
<div className="d-flex flex-row flex-wrap justify-content-around align-items-center align-content-around h-100 p-2 overflow-auto"> <div className="d-flex flex-row flex-wrap justify-content-around align-items-center align-content-around h-100 p-2 overflow-auto">
{state.sort((a, b) => a.liceName.localeCompare(b.liceName)).map((table, index) => {state.sort((a, b) => a.liceName.localeCompare(b.liceName)).map((table, index) =>
<div key={index} className="card d-inline-flex flex-grow-1 align-self-stretch" style={{minWidth: "25em", maxWidth: "30em"}}> <div key={index} className="card d-inline-flex flex-grow-1 align-self-stretch" style={{minWidth: "25em", maxWidth: "30em"}}>
<ShowState table={table} dispatch={dispatchState}/> <ShowState table={table}/>
</div>) </div>)
} }
</div> </div>
@ -191,7 +197,7 @@ function PrintChrono({chrono}) {
const timer = setInterval(() => { const timer = setInterval(() => {
let currentDuration = chrono.configTime let currentDuration = chrono.configTime
if (chrono.state === 2) { if (chrono.state === 2) {
currentDuration = (chrono.state === 0) ? 10000 : chrono.configPause currentDuration = chrono.configPause
} }
const timeStr = (chrono.state === 1 ? " Match - " : " Pause - ") + timePrint(currentDuration - getTime()) + (isRunning() ? "" : " (arrêté)") const timeStr = (chrono.state === 1 ? " Match - " : " Pause - ") + timePrint(currentDuration - getTime()) + (isRunning() ? "" : " (arrêté)")