diff --git a/docker-compose.yml b/docker-compose.yml index fe71564..2ad1dc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,12 @@ # (docker-compose.calls.yml, https://crm-calls.volcanic.tes.gd): HS256, iss tvorymo-crm, aud jitsi, # one room, context.user.moderator -> owner via prosody token_affiliation. # -# Secrets (JICOFO_AUTH_PASSWORD, JVB_AUTH_PASSWORD, JWT_APP_SECRET) live only in Coolify env vars. +# Voice-only calls: jitsi/custom-config.js is appended to the server config.js (browser and mobile app). +# Recording: jibri (one instance, started by the calls module over its HTTP API on the crm-calls-bridge network), +# jitsi/finalize.sh keeps only audio and deletes the video; files go to RECORDINGS_HOST_DIR shared with the module. +# +# Secrets (JICOFO_AUTH_PASSWORD, JVB_AUTH_PASSWORD, JWT_APP_SECRET, JIBRI_RECORDER_PASSWORD, JIBRI_XMPP_PASSWORD) +# live only in Coolify env vars. services: web: @@ -26,6 +31,8 @@ services: volumes: - jitsi-web-config:/config - jitsi-web-storage:/storage + # Voice-only settings appended to config.js (see the file). + - ./jitsi/custom-config.js:/config/custom-config.js:ro environment: TZ: '${TZ:-Europe/Kyiv}' PUBLIC_URL: '${PUBLIC_URL:-https://meet.volcanic.tes.gd}' @@ -46,6 +53,10 @@ services: ENABLE_GUESTS: '0' # The calls module shows its own "open in app / continue in browser" page. DISABLE_DEEP_LINKING: 'true' + # Recording on: config gets hiddenDomain, so jibri can join as the hidden recorder. + ENABLE_RECORDING: '1' + START_AUDIO_ONLY: 'true' + START_WITH_VIDEO_MUTED: 'true' # Only the calls module may embed Jitsi (call window). Jitsi's nginx sets no X-Frame-Options. CSP_HEADER: "frame-ancestors ${CALLS_ORIGIN:-https://crm-calls.volcanic.tes.gd}" depends_on: @@ -88,6 +99,16 @@ services: JWT_ACCEPTED_AUDIENCES: '${JWT_AUDIENCE:-jitsi}' # context.user.moderator=true in the pass -> room owner (moderator); guests -> member. XMPP_MUC_MODULES: token_affiliation + # HTTP /room?room=… (occupants) on prosody:5280 — the calls module polls it (concurrency limit, recording start/stop). + XMPP_MODULES: muc_size + ENABLE_RECORDING: '1' + JIBRI_RECORDER_PASSWORD: '${JIBRI_RECORDER_PASSWORD}' + JIBRI_XMPP_PASSWORD: '${JIBRI_XMPP_PASSWORD}' + networks: + default: + calls-bridge: + aliases: + - jitsi-prosody jicofo: image: ghcr.io/jitsi/jicofo:${JITSI_IMAGE_VERSION:-stable-11248} @@ -111,6 +132,10 @@ services: # Moderators come only from the pass (prosody token_affiliation); nobody becomes owner automatically. JICOFO_ENABLE_AUTH: '0' ENABLE_AUTO_OWNER: 'false' + ENABLE_RECORDING: '1' + JIBRI_XMPP_PASSWORD: '${JIBRI_XMPP_PASSWORD}' + # Java heap (default 3072m — more than the whole server). + JICOFO_MAX_MEMORY: '${JICOFO_MAX_MEMORY:-256m}' depends_on: - prosody @@ -138,10 +163,65 @@ services: JVB_PORT: '${JVB_PORT:-10000}' # Comma-separated public IP(s) announced to clients; set in Coolify env (164.90.233.156). JVB_ADVERTISE_IPS: '${JVB_ADVERTISE_IPS:-164.90.233.156}' + # Java heap (default 3072m — more than the whole server). + VIDEOBRIDGE_MAX_MEMORY: '${VIDEOBRIDGE_MAX_MEMORY:-512m}' depends_on: - prosody + # One recorder (MAX_CONCURRENT_CALLS=1). Started/stopped by the calls module via http://jitsi-jibri:2222. + # Minimal video (800x480, 5 fps): only the audio is kept, see jitsi/finalize.sh. + jibri: + image: ghcr.io/jitsi/jibri:${JITSI_IMAGE_VERSION:-stable-11248} + restart: unless-stopped + read_only: true + tmpfs: + - /run:size=256M,mode=1750,exec + - /tmp:size=256M,mode=1777,noexec + shm_size: '1gb' + volumes: + - jitsi-jibri-config:/config + - jitsi-jibri-storage:/storage + - '${RECORDINGS_HOST_DIR:-/data/crm-calls/recordings}:/recordings' + - ./jitsi/finalize.sh:/opt/crm-calls/finalize.sh:ro + environment: + TZ: '${TZ:-Europe/Kyiv}' + PUBLIC_URL: '${PUBLIC_URL:-https://meet.volcanic.tes.gd}' + XMPP_SERVER: prosody + XMPP_DOMAIN: meet.jitsi + XMPP_AUTH_DOMAIN: auth.meet.jitsi + XMPP_MUC_DOMAIN: muc.meet.jitsi + XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi + XMPP_HIDDEN_DOMAIN: hidden.meet.jitsi + JIBRI_RECORDER_PASSWORD: '${JIBRI_RECORDER_PASSWORD}' + JIBRI_XMPP_PASSWORD: '${JIBRI_XMPP_PASSWORD}' + JIBRI_HTTP_API_EXTERNAL_PORT: '2222' + JIBRI_RECORDING_DIR: /recordings + JIBRI_FINALIZE_RECORDING_SCRIPT_PATH: /opt/crm-calls/finalize.sh + JIBRI_RECORDING_RESOLUTION: '800x480' + JIBRI_RECORDING_FRAMERATE: '5' + JIBRI_RECORDING_CONSTANT_RATE_FACTOR: '40' + JIBRI_RECORDING_VIDEO_ENCODE_PRESET_RECORDING: ultrafast + # Jibri's own JVM: small heap. Voice calls: people may stay muted/silent for long — + # Jibri must not stop the recording on "no media"/"all muted"; the module stops it when the room empties. + JAVA_TOOL_OPTIONS: '-Xmx256m -Djibri.call-status-checks.no-media-timeout=4h -Djibri.call-status-checks.all-muted-timeout=4h -Djibri.call-status-checks.default-call-empty-timeout=60s' + CALLS_AUDIO_BITRATE: '${CALLS_AUDIO_BITRATE:-32k}' + CALLS_CHUNK_SECONDS: '${CALLS_CHUNK_SECONDS:-1200}' + depends_on: + - jicofo + networks: + default: + calls-bridge: + aliases: + - jitsi-jibri + +networks: + # Shared with the calls module app (docker-compose.calls.yml, external network of the same name). + calls-bridge: + name: crm-calls-bridge + volumes: + jitsi-jibri-config: + jitsi-jibri-storage: jitsi-web-config: jitsi-web-storage: jitsi-prosody-config: diff --git a/jitsi/custom-config.js b/jitsi/custom-config.js new file mode 100644 index 0000000..115b29b --- /dev/null +++ b/jitsi/custom-config.js @@ -0,0 +1,25 @@ + +// ---- CRM Tvorymo: лише голосові дзвінки (рішення власника 2026-10-15) ---- +// Додається в кінець config.js сервера (docker-jitsi-meet: /config/custom-config.js), +// тож діє і в браузері (модуль «Дзвінки», прямий вхід), і в мобільному додатку Jitsi Meet, +// який завантажує config.js з нашого сервера. +config.startAudioOnly = true; // режим «лише звук»: відео не надсилається і не приймається +config.startWithVideoMuted = true; +config.startVideoMuted = 0; +config.disableShortcuts = true; // без «V» (камера) і «D» (екран) +config.toolbarButtons = [ + 'microphone', 'hangup', 'chat', 'raisehand', 'participants-pane', + 'tileview', 'settings', 'fullscreen', 'noisesuppression', 'filmstrip' +]; +config.settingsSections = [ 'devices', 'language', 'sounds', 'more' ]; +config.disableSelfView = true; +config.disableAddingBackgroundImages = true; +config.disableVirtualBackground = true; +config.desktopSharingFrameRate = { min: 1, max: 1 }; +config.prejoinConfig = Object.assign({}, config.prejoinConfig, { + hideExtraJoinButtons: [ 'no-audio', 'by-phone' ] +}); +config.recordingService = Object.assign({}, config.recordingService, { hideStorageWarning: true }); +// Запис запускає модуль «Дзвінки» на сервері (Jibri), учасникам кнопки запису не потрібні. +config.fileRecordingsServiceEnabled = false; +config.fileRecordingsServiceSharingEnabled = false; diff --git a/jitsi/finalize.sh b/jitsi/finalize.sh new file mode 100755 index 0000000..83d8651 --- /dev/null +++ b/jitsi/finalize.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Jibri finalize-скрипт (CRM Tvorymo, модуль «Дзвінки»). +# Jibri викликає його після завершення запису з каталогом сесії ($1), де лежить відео *.mp4. +# 1. Лише звук: моно, 16 кГц, низький бітрейт (mp3, якщо є libmp3lame, інакше opus). +# 2. Відео одразу видаляється. +# 3. Звук ріжеться на частини (CALLS_CHUNK_SECONDS) для Whisper (ліміт API 25 МБ). +# 4. meta.json: тривалості, частини, статистика якості; маркер ready. +# Далі файли забирає модуль «Дзвінки» (спільний каталог записів) і видаляє після успішної обробки. +set -u +DIR="${1:?каталог запису}" +LOG="$DIR/finalize.log" +exec >>"$LOG" 2>&1 +echo "$(date -Is) finalize $DIR" + +BITRATE="${CALLS_AUDIO_BITRATE:-32k}" +CHUNK="${CALLS_CHUNK_SECONDS:-1200}" +fail() { echo "$(date -Is) ПОМИЛКА: $*"; echo "$*" > "$DIR/failed"; exit 0; } + +VIDEO="$(ls -1 "$DIR"/*.mp4 2>/dev/null | head -1)" +[ -n "$VIDEO" ] || fail "немає відеофайлу" + +if ffmpeg -hide_banner -encoders 2>/dev/null | grep -q libmp3lame; then + EXT=mp3; MIME=audio/mpeg; CODEC=(-c:a libmp3lame -b:a "$BITRATE") +else + EXT=ogg; MIME=audio/ogg; CODEC=(-c:a libopus -application voip -b:a "$BITRATE") +fi +AUDIO="$DIR/audio.$EXT" + +ffmpeg -nostdin -hide_banner -loglevel error -y -i "$VIDEO" -vn -ac 1 -ar 16000 "${CODEC[@]}" "$AUDIO" \ + || fail "ffmpeg не зміг витягти звук" +VIDEO_BYTES=$(stat -c %s "$VIDEO" 2>/dev/null || echo 0) +rm -f "$VIDEO" "$DIR"/*.mp4 +echo "$(date -Is) відео видалено ($VIDEO_BYTES байт), звук: $AUDIO" + +mkdir -p "$DIR/parts" +ffmpeg -nostdin -hide_banner -loglevel error -y -i "$AUDIO" -f segment -segment_time "$CHUNK" -reset_timestamps 1 -c copy \ + "$DIR/parts/part-%03d.$EXT" || fail "ffmpeg не зміг розрізати звук" + +dur() { ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$1" 2>/dev/null || echo 0; } + +PARTS='[]' +for p in "$DIR"/parts/part-*."$EXT"; do + [ -f "$p" ] || continue + PARTS=$(jq -c --arg f "parts/$(basename "$p")" --argjson d "$(dur "$p")" --argjson s "$(stat -c %s "$p")" \ + '. + [{file:$f, duration:$d, bytes:$s}]' <<<"$PARTS") +done + +# Якість: тиша (поріг -50 dB, від 2 с), гучність. +STATS=$(ffmpeg -nostdin -hide_banner -i "$AUDIO" -af silencedetect=n=-50dB:d=2,volumedetect -f null - 2>&1) +SIL_COUNT=$(grep -c 'silence_end' <<<"$STATS") +SIL_TOTAL=$(grep -o 'silence_duration: [0-9.]*' <<<"$STATS" | awk '{s+=$2} END {printf "%.1f", s+0}') +MEAN_DB=$(grep -o 'mean_volume: [-0-9.]*' <<<"$STATS" | awk '{print $2}') +MAX_DB=$(grep -o 'max_volume: [-0-9.]*' <<<"$STATS" | awk '{print $2}') + +jq -n --arg audio "audio.$EXT" --arg mime "$MIME" --argjson duration "$(dur "$AUDIO")" \ + --argjson bytes "$(stat -c %s "$AUDIO")" --argjson videoBytes "$VIDEO_BYTES" --argjson parts "$PARTS" \ + --argjson silences "${SIL_COUNT:-0}" --argjson silenceSeconds "${SIL_TOTAL:-0}" \ + --arg meanDb "${MEAN_DB:-}" --arg maxDb "${MAX_DB:-}" --arg finishedAt "$(date -Is)" \ + '{audio:$audio, mime:$mime, duration:$duration, bytes:$bytes, videoBytes:$videoBytes, parts:$parts, + quality:{silences:$silences, silenceSeconds:$silenceSeconds, meanDb:$meanDb, maxDb:$maxDb}, finishedAt:$finishedAt}' \ + > "$DIR/meta.json.tmp" && mv "$DIR/meta.json.tmp" "$DIR/meta.json" || fail "не вдалося записати meta.json" +chmod -R a+rwX "$DIR" 2>/dev/null +touch "$DIR/ready" +echo "$(date -Is) готово: $(cat "$DIR/meta.json")"