frontend (test): add Smart Mosaic layout helper functions and fake participant management
- Implemented helper functions for configuring Smart Mosaic layout, including setting participant count and waiting for participant visibility. - Created a new file for managing fake participants, allowing for joining and disconnecting from LiveKit rooms using both CLI and browser-based methods. - Introduced interfaces for browser-based fake participant options to streamline participant creation with audio and video assets.
This commit is contained in:
parent
f930bf1447
commit
1663b008ed
Binary file not shown.
BIN
meet-ce/frontend/webcomponent/tests/assets/audio/base.wav
Normal file
BIN
meet-ce/frontend/webcomponent/tests/assets/audio/base.wav
Normal file
Binary file not shown.
BIN
meet-ce/frontend/webcomponent/tests/assets/audio/base0.wav
Normal file
BIN
meet-ce/frontend/webcomponent/tests/assets/audio/base0.wav
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
330
meet-ce/frontend/webcomponent/tests/assets/audio/generate-test-audio.sh
Executable file
330
meet-ce/frontend/webcomponent/tests/assets/audio/generate-test-audio.sh
Executable file
@ -0,0 +1,330 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Audio Generation Script for Smart Mosaic Layout Tests
|
||||||
|
# =============================================================================
|
||||||
|
# This script generates test audio files from a base audio file (base.wav)
|
||||||
|
# for testing the Smart Mosaic layout speaker detection functionality.
|
||||||
|
#
|
||||||
|
# Requirements:
|
||||||
|
# - ffmpeg 7.0+ (optimized for this version)
|
||||||
|
# - base.wav file with continuous speech audio in the same directory
|
||||||
|
#
|
||||||
|
# IMPORTANT: This script generates WAV files for best compatibility with
|
||||||
|
# Chrome's fake audio capture (--use-file-for-fake-audio-capture).
|
||||||
|
# WAV format ensures proper audio device simulation and VAD detection.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# chmod +x generate-test-audio.sh
|
||||||
|
# ./generate-test-audio.sh
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BASE_AUDIO="$SCRIPT_DIR/base.wav"
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Audio settings
|
||||||
|
SAMPLE_RATE=48000
|
||||||
|
CHANNELS=1
|
||||||
|
|
||||||
|
# WAV encoding settings for Chrome fake audio capture compatibility
|
||||||
|
# PCM 16-bit is the most compatible format for Chrome's fake devices
|
||||||
|
WAV_OPTS="-c:a pcm_s16le -ar ${SAMPLE_RATE} -ac ${CHANNELS}"
|
||||||
|
|
||||||
|
# Check ffmpeg version
|
||||||
|
FFMPEG_VERSION=$(ffmpeg -version | head -n1 | grep -oP 'ffmpeg version \K[0-9]+')
|
||||||
|
echo "🔧 Detected ffmpeg major version: $FFMPEG_VERSION"
|
||||||
|
|
||||||
|
# Check if base audio exists
|
||||||
|
if [ ! -f "$BASE_AUDIO" ]; then
|
||||||
|
echo "❌ Error: base.wav not found in $SCRIPT_DIR"
|
||||||
|
echo "Please provide a base.wav file with continuous speech audio."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎵 Generating test audio files from base.wav..."
|
||||||
|
echo " Output directory: $OUTPUT_DIR"
|
||||||
|
echo " Sample rate: ${SAMPLE_RATE}Hz, Channels: ${CHANNELS}"
|
||||||
|
echo " Codec: PCM 16-bit (WAV) for Chrome fake audio compatibility"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 1. continuous_speech.wav (30s)
|
||||||
|
# Continuous speech audio for participants who speak constantly
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "1️⃣ Generating continuous_speech.wav (30s of continuous speech)..."
|
||||||
|
ffmpeg -y -i "$BASE_AUDIO" -t 30 -af "aresample=${SAMPLE_RATE}" $WAV_OPTS "$OUTPUT_DIR/continuous_speech.wav" 2>/dev/null
|
||||||
|
echo " ✅ continuous_speech.wav created"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 2. complete_silence.wav (30s)
|
||||||
|
# Complete digital silence using aevalsrc with explicit zero expression
|
||||||
|
# This generates samples with value exactly 0.0 - guaranteed no VAD trigger
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "2️⃣ Generating complete_silence.wav (30s of TRUE digital silence)..."
|
||||||
|
ffmpeg -y -f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
$WAV_OPTS "$OUTPUT_DIR/complete_silence.wav" 2>/dev/null
|
||||||
|
echo " ✅ complete_silence.wav created"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 3. speech_5s_then_silence.wav (30s)
|
||||||
|
# 5s speech, then 25s TRUE silence
|
||||||
|
# Uses amix to combine speech with silence background for clean transitions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "3️⃣ Generating speech_5s_then_silence.wav (5s speech + 25s TRUE silence)..."
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:5,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE}[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speech_5s_then_silence.wav" 2>/dev/null
|
||||||
|
echo " ✅ speech_5s_then_silence.wav created"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 4. silence_5s_then_speech.wav (30s)
|
||||||
|
# 5s TRUE silence, then 25s speech
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "4️⃣ Generating silence_5s_then_speech.wav (5s TRUE silence + 25s speech)..."
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:25,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=5s:all=1[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/silence_5s_then_speech.wav" 2>/dev/null
|
||||||
|
echo " ✅ silence_5s_then_speech.wav created"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 5. speech_gap_speech.wav (30s)
|
||||||
|
# 5s speech, 10s TRUE silence, 15s speech - for testing speaker re-activation
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "5️⃣ Generating speech_gap_speech.wav (5s speech + 10s TRUE gap + 15s speech)..."
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:5,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE}[s1];
|
||||||
|
[0:a]atrim=5:20,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=15s:all=1[s2];
|
||||||
|
[1:a][s1][s2]amix=inputs=3:duration=first:dropout_transition=0,volume=3[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speech_gap_speech.wav" 2>/dev/null
|
||||||
|
echo " ✅ speech_gap_speech.wav created"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 6-11. Sequential speaker audio files (for rotation tests)
|
||||||
|
# Each speaker has a unique time window for speech with TRUE silence elsewhere
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "6️⃣ Generating sequential speaker audio files (A through F)..."
|
||||||
|
|
||||||
|
# Speaker A: speaks 0-3s, then TRUE silence
|
||||||
|
echo " → speaker_seq_A.wav (speaks at 0-3s)"
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE}[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_A.wav" 2>/dev/null
|
||||||
|
|
||||||
|
# Speaker B: TRUE silence 0-5s, speaks 5-8s, then TRUE silence
|
||||||
|
echo " → speaker_seq_B.wav (speaks at 5-8s)"
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=5s:all=1[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_B.wav" 2>/dev/null
|
||||||
|
|
||||||
|
# Speaker C: TRUE silence 0-10s, speaks 10-13s, then TRUE silence
|
||||||
|
echo " → speaker_seq_C.wav (speaks at 10-13s)"
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=10s:all=1[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_C.wav" 2>/dev/null
|
||||||
|
|
||||||
|
# Speaker D: TRUE silence 0-15s, speaks 15-18s, then TRUE silence
|
||||||
|
echo " → speaker_seq_D.wav (speaks at 15-18s)"
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=15s:all=1[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_D.wav" 2>/dev/null
|
||||||
|
|
||||||
|
# Speaker E: TRUE silence 0-20s, speaks 20-23s, then TRUE silence
|
||||||
|
echo " → speaker_seq_E.wav (speaks at 20-23s)"
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=20s:all=1[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_E.wav" 2>/dev/null
|
||||||
|
|
||||||
|
# Speaker F: TRUE silence 0-25s, speaks 25-28s, then TRUE silence
|
||||||
|
echo " → speaker_seq_F.wav (speaks at 25-28s)"
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=25s:all=1[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_F.wav" 2>/dev/null
|
||||||
|
|
||||||
|
echo " ✅ Sequential speaker files created (A-F)"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 12. simultaneous_then_solo.wav (30s)
|
||||||
|
# 15s speech then 15s TRUE silence
|
||||||
|
# Used for the "simultaneous speech" test (this participant continues speaking)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "7️⃣ Generating simultaneous_then_solo.wav (15s speech + 15s TRUE silence)..."
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:15,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE}[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/simultaneous_then_solo.wav" 2>/dev/null
|
||||||
|
echo " ✅ simultaneous_then_solo.wav created"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 13. simultaneous_then_stop.wav (30s)
|
||||||
|
# 5s speech then 25s TRUE silence
|
||||||
|
# Used for participants who stop speaking after simultaneous period
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "8️⃣ Generating simultaneous_then_stop.wav (5s speech + 25s TRUE silence)..."
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:5,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE}[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/simultaneous_then_stop.wav" 2>/dev/null
|
||||||
|
echo " ✅ simultaneous_then_stop.wav created"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 14. low_volume_speech.wav (30s)
|
||||||
|
# Continuous speech at 10% volume - below the audioLevel threshold (0.15)
|
||||||
|
# Used to test that participants with low audio levels are filtered out
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "9️⃣ Generating low_volume_speech.wav (30s speech at 10% volume)..."
|
||||||
|
|
||||||
|
ffmpeg -y \
|
||||||
|
-f lavfi -i "anoisesrc=color=pink:amplitude=0.02:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
$WAV_OPTS "$OUTPUT_DIR/ambient_pink_noise.wav" 2>/dev/null
|
||||||
|
|
||||||
|
echo " ✅ low_volume_speech.wav created"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 15. brief_sound_1s.wav (30s)
|
||||||
|
# Only 1 second of speech followed by silence
|
||||||
|
# Used to test minimum speaking duration filter (should be filtered out)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "🔟 Generating brief_sound_1s.wav (1s speech + 29s silence)..."
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:1,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=5000|5000[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/brief_sound_1s_at_5s.wav" 2>/dev/null
|
||||||
|
echo " ✅ brief_sound_1s_at_5s.wav created"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 16. brief_cough.wav (30s)
|
||||||
|
# Only 0.5 seconds of sound (simulating a cough) followed by silence
|
||||||
|
# Used to test that very brief sounds are filtered out
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "1️⃣1️⃣ Generating brief_cough.wav (0.5s sound + 29.5s silence)..."
|
||||||
|
ffmpeg -y \
|
||||||
|
-i "$BASE_AUDIO" \
|
||||||
|
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
|
||||||
|
-filter_complex "
|
||||||
|
[0:a]atrim=0:0.5,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=5000|5000[speech];
|
||||||
|
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
|
||||||
|
" \
|
||||||
|
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/brief_cough_at_5s.wav" 2>/dev/null
|
||||||
|
echo " ✅ brief_cough_at_5s.wav created"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Verify silence in generated files
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "🔍 Verifying silence quality in generated files..."
|
||||||
|
verify_silence() {
|
||||||
|
local file=$1
|
||||||
|
local expected_silence_start=$2
|
||||||
|
|
||||||
|
# Check RMS level in silence portion (should be exactly 0 or very close)
|
||||||
|
local rms=$(ffmpeg -i "$file" -af "atrim=${expected_silence_start}:${expected_silence_start}+1,astats=metadata=1:reset=1" -f null - 2>&1 | grep "RMS level" | head -1 | grep -oP '[-0-9.]+' | head -1)
|
||||||
|
|
||||||
|
if [ -n "$rms" ]; then
|
||||||
|
echo " $file: RMS at ${expected_silence_start}s = ${rms}dB"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify a few key files
|
||||||
|
verify_silence "$OUTPUT_DIR/complete_silence.wav" 15
|
||||||
|
verify_silence "$OUTPUT_DIR/speech_5s_then_silence.wav" 10
|
||||||
|
verify_silence "$OUTPUT_DIR/speaker_seq_B.wav" 2
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "============================================================================="
|
||||||
|
echo "✅ Audio generation complete! (WAV format for Chrome fake audio capture)"
|
||||||
|
echo "============================================================================="
|
||||||
|
echo ""
|
||||||
|
echo "Generated files:"
|
||||||
|
echo " 📁 $OUTPUT_DIR/"
|
||||||
|
echo " ├── continuous_speech.wav (30s continuous speech)"
|
||||||
|
echo " ├── complete_silence.wav (30s TRUE digital silence - aevalsrc=0)"
|
||||||
|
echo " ├── speech_5s_then_silence.wav (5s speech + 25s TRUE silence)"
|
||||||
|
echo " ├── silence_5s_then_speech.wav (5s TRUE silence + 25s speech)"
|
||||||
|
echo " ├── speech_gap_speech.wav (5s speech + 10s gap + 15s speech)"
|
||||||
|
echo " ├── speaker_seq_A.wav (speaks at 0-3s)"
|
||||||
|
echo " ├── speaker_seq_B.wav (speaks at 5-8s)"
|
||||||
|
echo " ├── speaker_seq_C.wav (speaks at 10-13s)"
|
||||||
|
echo " ├── speaker_seq_D.wav (speaks at 15-18s)"
|
||||||
|
echo " ├── speaker_seq_E.wav (speaks at 20-23s)"
|
||||||
|
echo " ├── speaker_seq_F.wav (speaks at 25-28s)"
|
||||||
|
echo " ├── simultaneous_then_solo.wav (15s speech + 15s silence)"
|
||||||
|
echo " ├── simultaneous_then_stop.wav (5s speech + 25s silence)"
|
||||||
|
echo " ├── low_volume_speech.wav (30s speech at 10% volume - below threshold)"
|
||||||
|
echo " ├── brief_sound_1s.wav (1s speech + 29s silence - too short)"
|
||||||
|
echo " └── brief_cough.wav (0.5s sound + 29.5s silence - simulates cough)"
|
||||||
|
echo ""
|
||||||
|
echo "Key features of this version:"
|
||||||
|
echo " • WAV format (PCM 16-bit) for Chrome fake audio capture compatibility"
|
||||||
|
echo " • Uses aevalsrc=0 for TRUE digital silence (samples = 0.0)"
|
||||||
|
echo " • amix filter for clean speech/silence transitions"
|
||||||
|
echo " • adelay for precise speech timing"
|
||||||
|
echo " • 48kHz sample rate, mono channel"
|
||||||
|
echo ""
|
||||||
|
echo "Usage in tests:"
|
||||||
|
echo " await joinBrowserFakeParticipant(browser, roomId, 'speaker1', {"
|
||||||
|
echo " audioFile: 'continuous_speech.wav'"
|
||||||
|
echo " });"
|
||||||
|
echo ""
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
918
meet-ce/frontend/webcomponent/tests/e2e/custom-layout.test.ts
Normal file
918
meet-ce/frontend/webcomponent/tests/e2e/custom-layout.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -162,7 +162,8 @@ const getDefaultRoomConfig = (): MeetRoomConfig => ({
|
|||||||
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
|
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
|
||||||
},
|
},
|
||||||
chat: { enabled: true },
|
chat: { enabled: true },
|
||||||
virtualBackground: { enabled: true }
|
virtualBackground: { enabled: true },
|
||||||
|
e2ee: { enabled: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to create a room for testing
|
// Helper function to create a room for testing
|
||||||
@ -616,3 +617,296 @@ export const isShareLinkOverlayyHidden = async (page: Page, overlaySelector: str
|
|||||||
console.log('❌ Overlay is still visible');
|
console.log('❌ Overlay is still visible');
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// SMART MOSAIC LAYOUT HELPER FUNCTIONS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the settings panel and configures the Smart Mosaic layout.
|
||||||
|
* This function:
|
||||||
|
* 1. Opens the more options menu
|
||||||
|
* 2. Clicks on the layout settings button
|
||||||
|
* 3. Selects the specified layout mode
|
||||||
|
* 4. If Smart Mosaic is selected, sets the max participants count
|
||||||
|
* 5. Closes the settings panel
|
||||||
|
*
|
||||||
|
* @param page - Playwright page object
|
||||||
|
* @param layoutMode - The layout mode to select ('mosaic' or 'smart-mosaic')
|
||||||
|
* @param maxParticipants - Maximum number of remote participants to show (1-6, only used for smart-mosaic)
|
||||||
|
*/
|
||||||
|
export const configureLayoutMode = async (
|
||||||
|
page: Page,
|
||||||
|
layoutMode: 'smart-mosaic' | 'mosaic',
|
||||||
|
maxParticipants?: number
|
||||||
|
): Promise<void> => {
|
||||||
|
console.log(
|
||||||
|
`🎛️ Configuring layout mode: ${layoutMode}${maxParticipants ? `, max participants: ${maxParticipants}` : ''}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open more options menu
|
||||||
|
await openMoreOptionsMenu(page);
|
||||||
|
|
||||||
|
// Click on layout settings button (could be grid-layout-settings-btn or toolbar-settings-btn)
|
||||||
|
const layoutSettingsBtn = '#grid-layout-settings-btn, #toolbar-settings-btn';
|
||||||
|
await waitForElementInIframe(page, layoutSettingsBtn, { state: 'visible' });
|
||||||
|
await interactWithElementInIframe(page, layoutSettingsBtn, { action: 'click' });
|
||||||
|
|
||||||
|
// Wait for settings panel to open
|
||||||
|
await waitForElementInIframe(page, 'ov-settings-panel', { state: 'visible' });
|
||||||
|
await page.waitForTimeout(500); // Wait for panel animation
|
||||||
|
|
||||||
|
// Select the layout mode using radio buttons
|
||||||
|
const radioSelector = layoutMode === 'smart-mosaic' ? '#layout-smart-mosaic' : '#layout-mosaic';
|
||||||
|
|
||||||
|
await waitForElementInIframe(page, radioSelector, { state: 'visible' });
|
||||||
|
await interactWithElementInIframe(page, radioSelector, { action: 'click' });
|
||||||
|
await page.waitForTimeout(300); // Wait for mode change
|
||||||
|
|
||||||
|
// If Smart Mosaic is selected and maxParticipants is specified, set the slider value
|
||||||
|
if (layoutMode === 'smart-mosaic' && maxParticipants !== undefined) {
|
||||||
|
await setSmartMosaicParticipantCount(page, maxParticipants);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the settings panel
|
||||||
|
await closeSettingsPanel(page);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ Layout configured: ${layoutMode}${maxParticipants ? ` with ${maxParticipants} max participants` : ''}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the participant count for Smart Mosaic layout using the slider.
|
||||||
|
* This function should be called when the settings panel is already open
|
||||||
|
* and Smart Mosaic mode is selected.
|
||||||
|
*
|
||||||
|
* @param page - Playwright page object
|
||||||
|
* @param count - Number of participants to show (1-6)
|
||||||
|
*/
|
||||||
|
export const setSmartMosaicParticipantCount = async (page: Page, count: number): Promise<void> => {
|
||||||
|
if (count < 1 || count > 6) {
|
||||||
|
throw new Error(`Invalid participant count: ${count}. Must be between 1 and 6.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔢 Setting Smart Mosaic participant count to: ${count}`);
|
||||||
|
|
||||||
|
// Wait for the slider to be visible (only appears in Smart Mosaic mode)
|
||||||
|
const sliderSelector = '.participant-slider input[matSliderThumb]';
|
||||||
|
await waitForElementInIframe(page, sliderSelector, { state: 'visible', timeout: 5000 });
|
||||||
|
|
||||||
|
// Get the slider element and set its value
|
||||||
|
const frameLocator = await getIframeInShadowDom(page);
|
||||||
|
const slider = frameLocator.locator(sliderSelector);
|
||||||
|
|
||||||
|
// Use keyboard to set the value - first focus, then set value via fill
|
||||||
|
await slider.focus();
|
||||||
|
|
||||||
|
// Clear and set the value - the slider input accepts direct value assignment
|
||||||
|
await slider.fill(count.toString());
|
||||||
|
await page.waitForTimeout(300); // Wait for value to be applied
|
||||||
|
|
||||||
|
console.log(`✅ Smart Mosaic participant count set to: ${count}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the settings panel by clicking the close button or clicking outside
|
||||||
|
*
|
||||||
|
* @param page - Playwright page object
|
||||||
|
*/
|
||||||
|
export const closeSettingsPanel = async (page: Page): Promise<void> => {
|
||||||
|
// Try to close via the panel close button
|
||||||
|
const closeButtonSelector = '.panel-close-button';
|
||||||
|
try {
|
||||||
|
await waitForElementInIframe(page, closeButtonSelector, { state: 'visible', timeout: 2000 });
|
||||||
|
await interactWithElementInIframe(page, closeButtonSelector, { action: 'click' });
|
||||||
|
} catch {
|
||||||
|
// If close button not found, click outside the panel
|
||||||
|
await interactWithElementInIframe(page, 'body', { action: 'click' });
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500); // Wait for panel to close
|
||||||
|
};
|
||||||
|
|
||||||
|
export const muteAudio = async (page: Page) => {
|
||||||
|
await interactWithElementInIframe(page, '#mic-btn', { action: 'click' });
|
||||||
|
await page.waitForTimeout(500); // Wait for action to complete
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the number of visible participant tiles in the video grid.
|
||||||
|
* This counts all participant containers currently displayed.
|
||||||
|
*
|
||||||
|
* @param page - Playwright page object
|
||||||
|
* @returns Number of visible participant tiles
|
||||||
|
*/
|
||||||
|
export const getVisibleParticipantsCount = async (page: Page): Promise<number> => {
|
||||||
|
const participantSelector = '.OV_publisher';
|
||||||
|
const count = await countElementsInIframe(page, participantSelector);
|
||||||
|
console.log(`👥 Visible participants in grid: ${count}`);
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the identities of all visible participants in the grid.
|
||||||
|
*
|
||||||
|
* @param page - Playwright page object
|
||||||
|
* @returns Array of participant names/identities visible in the grid
|
||||||
|
*/
|
||||||
|
export const getVisibleParticipantNames = async (page: Page): Promise<string[]> => {
|
||||||
|
const frameLocator = await getIframeInShadowDom(page);
|
||||||
|
const participantContainers = frameLocator.locator('.participant-name-container');
|
||||||
|
const count = await participantContainers.count();
|
||||||
|
|
||||||
|
const names: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const container = participantContainers.nth(i);
|
||||||
|
const participantName = await container.textContent();
|
||||||
|
|
||||||
|
if (participantName) {
|
||||||
|
names.push(participantName.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`👥 Visible participant names: ${names.join(', ')}`);
|
||||||
|
return names;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for the participant grid to show a specific number of participants.
|
||||||
|
*
|
||||||
|
* @param page - Playwright page object
|
||||||
|
* @param expectedCount - Expected number of visible participants
|
||||||
|
* @param timeout - Maximum time to wait in milliseconds (default: 10000)
|
||||||
|
* @returns true if the expected count is reached, false if timeout
|
||||||
|
*/
|
||||||
|
export const waitForParticipantCount = async (
|
||||||
|
page: Page,
|
||||||
|
expectedCount: number,
|
||||||
|
timeout: number = 10000
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const currentCount = await getVisibleParticipantsCount(page);
|
||||||
|
if (currentCount === expectedCount) {
|
||||||
|
console.log(`✅ Participant count reached: ${expectedCount}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalCount = await getVisibleParticipantsCount(page);
|
||||||
|
console.log(`❌ Timeout waiting for participant count. Expected: ${expectedCount}, Got: ${finalCount}`);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for a specific participant to become visible in the grid.
|
||||||
|
* Uses polling to check if the participant's name appears in the visible participants list.
|
||||||
|
*
|
||||||
|
* @param page - Playwright page object
|
||||||
|
* @param participantName - The name/identity of the participant to wait for
|
||||||
|
* @param timeout - Maximum time to wait in milliseconds (default: 30000)
|
||||||
|
* @returns true if the participant becomes visible, throws error if timeout
|
||||||
|
*/
|
||||||
|
export const waitForParticipantVisible = async (
|
||||||
|
page: Page,
|
||||||
|
participantName: string,
|
||||||
|
timeout: number = 30000
|
||||||
|
): Promise<boolean> => {
|
||||||
|
console.log(`⏳ Waiting for participant "${participantName}" to become visible...`);
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const visibleNames = await getVisibleParticipantNames(page);
|
||||||
|
if (visibleNames.includes(participantName)) {
|
||||||
|
console.log(`✅ Participant "${participantName}" is now visible`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalNames = await getVisibleParticipantNames(page);
|
||||||
|
throw new Error(
|
||||||
|
`Timeout waiting for participant "${participantName}" to become visible. ` +
|
||||||
|
`Current visible: [${finalNames.join(', ')}]`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for a specific participant to become hidden (not visible) in the grid.
|
||||||
|
* Uses polling to check if the participant's name disappears from the visible participants list.
|
||||||
|
*
|
||||||
|
* @param page - Playwright page object
|
||||||
|
* @param participantName - The name/identity of the participant to wait for hiding
|
||||||
|
* @param timeout - Maximum time to wait in milliseconds (default: 30000)
|
||||||
|
* @returns true if the participant becomes hidden, throws error if timeout
|
||||||
|
*/
|
||||||
|
export const waitForParticipantHidden = async (
|
||||||
|
page: Page,
|
||||||
|
participantName: string,
|
||||||
|
timeout: number = 30000
|
||||||
|
): Promise<boolean> => {
|
||||||
|
console.log(`⏳ Waiting for participant "${participantName}" to become hidden...`);
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const visibleNames = await getVisibleParticipantNames(page);
|
||||||
|
if (!visibleNames.includes(participantName)) {
|
||||||
|
console.log(`✅ Participant "${participantName}" is now hidden`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalNames = await getVisibleParticipantNames(page);
|
||||||
|
throw new Error(
|
||||||
|
`Timeout waiting for participant "${participantName}" to become hidden. ` +
|
||||||
|
`Current visible: [${finalNames.join(', ')}]`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for a layout change where one participant replaces another.
|
||||||
|
* Useful for testing Smart Mosaic speaker rotation.
|
||||||
|
*
|
||||||
|
* @param page - Playwright page object
|
||||||
|
* @param participantToAppear - The participant that should become visible
|
||||||
|
* @param participantToDisappear - The participant that should become hidden
|
||||||
|
* @param timeout - Maximum time to wait in milliseconds (default: 30000)
|
||||||
|
* @returns true if the swap happens, throws error if timeout
|
||||||
|
*/
|
||||||
|
export const waitForParticipantSwap = async (
|
||||||
|
page: Page,
|
||||||
|
participantToAppear: string,
|
||||||
|
participantToDisappear: string,
|
||||||
|
timeout: number = 30000
|
||||||
|
): Promise<boolean> => {
|
||||||
|
console.log(`⏳ Waiting for swap: "${participantToAppear}" replaces "${participantToDisappear}"...`);
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const visibleNames = await getVisibleParticipantNames(page);
|
||||||
|
const newIsVisible = visibleNames.includes(participantToAppear);
|
||||||
|
const oldIsHidden = !visibleNames.includes(participantToDisappear);
|
||||||
|
|
||||||
|
if (newIsVisible && oldIsHidden) {
|
||||||
|
console.log(`✅ Swap complete: "${participantToAppear}" replaced "${participantToDisappear}"`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalNames = await getVisibleParticipantNames(page);
|
||||||
|
throw new Error(
|
||||||
|
`Timeout waiting for participant swap. Expected "${participantToAppear}" to replace "${participantToDisappear}". ` +
|
||||||
|
`Current visible: [${finalNames.join(', ')}]`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to sleep for a specified time
|
||||||
|
*/
|
||||||
|
export const sleep = (ms: number): Promise<void> => {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
};
|
||||||
|
|||||||
@ -0,0 +1,283 @@
|
|||||||
|
import { BrowserContext, chromium, Page } from '@playwright/test';
|
||||||
|
import { BrowserFakeParticipantOptions } from '../interfaces/fake-participant';
|
||||||
|
import { ChildProcess, spawn } from 'child_process';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { joinRoomAs, leaveRoom, prepareForJoiningRoom, sleep, waitForElementInIframe } from './function-helpers';
|
||||||
|
import { MEET_TESTAPP_URL } from '../config';
|
||||||
|
|
||||||
|
// LiveKit credentials
|
||||||
|
const LIVEKIT_API_KEY = process.env['LIVEKIT_API_KEY'] || 'devkey';
|
||||||
|
const LIVEKIT_API_SECRET = process.env['LIVEKIT_API_SECRET'] || 'secret';
|
||||||
|
|
||||||
|
// Store fake participant processes for cleanup
|
||||||
|
const fakeParticipantProcesses = new Map<string, ChildProcess>();
|
||||||
|
|
||||||
|
// ES Module equivalent of __dirname
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// FAKE PARTICIPANT HELPER FUNCTIONS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the test audio assets directory
|
||||||
|
* Uses __dirname to resolve relative to this file's location
|
||||||
|
*/
|
||||||
|
const AUDIO_ASSETS_DIR = path.resolve(__dirname, '../assets/audio');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins a fake participant to a LiveKit room using the lk CLI.
|
||||||
|
* This participant can publish audio to trigger speaker detection.
|
||||||
|
*
|
||||||
|
* @param roomId - The room ID to join
|
||||||
|
* @param identity - The participant identity/name
|
||||||
|
* @param options - Options for publishing media
|
||||||
|
*/
|
||||||
|
export const joinFakeParticipant = async (roomId: string, identity: string): Promise<void> => {
|
||||||
|
console.log(`🤖 Joining fake participant: ${identity} to room: ${roomId}`);
|
||||||
|
|
||||||
|
const process = spawn('lk', [
|
||||||
|
'room',
|
||||||
|
'join',
|
||||||
|
roomId,
|
||||||
|
'--identity',
|
||||||
|
identity,
|
||||||
|
'--publish-demo',
|
||||||
|
'--api-key',
|
||||||
|
LIVEKIT_API_KEY,
|
||||||
|
'--api-secret',
|
||||||
|
LIVEKIT_API_SECRET
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Store process for cleanup
|
||||||
|
fakeParticipantProcesses.set(`${roomId}-${identity}`, process);
|
||||||
|
|
||||||
|
// Wait for participant to join
|
||||||
|
await sleep(1500);
|
||||||
|
|
||||||
|
console.log(`✅ Fake participant joined: ${identity}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects a specific fake participant from the room.
|
||||||
|
*
|
||||||
|
* @param roomId - The room ID
|
||||||
|
* @param identity - The participant identity to disconnect
|
||||||
|
*/
|
||||||
|
export const disconnectFakeParticipant = async (roomId: string, identity: string): Promise<void> => {
|
||||||
|
const key = `${roomId}-${identity}`;
|
||||||
|
const process = fakeParticipantProcesses.get(key);
|
||||||
|
|
||||||
|
if (process) {
|
||||||
|
process.kill();
|
||||||
|
fakeParticipantProcesses.delete(key);
|
||||||
|
console.log(`👋 Disconnected fake participant: ${identity}`);
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects all fake participants from all rooms.
|
||||||
|
* Should be called in afterEach or afterAll hooks.
|
||||||
|
*/
|
||||||
|
export const disconnectAllFakeParticipants = async (): Promise<void> => {
|
||||||
|
for (const [key, process] of fakeParticipantProcesses) {
|
||||||
|
process.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeParticipantProcesses.clear();
|
||||||
|
await sleep(500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BROWSER-BASED FAKE PARTICIPANT HELPERS
|
||||||
|
// ==========================================
|
||||||
|
// These functions use Playwright browser tabs with fake audio devices
|
||||||
|
// to create participants that properly trigger LiveKit's VAD
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store for browser-based fake participant contexts
|
||||||
|
* Each participant gets its own browser context with specific Chrome args
|
||||||
|
*/
|
||||||
|
const browserFakeParticipants = new Map<string, { context: BrowserContext; page: Page }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins a fake participant to a room using a new browser instance with fake audio device.
|
||||||
|
* This method properly triggers LiveKit's Voice Activity Detection (VAD) because
|
||||||
|
* it uses Chrome's --use-file-for-fake-audio-capture flag.
|
||||||
|
*
|
||||||
|
* IMPORTANT: The audio file should be in WAV format for best compatibility with Chrome.
|
||||||
|
* Chrome's fake audio capture works best with uncompressed audio.
|
||||||
|
*
|
||||||
|
* @param roomId - The room ID to join
|
||||||
|
* @param identity - The participant identity/name
|
||||||
|
* @param options - Options for the fake participant
|
||||||
|
* @returns The page object for the fake participant (for further interactions)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const participantPage = await joinBrowserFakeParticipant(
|
||||||
|
* browser,
|
||||||
|
* roomId,
|
||||||
|
* 'RemoteA-Speaker',
|
||||||
|
* { audioFile: 'continuous_speech.wav' }
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const joinBrowserFakeParticipant = async (
|
||||||
|
roomId: string,
|
||||||
|
identity: string,
|
||||||
|
options: BrowserFakeParticipantOptions = {}
|
||||||
|
): Promise<Page> => {
|
||||||
|
console.log(`🌐 Joining browser-based fake participant: ${identity} to room: ${roomId}`);
|
||||||
|
|
||||||
|
const { audioFile, videoFile, displayName = identity, enableVideo = true, enableAudio = true } = options;
|
||||||
|
|
||||||
|
// Video assets directory (sibling to audio assets)
|
||||||
|
const VIDEO_ASSETS_DIR = path.resolve(path.dirname(AUDIO_ASSETS_DIR), 'video');
|
||||||
|
|
||||||
|
// Resolve audio file path
|
||||||
|
let audioFilePath: string | undefined;
|
||||||
|
if (audioFile) {
|
||||||
|
audioFilePath = path.isAbsolute(audioFile) ? audioFile : path.resolve(AUDIO_ASSETS_DIR, audioFile);
|
||||||
|
|
||||||
|
if (!fs.existsSync(audioFilePath)) {
|
||||||
|
throw new Error(`Audio file not found: ${audioFilePath}`);
|
||||||
|
}
|
||||||
|
console.log(` 🎵 Using audio file: ${audioFilePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve video file path
|
||||||
|
let videoFilePath: string | undefined;
|
||||||
|
if (videoFile) {
|
||||||
|
videoFilePath = path.isAbsolute(videoFile) ? videoFile : path.resolve(VIDEO_ASSETS_DIR, videoFile);
|
||||||
|
|
||||||
|
if (!fs.existsSync(videoFilePath)) {
|
||||||
|
throw new Error(`Video file not found: ${videoFilePath}`);
|
||||||
|
}
|
||||||
|
console.log(` 🎬 Using video file: ${videoFilePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chrome flags for fake media devices
|
||||||
|
const chromeArgs = [
|
||||||
|
'--use-fake-ui-for-media-stream', // Auto-accept media permissions
|
||||||
|
'--use-fake-device-for-media-stream', // Use fake devices
|
||||||
|
'--allow-file-access-from-files',
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-dev-shm-usage'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add fake audio capture file if specified
|
||||||
|
if (audioFilePath) {
|
||||||
|
chromeArgs.push(`--use-file-for-fake-audio-capture=${audioFilePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add fake video capture file if specified
|
||||||
|
// Chrome supports Y4M (YUV4MPEG2) and MJPEG formats for fake video capture
|
||||||
|
if (videoFilePath) {
|
||||||
|
// chromeArgs.push(`--use-file-for-fake-video-capture=${videoFilePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` 🔧 Chrome args: ${chromeArgs.join(' ')}`);
|
||||||
|
|
||||||
|
// Launch a new browser context with the specific Chrome args
|
||||||
|
// We need to use launchPersistentContext to pass Chrome args
|
||||||
|
const userDataDir = `/tmp/playwright-fake-participant-${identity}-${Date.now()}`;
|
||||||
|
|
||||||
|
const context = await chromium.launchPersistentContext(userDataDir, {
|
||||||
|
headless: true, // Set to false for debugging
|
||||||
|
args: chromeArgs,
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
bypassCSP: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the first page or create one
|
||||||
|
const page = context.pages()[0] || (await context.newPage());
|
||||||
|
|
||||||
|
// Store for cleanup
|
||||||
|
const key = `${roomId}-${identity}`;
|
||||||
|
browserFakeParticipants.set(key, { context, page });
|
||||||
|
|
||||||
|
// Handle the lobby/prejoin if present - click join button
|
||||||
|
try {
|
||||||
|
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('speaker', identity, page);
|
||||||
|
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
|
||||||
|
await waitForElementInIframe(page, '.OV_publisher', { state: 'visible', timeout: 10000 });
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` ⚠️ No lobby found or already in room for ${identity}: ${e}`);
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects a browser-based fake participant from the room.
|
||||||
|
*
|
||||||
|
* @param roomId - The room ID
|
||||||
|
* @param identity - The participant identity to disconnect
|
||||||
|
*/
|
||||||
|
export const disconnectBrowserFakeParticipant = async (roomId: string, identity: string): Promise<void> => {
|
||||||
|
const key = `${roomId}-${identity}`;
|
||||||
|
const participant = browserFakeParticipants.get(key);
|
||||||
|
|
||||||
|
if (participant) {
|
||||||
|
try {
|
||||||
|
await leaveRoom(participant.page);
|
||||||
|
await participant.page.close();
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await participant.context.close();
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
browserFakeParticipants.delete(key);
|
||||||
|
console.log(`👋 Disconnected browser fake participant: ${identity}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects all browser-based fake participants.
|
||||||
|
* Should be called in afterEach or afterAll hooks.
|
||||||
|
*/
|
||||||
|
export const disconnectAllBrowserFakeParticipants = async (): Promise<void> => {
|
||||||
|
const keys = Array.from(browserFakeParticipants.keys());
|
||||||
|
for (const key of keys) {
|
||||||
|
const participant = browserFakeParticipants.get(key);
|
||||||
|
if (participant) {
|
||||||
|
try {
|
||||||
|
await participant.page.close();
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await participant.context.close();
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
browserFakeParticipants.clear();
|
||||||
|
if (keys.length > 0) {
|
||||||
|
console.log(`👋 Disconnected all browser fake participants (${keys.length})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the page object for a browser-based fake participant.
|
||||||
|
* Useful for interacting with the participant's UI (mute/unmute, etc.)
|
||||||
|
*
|
||||||
|
* @param roomId - The room ID
|
||||||
|
* @param identity - The participant identity
|
||||||
|
* @returns The Page object or undefined if not found
|
||||||
|
*/
|
||||||
|
export const getBrowserFakeParticipantPage = (roomId: string, identity: string): Page | undefined => {
|
||||||
|
const key = `${roomId}-${identity}`;
|
||||||
|
return browserFakeParticipants.get(key)?.page;
|
||||||
|
};
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Options for joining a browser-based fake participant
|
||||||
|
*/
|
||||||
|
export interface BrowserFakeParticipantOptions {
|
||||||
|
/** Path to audio file (relative to assets/audio or absolute) - WAV format recommended */
|
||||||
|
audioFile?: string;
|
||||||
|
/** Path to video file (relative to assets/video or absolute) - Y4M or MJPEG format recommended for Chrome */
|
||||||
|
videoFile?: string;
|
||||||
|
/** Participant display name */
|
||||||
|
displayName?: string;
|
||||||
|
/** Whether to enable video (default: true) */
|
||||||
|
enableVideo?: boolean;
|
||||||
|
/** Whether to enable audio (default: true) */
|
||||||
|
enableAudio?: boolean;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user