├── .ruby-version ├── .dockerignore ├── README.md ├── config.ru ├── Dockerfile ├── test ├── simple_rtmp_server.sh ├── test_rtmp_sender.sh ├── test_redis_stream.rb └── test_transcribe.rb ├── Gemfile ├── LICENSE.txt ├── lib ├── simple_sse_app.rb ├── ffmpeg_audio_stream.rb ├── transcribe_client.rb ├── bedrock_translate_client.rb ├── redis_streams_sse_app.rb └── rtmp_transcribe_service.rb ├── .env.example ├── compose.yaml ├── config └── sentry.rb ├── Gemfile.lock └── bin └── rtmp_transcribe_server.rb /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.7 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | tmp/ 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shirataki 2 | 3 | ## Acknowledgments 4 | * [ruby-no-kai/takeout-app](https://github.com/ruby-no-kai/takeout-app) 5 | * [tokyorubykaigi12/captioner](https://github.com/tokyorubykaigi12/captioner) 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | 3 | # Initialize Sentry before loading the app 4 | require_relative 'config/sentry' 5 | 6 | require_relative 'lib/redis_streams_sse_app' 7 | 8 | # Wrap the app with Sentry's Rack middleware 9 | use Sentry::Rack::CaptureExceptions 10 | 11 | app = RedisStreamsSSEApp.new 12 | run app 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.4.7-slim-trixie 2 | 3 | # Install system dependencies 4 | RUN apt-get update && apt-get install -y \ 5 | build-essential \ 6 | libssl-dev \ 7 | ffmpeg 8 | 9 | WORKDIR /app 10 | 11 | # Copy Gemfile and install dependencies 12 | COPY Gemfile Gemfile.lock ./ 13 | RUN bundle install 14 | 15 | # Copy application code 16 | COPY . . 17 | 18 | # Expose ports 19 | # 3000 for SSE server 20 | # 1935 for RTMP (if needed) 21 | EXPOSE 3000 1935 22 | -------------------------------------------------------------------------------- /test/simple_rtmp_server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Simple RTMP server using FFmpeg 4 | # Listens on rtmp://localhost:1935/live 5 | 6 | echo "Starting simple RTMP server on port 1935..." 7 | echo "Stream URL: rtmp://localhost:1935/live" 8 | echo "Press Ctrl+C to stop" 9 | echo "" 10 | 11 | # Start FFmpeg as RTMP server 12 | ffmpeg -listen 1 -i rtmp://localhost:1935/live \ 13 | -c copy -f flv - | \ 14 | ffmpeg -i - \ 15 | -f s16le -acodec pcm_s16le -ar 16000 -ac 1 \ 16 | -f null /dev/null 2>&1 | \ 17 | grep -E "time=|Stream" 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "falcon", "~> 0.51.1" 6 | gem "async" 7 | gem "rack" 8 | gem "async-redis" 9 | gem "json" 10 | 11 | # AWS SDK for Amazon Transcribe and Translate 12 | gem "aws-sdk-transcribestreamingservice" 13 | gem "aws-sdk-translate" 14 | 15 | # AWS SDK for Amazon Bedrock (for Claude translation) 16 | gem "aws-sdk-bedrockruntime" 17 | 18 | # Required for AWS Transcribe Streaming async client 19 | gem "http-2" 20 | 21 | # Concurrent processing 22 | gem "concurrent-ruby" 23 | 24 | # Error tracking and monitoring 25 | gem "sentry-ruby" 26 | -------------------------------------------------------------------------------- /test/test_rtmp_sender.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Test script to send audio to RTMP server 4 | # Usage: ./test_rtmp_sender.sh [rtmp_url] 5 | 6 | RTMP_URL=${1:-rtmp://localhost:1935/live} 7 | 8 | echo "=" 9 | echo "RTMP Test Audio Sender" 10 | echo "=" 11 | echo "Sending test audio to: $RTMP_URL" 12 | echo "Press Ctrl+C to stop" 13 | echo "" 14 | 15 | # Option 1: Send microphone input (if available) 16 | if [ -f /dev/dsp ] || [ -f /dev/audio ]; then 17 | echo "Using microphone input..." 18 | ffmpeg -f alsa -i default \ 19 | -acodec libmp3lame -b:a 128k \ 20 | -f flv "$RTMP_URL" 21 | 22 | # Option 2: Generate test audio (sine wave) 23 | else 24 | echo "Generating test audio (440Hz sine wave)..." 25 | ffmpeg -re -f lavfi -i "sine=frequency=440:duration=0" \ 26 | -f lavfi -i "sine=frequency=880:duration=0" \ 27 | -filter_complex "[0:a][1:a]amerge=inputs=2,pan=stereo|c0 'text/plain'}, ['Not Found']] 15 | end 16 | end 17 | 18 | private 19 | 20 | def handle_sse_stream 21 | headers = { 22 | 'Content-Type' => 'text/event-stream', 23 | 'Cache-Control' => 'no-cache', 24 | 'Connection' => 'keep-alive', 25 | 'Access-Control-Allow-Origin' => '*', 26 | 'Access-Control-Allow-Headers' => 'Cache-Control', 27 | 'X-Accel-Buffering' => 'no' 28 | } 29 | 30 | [200, headers, SSEStream.new] 31 | end 32 | 33 | def handle_health_check 34 | [200, {'Content-Type' => 'application/json'}, ["{\"status\":\"ok\",\"timestamp\":\"#{Time.now.iso8601}\"}"]] 35 | end 36 | end 37 | 38 | class SSEStream 39 | def each 40 | # 初期接続メッセージ 41 | yield "event: connected\n" 42 | yield "data: {\"message\":\"SSE connection established\",\"timestamp\":\"#{Time.now.iso8601}\"}\n\n" 43 | 44 | # 5秒ごとに固定メッセージを送信 45 | count = 0 46 | loop do 47 | count += 1 48 | 49 | # 固定メッセージを送信 50 | yield "event: message\n" 51 | yield "data: {\"count\":#{count},\"message\":\"Hello from SSE! Message ##{count}\",\"timestamp\":\"#{Time.now.iso8601}\"}\n\n" 52 | 53 | # 30秒ごとにハートビート 54 | if count % 6 == 0 55 | yield "event: heartbeat\n" 56 | yield "data: {\"timestamp\":\"#{Time.now.iso8601}\"}\n\n" 57 | end 58 | 59 | sleep 5 60 | end 61 | rescue => e 62 | yield "event: error\n" 63 | yield "data: {\"error\":\"#{e.message}\"}\n\n" 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Redis Configuration 2 | # Redis URL format (supports redis:// and rediss:// for TLS) 3 | # Examples: 4 | # - Standard: redis://localhost:6379/0 5 | # - With password: redis://:password@localhost:6379/0 6 | # - TLS: rediss://localhost:6380/0 7 | # - TLS with password: rediss://:password@localhost:6380/0 8 | REDIS_URL=redis://localhost:6379/0 9 | REDIS_STREAM_KEY=transcription_stream 10 | 11 | # Legacy Redis configuration (deprecated - use REDIS_URL instead) 12 | # REDIS_HOST=localhost 13 | # REDIS_PORT=6379 14 | # REDIS_DB=0 15 | 16 | # AWS Configuration 17 | # AWS_ACCESS_KEY_ID=your_aws_access_key 18 | # AWS_SECRET_ACCESS_KEY=your_aws_secret_key 19 | # AWS_REGION=ap-northeast-1 20 | 21 | # Amazon Transcribe Configuration 22 | TRANSCRIBE_LANGUAGE=ja-JP 23 | TRANSCRIBE_LANGUAGE_CODE=ja-JP 24 | # Optional: Uncomment to use a custom vocabulary 25 | # TRANSCRIBE_VOCABULARY=your_vocabulary_name 26 | 27 | # Translation Configuration (Amazon Bedrock) 28 | # Enable translation to English 29 | ENABLE_TRANSLATION=false 30 | # Bedrock region and model 31 | BEDROCK_REGION=ap-northeast-1 32 | BEDROCK_MODEL_ID=anthropic.claude-3-5-sonnet-20240620-v1:0 33 | # Batch processing settings to reduce API calls 34 | TRANSLATION_BATCH_SIZE=5 35 | TRANSLATION_BATCH_TIMEOUT=2 36 | # Set to true to translate only final transcriptions (reduces API calls significantly) 37 | TRANSLATE_ONLY_FINAL=false 38 | # Minimum text length for translation (characters) 39 | MIN_TRANSLATION_LENGTH=20 40 | 41 | # RTMP Configuration 42 | RTMP_URL=rtmp://localhost:1935/live 43 | ROOM=default 44 | 45 | # Sentry Error Tracking Configuration 46 | # Get your DSN from https://sentry.io/ 47 | SENTRY_DSN=https://your-key@your-org.ingest.sentry.io/your-project-id 48 | SENTRY_ENVIRONMENT=development 49 | # Optional: Set a release version 50 | # SENTRY_RELEASE=1.0.0 51 | # Optional: Sample rates (0.0 to 1.0) 52 | SENTRY_TRACES_SAMPLE_RATE=0.1 53 | SENTRY_PROFILES_SAMPLE_RATE=0.1 54 | 55 | # Debug Mode 56 | # Uncomment to enable debug logging 57 | # DEBUG=true 58 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | valkey: 5 | image: valkey/valkey:7.2-alpine 6 | ports: 7 | - "6379:6379" 8 | command: valkey-server --appendonly yes 9 | volumes: 10 | - valkey-data:/data 11 | healthcheck: 12 | test: ["CMD", "valkey-cli", "ping"] 13 | interval: 5s 14 | timeout: 3s 15 | retries: 5 16 | networks: 17 | - shirataki-net 18 | 19 | # SSE Server 20 | sse-server: 21 | build: . 22 | ports: 23 | - "3000:3000" 24 | environment: 25 | - REDIS_HOST=valkey 26 | - REDIS_PORT=6379 27 | - REDIS_DB=0 28 | - REDIS_STREAM_KEY=transcription_stream 29 | depends_on: 30 | - valkey 31 | volumes: 32 | - ./lib:/app/lib:ro 33 | - ./config.ru:/app/config.ru:ro 34 | command: ["bundle", "exec", "falcon", "serve", "-b", "http://0.0.0.0:3000"] 35 | networks: 36 | - shirataki-net 37 | 38 | # RTMP to Transcribe Service 39 | rtmp-transcribe: 40 | build: . 41 | environment: 42 | - REDIS_HOST=valkey 43 | - REDIS_PORT=6379 44 | - REDIS_DB=0 45 | - REDIS_STREAM_KEY=transcription_stream 46 | - AWS_REGION=${AWS_REGION:-ap-northeast-1} 47 | - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} 48 | - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} 49 | - RTMP_URL=${RTMP_URL:-rtmp://localhost:1935/live} 50 | - TRANSCRIBE_LANGUAGE=${TRANSCRIBE_LANGUAGE:-ja-JP} 51 | - ROOM=${ROOM:-default} 52 | depends_on: 53 | - valkey 54 | volumes: 55 | - ./lib:/app/lib:ro 56 | - ./bin:/app/bin:ro 57 | command: ["ruby", "bin/rtmp_transcribe_server.rb"] 58 | networks: 59 | - shirataki-net 60 | 61 | # Optional: Redis Commander for GUI management 62 | redis-commander: 63 | image: rediscommander/redis-commander:latest 64 | environment: 65 | - REDIS_HOSTS=local:valkey:6379 66 | ports: 67 | - "8081:8081" 68 | depends_on: 69 | - valkey 70 | networks: 71 | - shirataki-net 72 | 73 | networks: 74 | shirataki-net: 75 | driver: bridge 76 | 77 | volumes: 78 | valkey-data: 79 | driver: local 80 | -------------------------------------------------------------------------------- /config/sentry.rb: -------------------------------------------------------------------------------- 1 | require 'sentry-ruby' 2 | 3 | Sentry.init do |config| 4 | # Set DSN from environment variable 5 | config.dsn = ENV['SENTRY_DSN'] 6 | 7 | # Set the environment 8 | config.environment = ENV.fetch('SENTRY_ENVIRONMENT', 'development') 9 | 10 | # Set the release if available 11 | config.release = ENV['SENTRY_RELEASE'] if ENV['SENTRY_RELEASE'] 12 | 13 | # Breadcrumbs configuration 14 | config.breadcrumbs_logger = [:sentry_logger, :http_logger] 15 | 16 | # Sample rate for performance monitoring (0.0 to 1.0) 17 | config.traces_sample_rate = ENV.fetch('SENTRY_TRACES_SAMPLE_RATE', '0.1').to_f 18 | 19 | # Sample rate for profiling (0.0 to 1.0) 20 | config.profiles_sample_rate = ENV.fetch('SENTRY_PROFILES_SAMPLE_RATE', '0.1').to_f 21 | 22 | # Filter sensitive data 23 | config.before_send = lambda do |event, hint| 24 | # Filter out AWS credentials from the event 25 | if event.extra && event.extra.is_a?(Hash) 26 | # Convert keys to strings recursively 27 | filtered_extra = {} 28 | event.extra.each do |k, v| 29 | key_str = k.to_s 30 | # Skip sensitive keys 31 | next if key_str.match?(/aws|key|secret|password|token/i) 32 | filtered_extra[key_str] = v 33 | end 34 | event.extra = filtered_extra 35 | end 36 | 37 | # Filter environment variables 38 | if event.request && event.request.env 39 | event.request.env.delete_if { |k, _| k.match?(/AWS|KEY|SECRET|PASSWORD|TOKEN/i) } 40 | end 41 | 42 | event 43 | end 44 | 45 | # Capture additional context 46 | config.before_breadcrumb = lambda do |breadcrumb, hint| 47 | # Add additional context to breadcrumbs if needed 48 | breadcrumb 49 | end 50 | end 51 | 52 | # Set global tags after initialization 53 | Sentry.configure_scope do |scope| 54 | scope.set_tags( 55 | service: 'shirataki', 56 | component: ENV['SENTRY_COMPONENT'] || 'unknown' 57 | ) 58 | end 59 | 60 | # Helper method to set user/room context 61 | def set_sentry_context(room: nil, language: nil, **extra) 62 | Sentry.configure_scope do |scope| 63 | scope.set_context('transcription', { 64 | room: room, 65 | language: language, 66 | **extra 67 | }) 68 | end 69 | end 70 | 71 | # Helper method to capture exceptions with additional context 72 | def capture_with_context(exception, **context) 73 | Sentry.capture_exception(exception) do |scope| 74 | context.each do |key, value| 75 | scope.set_tag(key, value) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/test_redis_stream.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'async' 5 | require 'async/redis' 6 | require 'json' 7 | 8 | # Redis connection settings 9 | # Parse Redis URL (supports redis:// and rediss:// for TLS) 10 | redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') 11 | redis_endpoint = Async::Redis::Endpoint.parse(redis_url) 12 | 13 | stream_key = ENV.fetch('REDIS_STREAM_KEY', 'transcription_stream') 14 | 15 | def send_test_message(client, stream_key, room: 'default', language: 'ja', text: nil) 16 | message_text = text || "Test message at #{Time.now.iso8601}" 17 | 18 | # XADD stream_key * field1 value1 field2 value2 ... 19 | result = client.call('XADD', stream_key, '*', 20 | 'room', room, 21 | 'language', language, 22 | 'text', message_text, 23 | 'timestamp', Time.now.iso8601, 24 | 'type', 'transcription' 25 | ) 26 | 27 | puts "Sent message with ID: #{result}" 28 | puts " Room: #{room}, Language: #{language}" 29 | puts " Text: #{message_text}" 30 | result 31 | end 32 | 33 | Async do 34 | Async::Redis::Client.open(redis_endpoint) do |client| 35 | puts "Connected to Redis" 36 | puts "Stream key: #{stream_key}" 37 | puts "" 38 | 39 | # Test connection 40 | response = client.call('PING') 41 | puts "Redis PING response: #{response}" 42 | puts "" 43 | 44 | # Send test messages 45 | puts "Sending test messages..." 46 | 47 | # Message 1: Default room, Japanese 48 | send_test_message(client, stream_key, 49 | room: 'default', 50 | language: 'ja', 51 | text: 'こんにちは、これはテストメッセージです。' 52 | ) 53 | 54 | sleep 1 55 | 56 | # Message 2: Default room, English 57 | send_test_message(client, stream_key, 58 | room: 'default', 59 | language: 'en', 60 | text: 'Hello, this is a test message.' 61 | ) 62 | 63 | sleep 1 64 | 65 | # Message 3: Conference room, Japanese 66 | send_test_message(client, stream_key, 67 | room: 'conference', 68 | language: 'ja', 69 | text: '会議室からのメッセージです。' 70 | ) 71 | 72 | sleep 1 73 | 74 | # Message 4: Default room, Japanese (should be received by default SSE client) 75 | send_test_message(client, stream_key, 76 | room: 'default', 77 | language: 'ja', 78 | text: 'SSEクライアントに配信されるメッセージ' 79 | ) 80 | 81 | puts "" 82 | puts "All test messages sent!" 83 | 84 | # Check stream info 85 | info = client.call('XINFO', 'STREAM', stream_key) 86 | puts "" 87 | puts "Stream info:" 88 | puts " Length: #{info[1]}" 89 | puts " First entry: #{info[9]}" 90 | puts " Last entry: #{info[11]}" 91 | end 92 | rescue => e 93 | puts "Error: #{e.message}" 94 | puts e.backtrace.first(5) 95 | end 96 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | async (2.32.0) 5 | console (~> 1.29) 6 | fiber-annotation 7 | io-event (~> 1.11) 8 | metrics (~> 0.12) 9 | traces (~> 0.18) 10 | async-container (0.27.0) 11 | async (~> 2.22) 12 | async-container-supervisor (0.5.2) 13 | async-container (~> 0.22) 14 | async-service 15 | io-endpoint 16 | io-stream 17 | memory-leak (~> 0.5) 18 | async-http (0.91.0) 19 | async (>= 2.10.2) 20 | async-pool (~> 0.11) 21 | io-endpoint (~> 0.14) 22 | io-stream (~> 0.6) 23 | metrics (~> 0.12) 24 | protocol-http (~> 0.49) 25 | protocol-http1 (~> 0.30) 26 | protocol-http2 (~> 0.22) 27 | traces (~> 0.10) 28 | async-http-cache (0.4.5) 29 | async-http (~> 0.56) 30 | async-pool (0.11.0) 31 | async (>= 2.0) 32 | async-redis (0.13.0) 33 | async (~> 2.10) 34 | async-pool (~> 0.2) 35 | io-endpoint (~> 0.10) 36 | io-stream (~> 0.4) 37 | protocol-redis (~> 0.11) 38 | async-service (0.14.4) 39 | async 40 | async-container (~> 0.16) 41 | string-format (~> 0.2) 42 | aws-eventstream (1.4.0) 43 | aws-partitions (1.1162.0) 44 | aws-sdk-bedrockruntime (1.59.0) 45 | aws-sdk-core (~> 3, >= 3.231.0) 46 | aws-sigv4 (~> 1.5) 47 | aws-sdk-core (3.232.0) 48 | aws-eventstream (~> 1, >= 1.3.0) 49 | aws-partitions (~> 1, >= 1.992.0) 50 | aws-sigv4 (~> 1.9) 51 | base64 52 | bigdecimal 53 | jmespath (~> 1, >= 1.6.1) 54 | logger 55 | aws-sdk-transcribestreamingservice (1.90.0) 56 | aws-sdk-core (~> 3, >= 3.231.0) 57 | aws-sigv4 (~> 1.5) 58 | aws-sdk-translate (1.88.0) 59 | aws-sdk-core (~> 3, >= 3.231.0) 60 | aws-sigv4 (~> 1.5) 61 | aws-sigv4 (1.12.1) 62 | aws-eventstream (~> 1, >= 1.0.2) 63 | base64 (0.3.0) 64 | bigdecimal (3.2.3) 65 | concurrent-ruby (1.3.5) 66 | console (1.34.0) 67 | fiber-annotation 68 | fiber-local (~> 1.1) 69 | json 70 | falcon (0.51.1) 71 | async 72 | async-container (~> 0.20) 73 | async-container-supervisor (~> 0.5.0) 74 | async-http (~> 0.75) 75 | async-http-cache (~> 0.4) 76 | async-service (~> 0.10) 77 | bundler 78 | localhost (~> 1.1) 79 | openssl (~> 3.0) 80 | protocol-http (~> 0.31) 81 | protocol-rack (~> 0.7) 82 | samovar (~> 2.3) 83 | fiber-annotation (0.2.0) 84 | fiber-local (1.1.0) 85 | fiber-storage 86 | fiber-storage (1.0.1) 87 | http-2 (1.1.1) 88 | io-endpoint (0.15.2) 89 | io-event (1.14.0) 90 | io-stream (0.10.0) 91 | jmespath (1.6.2) 92 | json (2.14.1) 93 | localhost (1.6.0) 94 | logger (1.7.0) 95 | mapping (1.1.3) 96 | memory-leak (0.5.2) 97 | metrics (0.15.0) 98 | openssl (3.3.0) 99 | protocol-hpack (1.5.1) 100 | protocol-http (0.54.0) 101 | protocol-http1 (0.35.1) 102 | protocol-http (~> 0.22) 103 | protocol-http2 (0.23.0) 104 | protocol-hpack (~> 1.4) 105 | protocol-http (~> 0.47) 106 | protocol-rack (0.16.0) 107 | io-stream (>= 0.10) 108 | protocol-http (~> 0.43) 109 | rack (>= 1.0) 110 | protocol-redis (0.13.0) 111 | rack (3.2.1) 112 | samovar (2.3.0) 113 | console (~> 1.0) 114 | mapping (~> 1.0) 115 | sentry-ruby (5.27.0) 116 | bigdecimal 117 | concurrent-ruby (~> 1.0, >= 1.0.2) 118 | string-format (0.2.0) 119 | traces (0.18.2) 120 | 121 | PLATFORMS 122 | ruby 123 | x86_64-linux 124 | 125 | DEPENDENCIES 126 | async 127 | async-redis 128 | aws-sdk-bedrockruntime 129 | aws-sdk-transcribestreamingservice 130 | aws-sdk-translate 131 | concurrent-ruby 132 | falcon (~> 0.51.1) 133 | http-2 134 | json 135 | rack 136 | sentry-ruby 137 | 138 | BUNDLED WITH 139 | 2.6.7 140 | -------------------------------------------------------------------------------- /bin/rtmp_transcribe_server.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'optparse' 5 | 6 | # Initialize Sentry 7 | require_relative '../config/sentry' 8 | ENV['SENTRY_COMPONENT'] = 'rtmp_transcribe_server' 9 | 10 | require_relative '../lib/rtmp_transcribe_service' 11 | 12 | # Parse command line options 13 | options = { 14 | rtmp_url: ENV.fetch('RTMP_URL', 'rtmp://localhost:1935/live'), 15 | room: ENV.fetch('ROOM', 'default'), 16 | test_mode: false 17 | } 18 | 19 | OptionParser.new do |opts| 20 | opts.banner = "Usage: rtmp_transcribe_server.rb [options]" 21 | 22 | opts.on("-r", "--rtmp URL", "RTMP stream URL") do |url| 23 | options[:rtmp_url] = url 24 | end 25 | 26 | opts.on("-m", "--room ROOM", "Room name for transcriptions") do |room| 27 | options[:room] = room 28 | end 29 | 30 | opts.on("-t", "--test", "Use test audio instead of RTMP") do 31 | options[:test_mode] = true 32 | end 33 | 34 | opts.on("-h", "--help", "Show this help message") do 35 | puts opts 36 | exit 37 | end 38 | end.parse! 39 | 40 | # Override for test mode 41 | if options[:test_mode] 42 | require_relative '../lib/ffmpeg_audio_stream' 43 | # Monkey patch for test mode 44 | class FFmpegAudioStream 45 | def build_ffmpeg_command 46 | cmd = ['ffmpeg'] 47 | # Generate test audio (sine wave) 48 | cmd += [ 49 | '-f', 'lavfi', 50 | '-i', 'sine=frequency=440:duration=600:sample_rate=16000' 51 | ] 52 | # Output options 53 | cmd += [ 54 | '-f', 's16le', 55 | '-acodec', 'pcm_s16le', 56 | '-ar', '16000', 57 | '-ac', '1', 58 | '-vn', 59 | 'pipe:1' 60 | ] 61 | cmd 62 | end 63 | end 64 | puts "TEST MODE: Using generated audio instead of RTMP stream" 65 | end 66 | 67 | # Print configuration 68 | puts "=" * 60 69 | puts "RTMP to Amazon Transcribe Service" 70 | puts "=" * 60 71 | puts "Configuration:" 72 | puts " RTMP URL: #{options[:rtmp_url]}" 73 | puts " Room: #{options[:room]}" 74 | puts " Redis: #{ENV.fetch('REDIS_HOST', 'localhost')}:#{ENV.fetch('REDIS_PORT', 6379)}" 75 | puts " Stream Key: #{ENV.fetch('REDIS_STREAM_KEY', 'transcription_stream')}" 76 | puts " Language: #{ENV.fetch('TRANSCRIBE_LANGUAGE', 'ja-JP')}" 77 | puts " AWS Region: #{ENV.fetch('AWS_REGION', 'ap-northeast-1')}" 78 | puts "" 79 | puts "Translation:" 80 | puts " Enabled: #{ENV.fetch('ENABLE_TRANSLATION', 'false')}" 81 | if ENV.fetch('ENABLE_TRANSLATION', 'false').downcase == 'true' 82 | puts " Bedrock Model: #{ENV.fetch('BEDROCK_MODEL_ID', 'anthropic.claude-3-5-sonnet-20240620-v1:0')}" 83 | puts " Bedrock Region: #{ENV.fetch('BEDROCK_REGION', 'ap-northeast-1')}" 84 | end 85 | puts "=" * 60 86 | puts "" 87 | 88 | # Create and start service 89 | service = RtmpTranscribeService.new( 90 | rtmp_url: options[:rtmp_url], 91 | room: options[:room], 92 | test_mode: options[:test_mode] 93 | ) 94 | 95 | # Set up signal handlers 96 | @shutdown = false 97 | 98 | Signal.trap("INT") do 99 | puts "\nReceived INT signal, stopping..." 100 | @shutdown = true 101 | end 102 | 103 | Signal.trap("TERM") do 104 | puts "\nReceived TERM signal, stopping..." 105 | @shutdown = true 106 | end 107 | 108 | # Monitor for shutdown in separate thread 109 | Thread.new do 110 | loop do 111 | if @shutdown 112 | service.stop 113 | exit 0 114 | end 115 | sleep 0.1 116 | end 117 | end 118 | 119 | # Start the service 120 | begin 121 | # Set Sentry context for this service 122 | Sentry.configure_scope do |scope| 123 | scope.set_context('service', { 124 | rtmp_url: options[:rtmp_url], 125 | room: options[:room], 126 | test_mode: options[:test_mode] 127 | }) 128 | end 129 | 130 | # Wrap service.start in Async reactor for proper async task handling 131 | require 'async' 132 | 133 | Async do 134 | service.start 135 | end 136 | 137 | rescue => e 138 | puts "Fatal error: #{e.message}" 139 | puts e.backtrace.first(10) 140 | 141 | # Report to Sentry 142 | Sentry.capture_exception(e) 143 | 144 | exit 1 145 | end 146 | -------------------------------------------------------------------------------- /test/test_transcribe.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'aws-sdk-transcribestreamingservice' 4 | require 'logger' 5 | 6 | # Setup logger 7 | logger = Logger.new(STDOUT) 8 | logger.formatter = proc do |severity, datetime, progname, msg| 9 | "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n" 10 | end 11 | 12 | begin 13 | # Configuration 14 | region = ENV.fetch('AWS_REGION', 'ap-northeast-1') 15 | language_code = ENV.fetch('TRANSCRIBE_LANGUAGE', 'ja-JP') 16 | 17 | logger.info "Testing AWS Transcribe Streaming Service" 18 | logger.info "Region: #{region}" 19 | logger.info "Language: #{language_code}" 20 | 21 | # Check credentials 22 | if ENV['AWS_ACCESS_KEY_ID'] 23 | logger.info "Using AWS credentials from environment variables" 24 | elsif ENV['AWS_PROFILE'] 25 | logger.info "Using AWS profile: #{ENV['AWS_PROFILE']}" 26 | else 27 | logger.info "Using default credential chain (IAM role, etc.)" 28 | end 29 | 30 | # Initialize client 31 | logger.info "Initializing Transcribe client..." 32 | client = Aws::TranscribeStreamingService::AsyncClient.new( 33 | region: region 34 | ) 35 | logger.info "Client initialized successfully" 36 | 37 | # Prepare request parameters 38 | request_params = { 39 | language_code: language_code, 40 | media_sample_rate_hertz: 16000, 41 | media_encoding: 'pcm' 42 | } 43 | 44 | # Add optional parameters if they exist 45 | if ENV['TRANSCRIBE_VOCABULARY'] 46 | request_params[:vocabulary_name] = ENV['TRANSCRIBE_VOCABULARY'] 47 | logger.info "Using vocabulary: #{ENV['TRANSCRIBE_VOCABULARY']}" 48 | end 49 | 50 | if ENV['TRANSCRIBE_LANGUAGE_MODEL_NAME'] 51 | request_params[:language_model_name] = ENV['TRANSCRIBE_LANGUAGE_MODEL_NAME'] 52 | logger.info "Using language model: #{ENV['TRANSCRIBE_LANGUAGE_MODEL_NAME']}" 53 | end 54 | 55 | # Create event streams 56 | logger.info "Creating event streams..." 57 | input_stream = Aws::TranscribeStreamingService::EventStreams::AudioStream.new 58 | output_stream = Aws::TranscribeStreamingService::EventStreams::TranscriptResultStream.new 59 | 60 | # Setup output handlers 61 | output_stream.on_transcript_event_event do |event| 62 | logger.info "Received transcript event" 63 | if event.transcript && event.transcript.results 64 | event.transcript.results.each do |result| 65 | if result.alternatives && !result.alternatives.empty? 66 | text = result.alternatives.first.transcript 67 | logger.info "Transcript: #{text}" 68 | end 69 | end 70 | end 71 | end 72 | 73 | output_stream.on_bad_request_exception_event do |event| 74 | logger.error "Bad request: #{event.message}" 75 | end 76 | 77 | output_stream.on_limit_exceeded_exception_event do |event| 78 | logger.error "Limit exceeded: #{event.message}" 79 | end 80 | 81 | output_stream.on_internal_failure_exception_event do |event| 82 | logger.error "Internal failure: #{event.message}" 83 | end 84 | 85 | output_stream.on_conflict_exception_event do |event| 86 | logger.error "Conflict: #{event.message}" 87 | end 88 | 89 | output_stream.on_service_unavailable_exception_event do |event| 90 | logger.error "Service unavailable: #{event.message}" 91 | end 92 | 93 | output_stream.on_error_event do |event| 94 | logger.error "Stream error: #{event.inspect}" 95 | end 96 | 97 | # Add stream handlers to request 98 | request_params[:input_event_stream_handler] = input_stream 99 | request_params[:output_event_stream_handler] = output_stream 100 | 101 | # Start transcription stream 102 | logger.info "Starting transcription stream..." 103 | logger.info "Request parameters: #{request_params.reject { |k, _| k.to_s.include?('stream') }.inspect}" 104 | 105 | async_response = client.start_stream_transcription(request_params) 106 | 107 | logger.info "Stream started successfully!" 108 | 109 | # Send test audio (silence) 110 | logger.info "Sending test audio (silence)..." 111 | 112 | # Create 1 second of silence (16000 Hz, 16-bit, mono = 32000 bytes) 113 | silence = "\x00" * 32000 114 | 115 | # Send a few chunks 116 | 3.times do |i| 117 | logger.info "Sending chunk #{i + 1}..." 118 | input_stream.signal_audio_event_event(audio_chunk: silence) 119 | sleep(1) 120 | end 121 | 122 | # Signal end of stream 123 | logger.info "Signaling end of stream..." 124 | input_stream.signal_end_stream 125 | 126 | # Wait for response 127 | logger.info "Waiting for response..." 128 | result = async_response.wait 129 | 130 | logger.info "Test completed successfully!" 131 | logger.info "Response: #{result.inspect}" 132 | 133 | rescue Aws::Errors::MissingCredentialsError => e 134 | logger.error "AWS credentials not found!" 135 | logger.error "Error: #{e.message}" 136 | logger.error "Please ensure AWS credentials are configured:" 137 | logger.error " - Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables" 138 | logger.error " - Or use AWS_PROFILE" 139 | logger.error " - Or use IAM role if running on EC2" 140 | exit 1 141 | 142 | rescue Aws::TranscribeStreamingService::Errors::ServiceError => e 143 | logger.error "AWS Transcribe service error!" 144 | logger.error "Error class: #{e.class.name}" 145 | logger.error "Error message: #{e.message}" 146 | logger.error "Error code: #{e.code}" if e.respond_to?(:code) 147 | logger.error "Status code: #{e.status_code}" if e.respond_to?(:status_code) 148 | exit 1 149 | 150 | rescue => e 151 | logger.error "Unexpected error!" 152 | logger.error "Error class: #{e.class.name}" 153 | logger.error "Error message: #{e.message}" 154 | logger.error "Backtrace:" 155 | e.backtrace.first(5).each { |line| logger.error " #{line}" } 156 | exit 1 157 | end -------------------------------------------------------------------------------- /lib/ffmpeg_audio_stream.rb: -------------------------------------------------------------------------------- 1 | require 'open3' 2 | require 'concurrent' 3 | require 'logger' 4 | require 'sentry-ruby' 5 | 6 | class FFmpegAudioStream 7 | attr_reader :buffer 8 | 9 | def initialize(rtmp_url: nil, input_format: nil, logger: nil) 10 | @rtmp_url = rtmp_url || ENV.fetch('RTMP_URL', 'rtmp://localhost:1935/live') 11 | @input_format = input_format 12 | @buffer = Concurrent::Array.new 13 | @running = Concurrent::AtomicBoolean.new(false) 14 | @ffmpeg_process = nil 15 | @threads = [] 16 | @logger = logger || Logger.new(STDOUT).tap do |log| 17 | log.formatter = proc do |severity, datetime, progname, msg| 18 | "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] [FFmpegAudioStream] #{severity}: #{msg}\n" 19 | end 20 | end 21 | end 22 | 23 | def start 24 | return if @running.true? 25 | 26 | @running.make_true 27 | start_ffmpeg 28 | end 29 | 30 | def stop 31 | return unless @running.true? 32 | 33 | @logger.info "Stopping audio stream..." 34 | @running.make_false 35 | 36 | if @ffmpeg_process 37 | begin 38 | Process.kill('TERM', @ffmpeg_process.pid) 39 | @ffmpeg_process.value 40 | rescue => e 41 | @logger.error "Error stopping FFmpeg: #{e.message}" 42 | Sentry.capture_exception(e) 43 | end 44 | end 45 | 46 | @threads.each(&:kill) 47 | @threads.clear 48 | @buffer.clear 49 | end 50 | 51 | def running? 52 | @running.value 53 | end 54 | 55 | def monitor_ffmpeg_process 56 | @logger.info "FFmpeg process monitor started" 57 | check_count = 0 58 | 59 | while @running.value 60 | begin 61 | sleep(2) 62 | check_count += 1 63 | 64 | if @ffmpeg_process.nil? 65 | @logger.error "FFmpeg process is nil!" 66 | @running.make_false 67 | break 68 | end 69 | 70 | pid_status = Process.waitpid2(@ffmpeg_process.pid, Process::WNOHANG) 71 | if pid_status 72 | exit_status = pid_status[1] 73 | @logger.error "FFmpeg process exited unexpectedly!" 74 | @logger.error "Exit status: #{exit_status}" 75 | @logger.error "Exit code: #{exit_status.exitstatus}" if exit_status.respond_to?(:exitstatus) 76 | @running.make_false 77 | break 78 | end 79 | 80 | # Log process status every 30 seconds 81 | if check_count % 15 == 0 82 | @logger.debug "FFmpeg process still running (PID: #{@ffmpeg_process.pid}, buffer: #{@buffer.size} bytes)" if ENV['DEBUG'] 83 | end 84 | 85 | rescue => e 86 | @logger.error "Error monitoring FFmpeg process: #{e.message}" 87 | break 88 | end 89 | end 90 | 91 | @logger.info "FFmpeg process monitor stopped" 92 | start_ffmpeg 93 | rescue => e 94 | @logger.error "FFmpeg monitor thread error: #{e.message}" 95 | end 96 | 97 | def read_chunk(size = 32000) 98 | return nil unless running? 99 | 100 | chunk = [] 101 | size.times do 102 | break if @buffer.empty? 103 | byte = @buffer.shift 104 | chunk << byte if byte 105 | end 106 | 107 | return nil if chunk.empty? 108 | chunk.pack('C*') 109 | end 110 | 111 | private 112 | 113 | def start_ffmpeg 114 | # FFmpeg command to convert RTMP stream to PCM audio 115 | # Output format: PCM 16kHz, 16-bit, mono, little-endian 116 | cmd = build_ffmpeg_command 117 | 118 | @logger.info "Starting FFmpeg with command:" 119 | @logger.info " #{cmd.join(' ')}" 120 | 121 | @stdin, @stdout, @stderr, @ffmpeg_process = Open3.popen3(*cmd) 122 | @stdout.binmode 123 | 124 | # Start threads to read FFmpeg output 125 | @threads << Thread.new { read_audio_stream } 126 | @threads << Thread.new { read_error_stream } 127 | @threads << Thread.new { monitor_ffmpeg_process } # Add process monitor 128 | 129 | @logger.info "FFmpeg started with PID: #{@ffmpeg_process.pid}" 130 | 131 | # Check if process is actually running 132 | sleep(0.5) 133 | pid_status = Process.waitpid2(@ffmpeg_process.pid, Process::WNOHANG) 134 | if pid_status 135 | @logger.error "FFmpeg process exited immediately after starting!" 136 | @logger.error "Exit status: #{pid_status[1]}" 137 | else 138 | @logger.info "FFmpeg process is running" 139 | end 140 | end 141 | 142 | def build_ffmpeg_command 143 | # Check if sudo is needed for binding to all interfaces 144 | cmd = if @rtmp_url.include?('0.0.0.0') 145 | @logger.info "Detected 0.0.0.0 in RTMP URL, using sudo for ffmpeg" 146 | ['sudo', 'ffmpeg'] 147 | else 148 | ['ffmpeg'] 149 | end 150 | 151 | # Input options 152 | if @input_format == 'test' 153 | # Generate test audio (sine wave) 154 | cmd += [ 155 | '-f', 'lavfi', 156 | '-i', 'sine=frequency=440:duration=60' 157 | ] 158 | else 159 | # RTMP input 160 | cmd += [ 161 | '-listen', '1', 162 | '-f', 'flv', 163 | '-i', @rtmp_url, 164 | ] 165 | end 166 | 167 | # Output options - PCM audio for Amazon Transcribe 168 | cmd += [ 169 | '-f', 's16le', # 16-bit little-endian PCM 170 | '-acodec', 'pcm_s16le', 171 | '-ar', '16000', # 16kHz sample rate 172 | '-ac', '1', # Mono 173 | '-vn', # No video 174 | 'pipe:1' # Output to stdout 175 | ] 176 | 177 | cmd 178 | end 179 | 180 | def read_audio_stream 181 | bytes_read = 0 182 | chunks_read = 0 183 | last_log_time = Time.now 184 | 185 | @logger.info "Audio reader thread started" 186 | 187 | while @running.value 188 | begin 189 | # Read audio data in chunks 190 | chunk = @stdout.read(32000) 191 | 192 | if chunk.nil? 193 | @logger.warn "Audio stream ended (read returned nil)" 194 | break 195 | end 196 | 197 | if chunk.empty? 198 | @logger.debug "Read empty chunk from FFmpeg stdout" if ENV['DEBUG'] 199 | next 200 | end 201 | 202 | chunk_size = chunk.bytesize 203 | bytes_read += chunk_size 204 | chunks_read += 1 205 | 206 | # Add bytes to buffer 207 | chunk.bytes.each { |byte| @buffer << byte } 208 | 209 | # Log progress every 5 seconds 210 | if Time.now - last_log_time > 5 211 | @logger.info "Audio reader stats: chunks=#{chunks_read}, bytes=#{bytes_read}, buffer_size=#{@buffer.size}" 212 | last_log_time = Time.now 213 | end 214 | 215 | # Keep buffer size reasonable (max ~10 seconds of audio) 216 | if @buffer.size > 32000 217 | # Remove oldest data 218 | removed = @buffer.size - 32000 219 | removed.times { @buffer.shift } 220 | @logger.debug "Buffer overflow: removed #{removed} bytes" if ENV['DEBUG'] 221 | end 222 | 223 | rescue => e 224 | @logger.error "Error reading audio: #{e.message}" 225 | 226 | Sentry.capture_exception(e) do |scope| 227 | scope.set_tag('component', 'ffmpeg_audio_reader') 228 | scope.set_context('stream', { 229 | buffer_size: @buffer.size, 230 | rtmp_url: @rtmp_url 231 | }) 232 | end 233 | 234 | break 235 | end 236 | end 237 | rescue => e 238 | @logger.error "Audio thread error: #{e.message}" 239 | 240 | Sentry.capture_exception(e) do |scope| 241 | scope.set_tag('component', 'ffmpeg_audio_thread') 242 | end 243 | ensure 244 | @logger.info "Audio stream thread stopped" 245 | end 246 | 247 | def read_error_stream 248 | while @running.value 249 | begin 250 | line = @stderr.gets 251 | break unless line 252 | 253 | # Always log FFmpeg messages when running on EC2 or when debugging 254 | # This helps diagnose connection issues 255 | if line.include?('error') || line.include?('Error') 256 | @logger.error "[FFmpeg] #{line.strip}" 257 | 258 | # Report critical FFmpeg errors to Sentry 259 | if line.downcase.include?('fatal') 260 | Sentry.capture_message( 261 | "FFmpeg fatal error: #{line.strip}", 262 | level: 'error' 263 | ) 264 | end 265 | elsif line.include?('warning') || line.include?('Warning') 266 | @logger.warn "[FFmpeg] #{line.strip}" 267 | elsif line.include?('rtmp') || line.include?('RTMP') || line.include?('flv') || line.include?('FLV') 268 | # Always log RTMP/FLV related messages for debugging connection issues 269 | @logger.info "[FFmpeg] #{line.strip}" 270 | elsif line.include?('Input #') || line.include?('Output #') || line.include?('Stream #') 271 | # Log stream information 272 | @logger.info "[FFmpeg] #{line.strip}" 273 | elsif ENV['DEBUG'] || ENV['VERBOSE_FFMPEG'] 274 | @logger.debug "[FFmpeg] #{line.strip}" 275 | end 276 | 277 | rescue => e 278 | @logger.error "Error reading stderr: #{e.message}" 279 | 280 | Sentry.capture_exception(e) do |scope| 281 | scope.set_tag('component', 'ffmpeg_stderr_reader') 282 | end 283 | 284 | break 285 | end 286 | end 287 | rescue => e 288 | @logger.error "Error thread error: #{e.message}" 289 | 290 | Sentry.capture_exception(e) do |scope| 291 | scope.set_tag('component', 'ffmpeg_error_thread') 292 | end 293 | ensure 294 | @logger.info "Error stream thread stopped" 295 | end 296 | end 297 | 298 | if __FILE__ == $0 299 | audio_stream = FFmpegAudioStream.new 300 | audio_stream.start 301 | 302 | while audio_stream.running? 303 | sleep 2 304 | end 305 | end 306 | -------------------------------------------------------------------------------- /lib/transcribe_client.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk-transcribestreamingservice' 2 | require 'concurrent' 3 | require 'json' 4 | require 'logger' 5 | require 'sentry-ruby' 6 | 7 | class TranscribeClient 8 | attr_reader :results 9 | 10 | def initialize(language_code: nil, region: nil, logger: nil) 11 | @language_code = language_code || ENV.fetch('TRANSCRIBE_LANGUAGE', 'ja-JP') 12 | @region = region || ENV.fetch('AWS_REGION', 'ap-northeast-1') 13 | @results = Concurrent::Array.new 14 | @running = Concurrent::AtomicBoolean.new(false) 15 | @client = nil 16 | @stream = nil 17 | @max_results_size = ENV.fetch('MAX_RESULTS_SIZE', '1000').to_i 18 | @logger = logger || Logger.new(STDOUT).tap do |log| 19 | log.formatter = proc do |severity, datetime, progname, msg| 20 | "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] [TranscribeClient] #{severity}: #{msg}\n" 21 | end 22 | end 23 | end 24 | 25 | def start(audio_stream) 26 | return if @running.true? 27 | 28 | @running.make_true 29 | @audio_stream = audio_stream 30 | 31 | begin 32 | initialize_client 33 | start_transcription_async 34 | rescue => e 35 | @logger.error "Failed to start: #{e.message}" 36 | 37 | # Report to Sentry 38 | Sentry.capture_exception(e) do |scope| 39 | scope.set_tag('component', 'transcribe_client') 40 | scope.set_context('transcribe', { 41 | language_code: @language_code, 42 | region: @region 43 | }) 44 | end 45 | 46 | @running.make_false 47 | raise 48 | end 49 | end 50 | 51 | def stop 52 | return unless @running.true? 53 | 54 | @logger.info "Stopping transcription..." 55 | @running.make_false 56 | 57 | if @input_stream 58 | begin 59 | @input_stream.signal_end_stream 60 | rescue HTTP2::Error::StreamClosed => e 61 | # This is expected during shutdown - the stream may already be closed 62 | @logger.debug "Stream already closed during shutdown" if ENV['DEBUG'] 63 | rescue => e 64 | @logger.error "Error signaling end: #{e.message}" 65 | Sentry.capture_exception(e) 66 | end 67 | end 68 | 69 | # Kill threads 70 | @audio_thread.kill if @audio_thread && @audio_thread.alive? 71 | @monitor_thread.kill if @monitor_thread && @monitor_thread.alive? 72 | end 73 | 74 | def running? 75 | @running.value 76 | end 77 | 78 | private 79 | 80 | def initialize_client 81 | @client = Aws::TranscribeStreamingService::AsyncClient.new( 82 | region: @region, 83 | ) 84 | end 85 | 86 | def start_transcription_async 87 | @logger.info "Starting transcription with language: #{@language_code}" 88 | 89 | request_params = build_request_params 90 | 91 | # Create input stream for sending audio 92 | input_stream = Aws::TranscribeStreamingService::EventStreams::AudioStream.new 93 | @input_stream = input_stream 94 | 95 | # Create output stream and set up handlers 96 | output_stream = Aws::TranscribeStreamingService::EventStreams::TranscriptResultStream.new 97 | @output_stream = output_stream 98 | 99 | # Set up output stream event handlers (like serve.rb) 100 | output_stream.on_transcript_event_event do |event| 101 | process_transcript_event(event) 102 | end 103 | 104 | output_stream.on_bad_request_exception_event do |event| 105 | @logger.error "Bad request: #{event.message}" 106 | 107 | Sentry.capture_message( 108 | "AWS Transcribe bad request: #{event.message}", 109 | level: 'error' 110 | ) 111 | 112 | @running.make_false 113 | end 114 | 115 | output_stream.on_limit_exceeded_exception_event do |event| 116 | @logger.error "Limit exceeded: #{event.message}" 117 | 118 | Sentry.capture_message( 119 | "AWS Transcribe limit exceeded: #{event.message}", 120 | level: 'error' 121 | ) 122 | 123 | @running.make_false 124 | end 125 | 126 | output_stream.on_internal_failure_exception_event do |event| 127 | @logger.error "Internal failure: #{event.message}" 128 | 129 | Sentry.capture_message( 130 | "AWS Transcribe internal failure: #{event.message}", 131 | level: 'error' 132 | ) 133 | 134 | @running.make_false 135 | end 136 | 137 | output_stream.on_conflict_exception_event do |event| 138 | @logger.error "Conflict: #{event.message}" 139 | @running.make_false 140 | end 141 | 142 | output_stream.on_service_unavailable_exception_event do |event| 143 | @logger.error "Service unavailable: #{event.message}" 144 | 145 | Sentry.capture_message( 146 | "AWS Transcribe service unavailable: #{event.message}", 147 | level: 'error' 148 | ) 149 | 150 | @running.make_false 151 | end 152 | 153 | output_stream.on_error_event do |event| 154 | @logger.error "Stream error: #{event.inspect}" 155 | @running.make_false 156 | end 157 | 158 | # Add input_event_stream_handler to params 159 | request_params[:input_event_stream_handler] = input_stream 160 | request_params[:output_event_stream_handler] = output_stream 161 | 162 | 163 | # Start the transcription stream (like serve.rb) 164 | @logger.info "Initializing transcription stream..." 165 | @async_response = @client.start_stream_transcription(request_params) 166 | 167 | # Now that the stream is established, start sending audio 168 | @logger.info "Starting audio transmission..." 169 | @audio_thread = Thread.new { send_audio_chunks(input_stream) } 170 | 171 | # Start a monitoring thread instead of blocking 172 | @monitor_thread = Thread.new do 173 | begin 174 | @logger.info "Monitoring transcription stream..." 175 | result = @async_response.wait 176 | @logger.info "Transcription stream completed: #{result.inspect}" 177 | rescue => e 178 | @logger.error "Transcription stream error: #{e.message}" 179 | 180 | Sentry.capture_exception(e) do |scope| 181 | scope.set_tag('component', 'transcribe_monitor') 182 | end 183 | 184 | @running.make_false 185 | ensure 186 | # Ensure audio thread is stopped 187 | @running.make_false 188 | @audio_thread.kill if @audio_thread && @audio_thread.alive? 189 | end 190 | end 191 | 192 | @logger.info "Transcription started successfully (non-blocking)" 193 | end 194 | 195 | def build_request_params 196 | params = { 197 | language_code: @language_code, 198 | media_sample_rate_hertz: 16000, 199 | media_encoding: 'pcm', 200 | enable_partial_results_stabilization: true, 201 | partial_results_stability: 'high' 202 | } 203 | 204 | # Add vocabulary if specified 205 | if ENV['TRANSCRIBE_VOCABULARY'] 206 | params[:vocabulary_name] = ENV['TRANSCRIBE_VOCABULARY'] 207 | end 208 | 209 | # Add language model name if specified 210 | if ENV['TRANSCRIBE_LANGUAGE_MODEL_NAME'] 211 | params[:language_model_name] = ENV['TRANSCRIBE_LANGUAGE_MODEL_NAME'] 212 | end 213 | 214 | # Enable speaker identification for supported languages 215 | if ['en-US', 'en-GB', 'es-US', 'fr-CA', 'fr-FR', 'de-DE'].include?(@language_code) 216 | params[:show_speaker_label] = true 217 | params[:enable_channel_identification] = false 218 | end 219 | 220 | params 221 | end 222 | 223 | def send_audio_chunks(stream) 224 | @logger.info "Starting to send audio chunks..." 225 | chunks_sent = 0 226 | total_bytes_sent = 0 227 | 228 | while @running.value 229 | begin 230 | # Read 200ms of audio (6400 bytes at 16kHz 16-bit mono) 231 | # AWS Transcribe recommends chunks between 200-500ms for optimal performance 232 | chunk = @audio_stream.read_chunk(32000) 233 | 234 | if chunk && !chunk.empty? 235 | chunk_size = chunk.bytesize 236 | 237 | # Send audio event 238 | stream.signal_audio_event_event( 239 | audio_chunk: chunk 240 | ) 241 | chunks_sent += 1 242 | total_bytes_sent += chunk_size 243 | 244 | # Log progress every 5 chunks (1 second) 245 | if chunks_sent % 5 == 0 246 | @logger.info "Progress: Sent #{chunks_sent} chunks, #{total_bytes_sent} bytes total (#{chunks_sent * 1000}ms)" 247 | end 248 | 249 | # Small delay to prevent overwhelming the API 250 | sleep(0.18) # Slightly less than 200ms to account for processing time 251 | else 252 | # No audio available, wait a bit 253 | @logger.debug "No audio available, buffer size: #{@audio_stream.buffer.size}" if ENV['DEBUG'] 254 | sleep(0.1) 255 | end 256 | 257 | rescue HTTP2::Error::StreamClosed => e 258 | @logger.error "AWS Transcribe stream unexpectedly closed: #{e.message}" 259 | 260 | Sentry.capture_exception(e) do |scope| 261 | scope.set_tag('component', 'audio_sender') 262 | scope.set_context('stream', { 263 | chunks_sent: chunks_sent, 264 | total_bytes: total_bytes_sent 265 | }) 266 | end 267 | 268 | @running.make_false 269 | break 270 | rescue => e 271 | @logger.error "Error sending audio: #{e.message}" 272 | 273 | Sentry.capture_exception(e) do |scope| 274 | scope.set_tag('component', 'audio_sender') 275 | end 276 | 277 | @running.make_false 278 | break 279 | end 280 | end 281 | 282 | # Signal end of stream 283 | begin 284 | stream.signal_end_stream if stream && @running.value 285 | @logger.info "Signaled end of audio stream" 286 | rescue => e 287 | @logger.debug "Error signaling end: #{e.message}" if ENV['DEBUG'] 288 | end 289 | end 290 | 291 | def process_transcript_event(event) 292 | return unless event.transcript 293 | 294 | event.transcript.results.each do |result| 295 | next if result.alternatives.empty? 296 | 297 | alternative = result.alternatives.first 298 | text = alternative.transcript 299 | @logger.debug "Received transcript: #{result.inspect}" if ENV['DEBUG'] 300 | 301 | # Skip empty results 302 | next if text.nil? || text.strip.empty? 303 | 304 | # Build result object 305 | result_data = { 306 | text: text, 307 | is_final: !result.is_partial, 308 | timestamp: Time.now.iso8601, 309 | language: @language_code.split('-').first 310 | } 311 | 312 | # Add confidence score if available 313 | if alternative.items && !alternative.items.empty? 314 | confidences = alternative.items.map(&:confidence).compact 315 | if confidences.any? 316 | result_data[:confidence] = confidences.sum / confidences.size.to_f 317 | end 318 | end 319 | 320 | # Add speaker label if available 321 | if alternative.items && alternative.items.any? { |item| item.speaker } 322 | speakers = alternative.items.map(&:speaker).compact.uniq 323 | result_data[:speakers] = speakers if speakers.any? 324 | end 325 | 326 | # Add to results 327 | @results << result_data 328 | 329 | # Log the result 330 | if result_data[:is_final] 331 | @logger.info "Final: #{text}" 332 | else 333 | @logger.debug "Partial: #{text}" if ENV['DEBUG'] 334 | end 335 | 336 | end 337 | rescue => e 338 | @logger.error "Error processing transcript: #{e.message}" 339 | 340 | Sentry.capture_exception(e) do |scope| 341 | scope.set_tag('component', 'transcript_processor') 342 | end 343 | end 344 | end 345 | -------------------------------------------------------------------------------- /lib/bedrock_translate_client.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk-bedrockruntime' 2 | require 'json' 3 | require 'logger' 4 | require 'sentry-ruby' 5 | require 'concurrent' 6 | 7 | class BedrockTranslateClient 8 | def initialize(region: nil, model_id: nil, logger: nil) 9 | @region = region || ENV.fetch('BEDROCK_REGION', 'ap-northeast-1') 10 | @model_id = model_id || ENV.fetch('BEDROCK_MODEL_ID', 'anthropic.claude-3-5-sonnet-20240620-v1:0') 11 | @enabled = ENV.fetch('ENABLE_TRANSLATION', 'false').downcase == 'true' 12 | 13 | # Cache for translations to avoid re-translating identical text 14 | @translation_cache = Concurrent::Hash.new 15 | @cache_max_size = 100 16 | 17 | # Batch processing configuration 18 | @batch_size = ENV.fetch('TRANSLATION_BATCH_SIZE', '5').to_i 19 | @translate_only_final = ENV.fetch('TRANSLATE_ONLY_FINAL', 'false').downcase == 'true' 20 | 21 | @logger = logger || Logger.new(STDOUT).tap do |log| 22 | log.formatter = proc do |severity, datetime, progname, msg| 23 | "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] [BedrockTranslateClient] #{severity}: #{msg}\n" 24 | end 25 | end 26 | 27 | if @enabled 28 | initialize_client 29 | @logger.info "Translation enabled with model: #{@model_id}" 30 | else 31 | @logger.info "Translation disabled (set ENABLE_TRANSLATION=true to enable)" 32 | end 33 | end 34 | 35 | def enabled? 36 | @enabled 37 | end 38 | 39 | def translate_only_final? 40 | @translate_only_final 41 | end 42 | 43 | def batch_size 44 | @batch_size 45 | end 46 | 47 | def translate(text, source_lang: 'ja', target_lang: 'en') 48 | return nil unless @enabled 49 | return nil if text.nil? || text.strip.empty? 50 | 51 | # Check cache first 52 | cache_key = "#{source_lang}:#{target_lang}:#{text}" 53 | cached = @translation_cache[cache_key] 54 | if cached 55 | @logger.debug "Translation found in cache" if ENV['DEBUG'] 56 | return cached 57 | end 58 | 59 | begin 60 | # Prepare the prompt for translation 61 | prompt = build_translation_prompt(text, source_lang, target_lang) 62 | 63 | # Build request body for Claude 64 | request_body = { 65 | anthropic_version: 'bedrock-2023-05-31', 66 | max_tokens: 2048, 67 | temperature: 0.3, # Lower temperature for more consistent translations 68 | messages: [ 69 | { 70 | role: 'user', 71 | content: prompt 72 | } 73 | ] 74 | } 75 | 76 | @logger.debug "Translating: #{text[0..50]}..." if ENV['DEBUG'] 77 | 78 | # Invoke the model 79 | response = @client.invoke_model( 80 | model_id: @model_id, 81 | content_type: 'application/json', 82 | accept: 'application/json', 83 | body: request_body.to_json 84 | ) 85 | 86 | # Parse the response 87 | response_body = JSON.parse(response.body.read) 88 | translated_text = extract_translation(response_body) 89 | 90 | # Cache the translation 91 | add_to_cache(cache_key, translated_text) 92 | 93 | @logger.info "Translated: #{text[0..30]}... -> #{translated_text[0..30]}..." 94 | 95 | translated_text 96 | 97 | rescue Aws::BedrockRuntime::Errors::ServiceError => e 98 | @logger.error "AWS Bedrock error: #{e.message}" 99 | 100 | Sentry.capture_exception(e) do |scope| 101 | scope.set_tag('component', 'bedrock_translate') 102 | scope.set_context('translation', { 103 | text: text[0..100], 104 | source_lang: source_lang, 105 | target_lang: target_lang, 106 | model_id: @model_id 107 | }) 108 | end 109 | 110 | nil 111 | 112 | rescue => e 113 | @logger.error "Translation error: #{e.message}" 114 | 115 | Sentry.capture_exception(e) do |scope| 116 | scope.set_tag('component', 'bedrock_translate') 117 | end 118 | 119 | nil 120 | end 121 | end 122 | 123 | # Batch translation method to reduce API calls 124 | def translate_batch(texts_array, source_lang: 'ja', target_lang: 'en') 125 | return [] unless @enabled 126 | return [] if texts_array.nil? || texts_array.empty? 127 | 128 | # Filter out empty texts and check cache 129 | texts_to_translate = [] 130 | cached_results = {} 131 | 132 | texts_array.each_with_index do |text, index| 133 | next if text.nil? || text.strip.empty? 134 | 135 | cache_key = "#{source_lang}:#{target_lang}:#{text}" 136 | cached = @translation_cache[cache_key] 137 | 138 | if cached 139 | cached_results[index] = cached 140 | @logger.debug "Translation #{index} found in cache" if ENV['DEBUG'] 141 | else 142 | texts_to_translate << { index: index, text: text } 143 | end 144 | end 145 | 146 | # If all translations are cached, return them 147 | if texts_to_translate.empty? 148 | return texts_array.map.with_index { |_, i| cached_results[i] } 149 | end 150 | 151 | begin 152 | # Build batch translation prompt 153 | prompt = build_batch_translation_prompt(texts_to_translate, source_lang, target_lang) 154 | 155 | # Build request body for Claude 156 | request_body = { 157 | anthropic_version: 'bedrock-2023-05-31', 158 | max_tokens: 4096, # Increased for batch processing 159 | temperature: 0.3, 160 | messages: [ 161 | { 162 | role: 'user', 163 | content: prompt 164 | } 165 | ] 166 | } 167 | 168 | @logger.info "Batch translating #{texts_to_translate.size} texts" 169 | 170 | # Single API call for all texts 171 | response = @client.invoke_model( 172 | model_id: @model_id, 173 | content_type: 'application/json', 174 | accept: 'application/json', 175 | body: request_body.to_json 176 | ) 177 | 178 | # Parse the response 179 | response_body = JSON.parse(response.body.read) 180 | translations = extract_batch_translations(response_body, texts_to_translate.size) 181 | 182 | # Cache the translations and build result array 183 | results = Array.new(texts_array.size) 184 | 185 | texts_to_translate.each_with_index do |item, batch_index| 186 | translation = translations[batch_index] 187 | if translation 188 | cache_key = "#{source_lang}:#{target_lang}:#{item[:text]}" 189 | add_to_cache(cache_key, translation) 190 | results[item[:index]] = translation 191 | end 192 | end 193 | 194 | # Add cached results 195 | cached_results.each { |index, text| results[index] = text } 196 | 197 | @logger.info "Batch translation completed: #{texts_to_translate.size} texts translated in 1 API call" 198 | 199 | results 200 | 201 | rescue Aws::BedrockRuntime::Errors::ServiceError => e 202 | @logger.error "AWS Bedrock batch error: #{e.message}" 203 | 204 | Sentry.capture_exception(e) do |scope| 205 | scope.set_tag('component', 'bedrock_translate_batch') 206 | scope.set_context('batch_translation', { 207 | batch_size: texts_to_translate.size, 208 | source_lang: source_lang, 209 | target_lang: target_lang, 210 | model_id: @model_id 211 | }) 212 | end 213 | 214 | # Return empty translations on error 215 | Array.new(texts_array.size) 216 | 217 | rescue => e 218 | @logger.error "Batch translation error: #{e.message}" 219 | 220 | Sentry.capture_exception(e) do |scope| 221 | scope.set_tag('component', 'bedrock_translate_batch') 222 | end 223 | 224 | Array.new(texts_array.size) 225 | end 226 | end 227 | 228 | private 229 | 230 | def initialize_client 231 | @client = Aws::BedrockRuntime::Client.new( 232 | region: @region, 233 | # Increase timeout for Bedrock API calls 234 | http_read_timeout: 60, 235 | retry_limit: 3 236 | ) 237 | 238 | @logger.info "Initialized Bedrock client in region: #{@region}" 239 | 240 | rescue => e 241 | @logger.error "Failed to initialize Bedrock client: #{e.message}" 242 | @logger.error "Translation will be disabled. Transcription will continue without translation." 243 | 244 | Sentry.capture_exception(e) do |scope| 245 | scope.set_tag('component', 'bedrock_client_init') 246 | end 247 | 248 | @enabled = false 249 | # Don't raise - allow service to continue without translation 250 | end 251 | 252 | def build_translation_prompt(text, source_lang, target_lang) 253 | lang_names = { 254 | 'ja' => 'Japanese', 255 | 'en' => 'English', 256 | 'zh' => 'Chinese', 257 | 'ko' => 'Korean', 258 | 'es' => 'Spanish', 259 | 'fr' => 'French' 260 | } 261 | 262 | source_name = lang_names[source_lang] || source_lang 263 | target_name = lang_names[target_lang] || target_lang 264 | 265 | # Create a focused translation prompt 266 | <<~PROMPT 267 | Translate the following #{source_name} text to #{target_name}. 268 | Provide only the translation without any explanation or additional text. 269 | Maintain the tone and style of the original text. 270 | 271 | Text to translate: 272 | #{text} 273 | 274 | Translation: 275 | PROMPT 276 | end 277 | 278 | def extract_translation(response_body) 279 | # Extract text from Claude's response 280 | content = response_body['content'] 281 | return nil unless content && content.is_a?(Array) && !content.empty? 282 | 283 | # Get the text from the first content block 284 | text_content = content.find { |c| c['type'] == 'text' } 285 | return nil unless text_content 286 | 287 | # Clean up the translation (remove any leading/trailing whitespace) 288 | translation = text_content['text'].strip 289 | 290 | # Sometimes the model might include quotes or explanation, try to extract just the translation 291 | # Remove common prefixes if present 292 | translation.sub!(/^(Translation:|Translated text:|Here's the translation:)\s*/i, '') 293 | translation.strip 294 | end 295 | 296 | def add_to_cache(key, value) 297 | # Implement simple LRU-like cache management 298 | if @translation_cache.size >= @cache_max_size 299 | # Remove oldest entries (simple approach - remove first 20% of entries) 300 | keys_to_remove = @translation_cache.keys.first(@cache_max_size / 5) 301 | keys_to_remove.each { |k| @translation_cache.delete(k) } 302 | 303 | @logger.debug "Cache pruned, removed #{keys_to_remove.size} entries" if ENV['DEBUG'] 304 | end 305 | 306 | @translation_cache[key] = value 307 | end 308 | 309 | def build_batch_translation_prompt(texts_to_translate, source_lang, target_lang) 310 | lang_names = { 311 | 'ja' => 'Japanese', 312 | 'en' => 'English', 313 | 'zh' => 'Chinese', 314 | 'ko' => 'Korean', 315 | 'es' => 'Spanish', 316 | 'fr' => 'French' 317 | } 318 | 319 | source_name = lang_names[source_lang] || source_lang 320 | target_name = lang_names[target_lang] || target_lang 321 | 322 | texts_formatted = texts_to_translate.map.with_index do |item, i| 323 | "[#{i + 1}] #{item[:text]}" 324 | end.join("\n") 325 | 326 | <<~PROMPT 327 | Translate the following #{texts_to_translate.size} #{source_name} texts to #{target_name}. 328 | Provide only the translations, each on a new line with its number. 329 | Maintain the tone and style of each original text. 330 | Format: [number] translated text 331 | 332 | Texts to translate: 333 | #{texts_formatted} 334 | 335 | Translations: 336 | PROMPT 337 | end 338 | 339 | def extract_batch_translations(response_body, expected_count) 340 | content = response_body['content'] 341 | return Array.new(expected_count) unless content && content.is_a?(Array) && !content.empty? 342 | 343 | text_content = content.find { |c| c['type'] == 'text' } 344 | return Array.new(expected_count) unless text_content 345 | 346 | # Parse numbered translations 347 | translations = Array.new(expected_count) 348 | lines = text_content['text'].strip.split("\n") 349 | 350 | lines.each do |line| 351 | # Match pattern: [number] translation 352 | if match = line.match(/^\[(\d+)\]\s*(.+)$/) 353 | index = match[1].to_i - 1 354 | translation = match[2].strip 355 | translations[index] = translation if index >= 0 && index < expected_count 356 | end 357 | end 358 | 359 | translations 360 | end 361 | end 362 | -------------------------------------------------------------------------------- /lib/redis_streams_sse_app.rb: -------------------------------------------------------------------------------- 1 | require 'async' 2 | require 'async/redis' 3 | require 'rack' 4 | require 'json' 5 | require 'logger' 6 | require 'securerandom' 7 | require 'concurrent' 8 | require 'sentry-ruby' 9 | 10 | class RedisStreamsSSEApp 11 | def initialize(logger: nil) 12 | # Parse Redis URL (supports redis:// and rediss:// for TLS) 13 | redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') 14 | @redis_endpoint = Async::Redis::Endpoint.parse(redis_url) 15 | @stream_key = ENV.fetch('REDIS_STREAM_KEY', 'transcription_stream') 16 | @connected_clients = Concurrent::Hash.new 17 | @logger = logger || Logger.new(STDOUT).tap do |log| 18 | log.formatter = proc do |severity, datetime, progname, msg| 19 | "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] [RedisStreamsSSEApp] #{severity}: #{msg}\n" 20 | end 21 | end 22 | 23 | # Start monitoring thread for client connections 24 | start_connection_monitor 25 | end 26 | 27 | def call(env) 28 | request = Rack::Request.new(env) 29 | 30 | case request.path 31 | when '/sse' 32 | handle_sse_stream(request) 33 | when '/health' 34 | handle_health_check 35 | else 36 | [404, {'Content-Type' => 'text/plain'}, ['Not Found']] 37 | end 38 | end 39 | 40 | private 41 | 42 | def handle_sse_stream(request) 43 | headers = { 44 | 'Content-Type' => 'text/event-stream', 45 | 'Cache-Control' => 'no-cache', 46 | 'Connection' => 'keep-alive', 47 | 'Access-Control-Allow-Origin' => '*', 48 | 'Access-Control-Allow-Headers' => 'Cache-Control', 49 | 'X-Accel-Buffering' => 'no' 50 | } 51 | 52 | # Get language from query params 53 | language = request.params['language'] || 'ja' 54 | room = request.params['room'] || 'default' 55 | 56 | # Set Sentry context for this request 57 | Sentry.configure_scope do |scope| 58 | scope.set_context('sse_stream', { 59 | room: room, 60 | language: language, 61 | path: request.path 62 | }) 63 | end 64 | 65 | # Create SSE stream and track it 66 | client_id = SecureRandom.uuid 67 | sse_stream = RedisStreamSSE.new(@redis_endpoint, @stream_key, room, language, 68 | logger: @logger, 69 | client_id: client_id, 70 | on_close: -> { @connected_clients.delete(client_id) }) 71 | 72 | # Track this client 73 | @connected_clients[client_id] = { 74 | connected_at: Time.now, 75 | room: room, 76 | language: language, 77 | remote_ip: request.ip 78 | } 79 | 80 | @logger.info "New SSE client connected: #{client_id} from #{request.ip} (room: #{room}, language: #{language})" 81 | 82 | [200, headers, sse_stream] 83 | end 84 | 85 | def handle_health_check 86 | status = { 87 | status: 'ok', 88 | timestamp: Time.now.iso8601, 89 | redis_status: check_redis_connection 90 | } 91 | [200, {'Content-Type' => 'application/json'}, [status.to_json]] 92 | end 93 | 94 | def check_redis_connection 95 | # Run in current Async context if available, otherwise create new one 96 | if Async::Task.current? 97 | Async::Redis::Client.open(@redis_endpoint) do |client| 98 | response = client.call('PING') 99 | response == 'PONG' ? 'connected' : 'disconnected' 100 | end 101 | else 102 | Async do 103 | Async::Redis::Client.open(@redis_endpoint) do |client| 104 | response = client.call('PING') 105 | response == 'PONG' ? 'connected' : 'disconnected' 106 | end 107 | end.wait 108 | end 109 | rescue => e 110 | Sentry.capture_exception(e) 111 | "error: #{e.message}" 112 | end 113 | 114 | private 115 | 116 | def start_connection_monitor 117 | Thread.new do 118 | loop do 119 | sleep(30) # Log every 30 seconds 120 | 121 | active_count = @connected_clients.size 122 | if active_count > 0 123 | # Group clients by room and language 124 | room_stats = @connected_clients.values.group_by { |c| "#{c[:room]}/#{c[:language]}" } 125 | .transform_values(&:count) 126 | 127 | # Format room statistics as a single line 128 | room_details = room_stats.map { |room_lang, count| "#{room_lang}: #{count}" }.join(', ') 129 | @logger.info "Connected clients: #{active_count} total (#{room_details})" 130 | else 131 | @logger.debug "No active SSE connections" if ENV['DEBUG'] 132 | end 133 | rescue => e 134 | @logger.error "Connection monitor error: #{e.message}" 135 | end 136 | end 137 | end 138 | end 139 | 140 | class RedisStreamSSE 141 | def initialize(redis_endpoint, stream_key, room, language, logger: nil, client_id: nil, on_close: nil) 142 | @redis_endpoint = redis_endpoint 143 | @stream_key = stream_key 144 | @room = room 145 | @language = language 146 | @client_id = client_id || SecureRandom.uuid 147 | @on_close = on_close 148 | # Use '$' to start reading only new messages from connection time 149 | @last_id = '$' 150 | @logger = logger || Logger.new(STDOUT).tap do |log| 151 | log.formatter = proc do |severity, datetime, progname, msg| 152 | "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] [RedisStreamSSE] #{severity}: #{msg}\n" 153 | end 154 | end 155 | end 156 | 157 | def each 158 | # Send initial connection message before entering Async context 159 | yield "event: connected\n" 160 | yield "data: #{JSON.generate({ 161 | message: 'Connected to Redis Streams SSE', 162 | stream: @stream_key, 163 | room: @room, 164 | language: @language, 165 | client_id: @client_id, 166 | timestamp: Time.now.iso8601 167 | })}\n\n" 168 | 169 | @logger.debug "SSE stream started for client #{@client_id}" if ENV['DEBUG'] 170 | 171 | # Create a queue for async tasks to send data through 172 | queue = Queue.new 173 | 174 | # Start async tasks in background 175 | async_task = Async do |task| 176 | begin 177 | run_sse_stream_async(task, queue) 178 | rescue => e 179 | queue.push([:error, e]) 180 | end 181 | end 182 | 183 | # Read from queue and yield to the client (synchronously) 184 | begin 185 | while true 186 | type, data = queue.pop 187 | 188 | case type 189 | when :data 190 | yield data 191 | when :error 192 | raise data 193 | when :done 194 | break 195 | end 196 | end 197 | rescue Errno::EPIPE, IOError => e 198 | # Client disconnected - this is normal, don't report to Sentry 199 | @logger.info "SSE client #{@client_id} disconnected: #{e.class.name}" 200 | rescue => e 201 | # Report other errors to Sentry with SSE context 202 | Sentry.capture_exception(e) do |scope| 203 | scope.set_context('sse', { 204 | room: @room, 205 | language: @language, 206 | last_id: @last_id 207 | }) 208 | end 209 | 210 | begin 211 | yield "event: error\n" 212 | yield "data: #{JSON.generate({ error: e.message })}\n\n" 213 | rescue Errno::EPIPE, IOError 214 | # Client already disconnected, can't send error message 215 | @logger.debug "Could not send error to disconnected client #{@client_id}" if ENV['DEBUG'] 216 | end 217 | ensure 218 | # Stop async task if still running 219 | async_task&.stop 220 | 221 | # Call cleanup callback 222 | @on_close&.call 223 | @logger.info "SSE client disconnected: #{@client_id}" 224 | end 225 | end 226 | 227 | private 228 | 229 | def run_sse_stream_async(parent_task, queue) 230 | redis_task = nil 231 | heartbeat_task = nil 232 | 233 | begin 234 | # Start reading from Redis Streams within parent task context 235 | redis_task = parent_task.async do 236 | read_redis_stream { |event, data| 237 | # Send data through queue instead of yielding directly 238 | queue.push([:data, "event: #{event}\n"]) 239 | queue.push([:data, "data: #{data}\n\n"]) 240 | } 241 | end 242 | 243 | # Send heartbeat every 30 seconds within parent task context 244 | heartbeat_task = parent_task.async do 245 | loop do 246 | sleep 30 247 | begin 248 | queue.push([:data, "event: heartbeat\n"]) 249 | queue.push([:data, "data: #{JSON.generate({ timestamp: Time.now.iso8601 })}\n\n"]) 250 | rescue => e 251 | # Client disconnected or queue closed 252 | @logger.debug "Heartbeat stopped: #{e.class}" if ENV['DEBUG'] 253 | break 254 | end 255 | end 256 | end 257 | 258 | # Wait for redis task to complete 259 | redis_task.wait 260 | queue.push([:done, nil]) 261 | ensure 262 | redis_task&.stop 263 | heartbeat_task&.stop 264 | end 265 | end 266 | 267 | def read_redis_stream 268 | begin 269 | Async::Redis::Client.open(@redis_endpoint) do |client| 270 | @logger.info "Connected to Redis, reading new messages from stream: #{@stream_key} (starting from: #{@last_id})" 271 | 272 | loop do 273 | begin 274 | # XREAD with block timeout of 5 seconds 275 | # Format: XREAD BLOCK 5000 STREAMS stream_key last_id 276 | # Using '$' initially means we only get messages that arrive after connection 277 | result = client.call('XREAD', 'BLOCK', '5000', 'STREAMS', @stream_key, @last_id) 278 | 279 | if result && result.is_a?(Array) && !result.empty? 280 | # Parse XREAD response 281 | # Format: [[stream_name, [[id, [field1, value1, field2, value2, ...]], ...]]] 282 | stream_data = result[0] 283 | stream_name = stream_data[0] 284 | entries = stream_data[1] 285 | 286 | entries.each do |entry| 287 | entry_id = entry[0] 288 | fields = entry[1] 289 | 290 | # Convert field array to hash 291 | data = {} 292 | fields.each_slice(2) do |key, value| 293 | data[key] = value.force_encoding('UTF-8') 294 | end 295 | 296 | # Filter by room and language if specified in the data 297 | if should_send_message?(data) 298 | message = { 299 | id: entry_id, 300 | stream: stream_name, 301 | data: data, 302 | timestamp: Time.now.iso8601 303 | } 304 | 305 | begin 306 | yield 'message', JSON.generate(message) 307 | @logger.debug "Sent message: #{entry_id}" if ENV['DEBUG'] 308 | rescue Errno::EPIPE, IOError => e 309 | # Client disconnected while sending message 310 | @logger.debug "Client #{@client_id} disconnected while sending message: #{e.class}" if ENV['DEBUG'] 311 | break # Exit the loop, client disconnected 312 | end 313 | end 314 | 315 | @last_id = entry_id 316 | end 317 | end 318 | 319 | rescue => e 320 | @logger.error "Error reading stream: #{e.message}" 321 | 322 | # Report to Sentry 323 | Sentry.capture_exception(e) do |scope| 324 | scope.set_tag('component', 'redis_stream_reader') 325 | scope.set_context('stream', { 326 | stream_key: @stream_key, 327 | last_id: @last_id 328 | }) 329 | end 330 | 331 | yield 'error', JSON.generate({ error: e.message }) 332 | sleep 1 333 | end 334 | end 335 | end 336 | rescue => e 337 | @logger.error "Redis connection error: #{e.message}" 338 | 339 | # Report to Sentry 340 | Sentry.capture_exception(e) do |scope| 341 | scope.set_tag('component', 'redis_connection') 342 | scope.set_context('redis', { 343 | endpoint: @redis_endpoint.to_s 344 | }) 345 | end 346 | 347 | yield 'error', JSON.generate({ error: "Redis connection failed: #{e.message}" }) 348 | 349 | # Retry connection after 5 seconds 350 | sleep 5 351 | retry 352 | end 353 | end 354 | 355 | def should_send_message?(data) 356 | # Check if message matches room and language filters 357 | message_room = data['room'] || 'default' 358 | message_language = data['language'] || 'ja' 359 | 360 | return true if @room == 'all' || @language == 'all' 361 | return message_room == @room && message_language == @language 362 | end 363 | end 364 | -------------------------------------------------------------------------------- /lib/rtmp_transcribe_service.rb: -------------------------------------------------------------------------------- 1 | require 'async' 2 | require 'async/redis' 3 | require 'json' 4 | require 'logger' 5 | require 'concurrent' 6 | require 'digest' 7 | require 'sentry-ruby' 8 | require_relative 'ffmpeg_audio_stream' 9 | require_relative 'transcribe_client' 10 | require_relative 'bedrock_translate_client' 11 | 12 | class RtmpTranscribeService 13 | def initialize(rtmp_url: nil, room: 'default', test_mode: false, logger: nil) 14 | @rtmp_url = rtmp_url || ENV.fetch('RTMP_URL', 'rtmp://localhost:1935/live') 15 | @room = room 16 | @test_mode = test_mode 17 | @audio_stream = nil 18 | @transcribe_client = nil 19 | @translate_client = nil 20 | # Parse Redis URL (supports redis:// and rediss:// for TLS) 21 | redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') 22 | @redis_endpoint = Async::Redis::Endpoint.parse(redis_url) 23 | @stream_key = ENV.fetch('REDIS_STREAM_KEY', 'transcription_stream') 24 | @running = false 25 | @redis_task = nil 26 | @translation_queue = Concurrent::Array.new 27 | @translated_texts = Concurrent::Hash.new # Track already translated text to avoid duplicates 28 | @min_translation_length = ENV.fetch('MIN_TRANSLATION_LENGTH', '20').to_i # Minimum characters for translation 29 | @translation_batch_timeout = ENV.fetch('TRANSLATION_BATCH_TIMEOUT', '2').to_f # Batch timeout in seconds 30 | @logger = logger || Logger.new(STDOUT).tap do |log| 31 | log.formatter = proc do |severity, datetime, progname, msg| 32 | "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] [RtmpTranscribeService] #{severity}: #{msg}\n" 33 | end 34 | end 35 | end 36 | 37 | def start 38 | return if @running 39 | 40 | @running = true 41 | @logger.info "Starting service..." 42 | @logger.info " RTMP URL: #{@rtmp_url}" 43 | @logger.info " Room: #{@room}" 44 | @logger.info " Redis Stream: #{@stream_key}" 45 | 46 | begin 47 | # Start FFmpeg audio stream (pass logger to child component) 48 | @audio_stream = FFmpegAudioStream.new(rtmp_url: @rtmp_url, logger: @logger) 49 | @audio_stream.start 50 | 51 | # Wait for audio buffer to have sufficient data before starting Transcribe 52 | @logger.info "Waiting for audio data to be available..." 53 | wait_count = 0 54 | while @audio_stream.buffer.size < 6400 && wait_count < 30 # Wait up to 30 seconds for at least 200ms of audio 55 | sleep(1) 56 | wait_count += 1 57 | 58 | # More detailed logging for debugging 59 | if wait_count % 2 == 0 || ENV['DEBUG'] 60 | @logger.info "Waiting for audio data... (buffer size: #{@audio_stream.buffer.size}, wait: #{wait_count}s)" 61 | 62 | # Check if FFmpeg is still running 63 | unless @audio_stream.running? 64 | @logger.error "Audio stream stopped while waiting for data!" 65 | raise "FFmpeg audio stream stopped unexpectedly" 66 | end 67 | end 68 | end 69 | 70 | if @audio_stream.buffer.size < 6400 71 | @logger.warn "Starting with insufficient audio buffer (size: #{@audio_stream.buffer.size})" 72 | @logger.warn "This might indicate FFmpeg is not receiving RTMP data" 73 | else 74 | @logger.info "Audio buffer ready (size: #{@audio_stream.buffer.size})" 75 | end 76 | 77 | # Start Transcribe client (non-blocking) 78 | @transcribe_client = TranscribeClient.new(logger: @logger) 79 | @transcribe_client.start(@audio_stream) 80 | 81 | # Start Translation client 82 | @translate_client = BedrockTranslateClient.new(logger: @logger) 83 | if @translate_client.enabled? 84 | @logger.info "Translation enabled" 85 | start_translation_processor 86 | else 87 | @logger.info "Translation disabled" 88 | end 89 | 90 | # Start Redis publishing task 91 | start_redis_publisher 92 | 93 | # Monitor loop 94 | monitor_services 95 | 96 | rescue => e 97 | @logger.error "Error: #{e.message}" 98 | @logger.error e.backtrace.first(5).join("\n") if e.backtrace 99 | 100 | # Report to Sentry with context 101 | Sentry.capture_exception(e) do |scope| 102 | scope.set_context('rtmp_service', { 103 | rtmp_url: @rtmp_url, 104 | room: @room, 105 | test_mode: @test_mode 106 | }) 107 | end 108 | 109 | stop 110 | end 111 | end 112 | 113 | def stop 114 | return unless @running 115 | 116 | @logger.info "Stopping service..." 117 | @running = false 118 | 119 | # Stop components 120 | @redis_task&.stop 121 | @translation_task&.stop if @translation_task 122 | @transcribe_client&.stop 123 | @audio_stream&.stop 124 | 125 | @logger.info "Service stopped" 126 | end 127 | 128 | private 129 | 130 | def start_redis_publisher 131 | @redis_task = Async do 132 | last_processed_index = 0 133 | consecutive_errors = 0 134 | max_consecutive_errors = 10 135 | published_count = 0 136 | last_stats_time = Time.now 137 | stats_interval = 60 # 1 minute 138 | 139 | @logger.info "Redis publisher started" 140 | last_loop_log = Time.now 141 | loop_count = 0 142 | 143 | while @running 144 | begin 145 | loop_count += 1 146 | 147 | # Log loop execution every 60 seconds to detect if loop is stuck 148 | if Time.now - last_loop_log > 60 149 | @logger.debug "[Redis Publisher Loop] Still running... (iteration: #{loop_count}, last_processed: #{last_processed_index})" if ENV['DEBUG'] 150 | last_loop_log = Time.now 151 | end 152 | 153 | # Check for new transcription results 154 | results = @transcribe_client.results 155 | current_results_size = results.size 156 | 157 | @logger.debug "Checking results: current size=#{current_results_size}, last_processed=#{last_processed_index}" if ENV['DEBUG'] 158 | 159 | # Log statistics periodically 160 | if Time.now - last_stats_time > stats_interval 161 | @logger.info "[Redis Publisher Stats] Published: #{published_count} messages, Queue size: #{@translation_queue.size}, Results buffer: #{current_results_size}" 162 | last_stats_time = Time.now 163 | end 164 | 165 | if current_results_size > last_processed_index 166 | # Process new results 167 | new_results = results[last_processed_index..-1] 168 | @logger.debug "Processing #{new_results.size} new transcription results" if ENV['DEBUG'] 169 | 170 | # Use persistent Redis connection with reconnection logic 171 | redis_connected = false 172 | retry_count = 0 173 | max_retries = 3 174 | 175 | while !redis_connected && retry_count < max_retries 176 | begin 177 | # Add timeout for Redis operations 178 | Async do |task| 179 | task.with_timeout(10) do # 10 second timeout for Redis operations 180 | Async::Redis::Client.open(@redis_endpoint) do |client| 181 | # Test connection with PING 182 | ping_result = client.call('PING') 183 | if ping_result != 'PONG' 184 | raise "Redis ping failed: #{ping_result}" 185 | end 186 | 187 | @logger.debug "Redis client connected successfully" if ENV['DEBUG'] 188 | redis_connected = true 189 | 190 | new_results.each do |result| 191 | # Publish original transcription 192 | publish_to_redis(client, result) 193 | published_count += 1 194 | 195 | # Queue for translation if enabled and text is long enough 196 | if @translate_client&.enabled? && should_translate?(result) 197 | @translation_queue << result 198 | end 199 | end 200 | end 201 | end 202 | end.wait 203 | 204 | # Reset consecutive errors on success 205 | consecutive_errors = 0 206 | 207 | rescue Async::TimeoutError => e 208 | retry_count += 1 209 | @logger.error "Redis operation timeout (attempt #{retry_count}/#{max_retries})" 210 | 211 | if retry_count < max_retries 212 | sleep_time = retry_count * 2 # Exponential backoff 213 | @logger.info "Retrying Redis connection in #{sleep_time} seconds..." 214 | sleep(sleep_time) 215 | else 216 | raise "Redis operation timeout after #{max_retries} attempts" 217 | end 218 | rescue => e 219 | retry_count += 1 220 | @logger.error "Redis connection attempt #{retry_count}/#{max_retries} failed: #{e.message}" 221 | 222 | if retry_count < max_retries 223 | sleep_time = retry_count * 2 # Exponential backoff 224 | @logger.info "Retrying Redis connection in #{sleep_time} seconds..." 225 | sleep(sleep_time) 226 | else 227 | raise e # Re-raise if all retries exhausted 228 | end 229 | end 230 | end 231 | 232 | last_processed_index = current_results_size 233 | @logger.debug "Updated last_processed_index to #{last_processed_index}" if ENV['DEBUG'] 234 | end 235 | 236 | # Check every 100ms 237 | sleep(0.1) 238 | 239 | rescue => e 240 | consecutive_errors += 1 241 | @logger.error "Redis publisher error (#{consecutive_errors}/#{max_consecutive_errors}): #{e.message}" 242 | @logger.error e.backtrace.first(3).join("\n") if e.backtrace 243 | 244 | # Report to Sentry 245 | Sentry.capture_exception(e) do |scope| 246 | scope.set_tag('component', 'redis_publisher') 247 | scope.set_context('redis', { 248 | endpoint: @redis_endpoint.to_s, 249 | stream_key: @stream_key, 250 | consecutive_errors: consecutive_errors, 251 | published_count: published_count 252 | }) 253 | end 254 | 255 | # Check if too many consecutive errors 256 | if consecutive_errors >= max_consecutive_errors 257 | @logger.fatal "Too many consecutive Redis errors (#{consecutive_errors}), stopping service" 258 | @running = false 259 | break 260 | end 261 | 262 | # Exponential backoff for errors 263 | sleep_time = [consecutive_errors * 2, 30].min # Max 30 seconds 264 | @logger.info "Waiting #{sleep_time} seconds before retry..." 265 | sleep(sleep_time) 266 | end 267 | end 268 | 269 | @logger.info "Redis publisher stopped (published #{published_count} total messages)" 270 | end 271 | end 272 | 273 | def start_translation_processor 274 | @translation_task = Async do 275 | @logger.info "Translation processor started" 276 | @logger.info "Minimum translation length: #{@min_translation_length} characters" 277 | @logger.info "Batch size: #{@translate_client.batch_size}, Timeout: #{@translation_batch_timeout}s" 278 | @logger.info "Translate only final: #{@translate_client.translate_only_final?}" 279 | 280 | batch = [] 281 | last_batch_time = Time.now 282 | 283 | while @running 284 | begin 285 | # Collect items for batch processing 286 | while @translation_queue.size > 0 && batch.size < @translate_client.batch_size 287 | result = @translation_queue.shift 288 | 289 | # Skip non-final texts if configured 290 | if @translate_client.translate_only_final? && !result[:is_final] 291 | @logger.debug "Skipping non-final text for translation" if ENV['DEBUG'] 292 | next 293 | end 294 | 295 | # Skip if we've already translated this exact text 296 | text_hash = Digest::MD5.hexdigest(result[:text]) 297 | if @translated_texts[text_hash] 298 | @logger.debug "Skipping already translated text: #{result[:text][0..30]}..." if ENV['DEBUG'] 299 | next 300 | end 301 | 302 | batch << result 303 | end 304 | 305 | # Process batch if it's full or timeout reached 306 | should_process = batch.size >= @translate_client.batch_size || 307 | (batch.size > 0 && Time.now - last_batch_time >= @translation_batch_timeout) 308 | 309 | if should_process && batch.size > 0 310 | @logger.info "Processing translation batch: #{batch.size} texts" 311 | 312 | # Extract texts for batch translation 313 | texts_to_translate = batch.map { |r| r[:text] } 314 | 315 | # Batch translate 316 | translated_texts = @translate_client.translate_batch(texts_to_translate) 317 | 318 | # Process results 319 | batch.each_with_index do |result, index| 320 | translated_text = translated_texts[index] 321 | 322 | if translated_text 323 | # Mark this text as translated 324 | text_hash = Digest::MD5.hexdigest(result[:text]) 325 | @translated_texts[text_hash] = true 326 | 327 | # Create a new result for the translation 328 | translation_result = { 329 | text: translated_text, 330 | is_final: result[:is_final], 331 | timestamp: Time.now.iso8601, 332 | language: 'en', 333 | original_language: result[:language] || 'ja', 334 | translation_of: result[:text], 335 | type: 'translation' 336 | } 337 | 338 | # Publish the translation to Redis 339 | Async::Redis::Client.open(@redis_endpoint) do |client| 340 | publish_to_redis(client, translation_result) 341 | end 342 | end 343 | end 344 | 345 | # Clean up old entries if cache gets too large 346 | if @translated_texts.size > 1000 347 | @translated_texts.clear 348 | @logger.debug "Cleared translation cache" if ENV['DEBUG'] 349 | end 350 | 351 | # Reset batch 352 | batch.clear 353 | last_batch_time = Time.now 354 | end 355 | 356 | # Check every 100ms 357 | sleep(0.1) 358 | 359 | rescue => e 360 | @logger.error "Translation processor error: #{e.message}" 361 | 362 | Sentry.capture_exception(e) do |scope| 363 | scope.set_tag('component', 'translation_processor') 364 | end 365 | 366 | sleep(1) 367 | end 368 | end 369 | 370 | @logger.info "Translation processor stopped" 371 | end 372 | end 373 | 374 | def publish_to_redis(client, result) 375 | @logger.debug "Publishing result to Redis: #{result[:text][0..50]}..." if ENV['DEBUG'] 376 | 377 | # Validate result before publishing 378 | unless result[:text] && !result[:text].empty? 379 | @logger.warn "Skipping empty result publication" 380 | return 381 | end 382 | 383 | # Prepare message for Redis Stream 384 | message_data = { 385 | 'room' => @room, 386 | 'language' => result[:language] || 'ja', 387 | 'text' => result[:text], 388 | 'is_final' => result[:is_final].to_s, 389 | 'timestamp' => result[:timestamp] || Time.now.iso8601, 390 | 'type' => result[:type] || 'transcription' 391 | } 392 | 393 | # Add confidence if available 394 | if result[:confidence] 395 | message_data['confidence'] = result[:confidence].to_s 396 | end 397 | 398 | # Add speakers if available 399 | if result[:speakers] && !result[:speakers].empty? 400 | message_data['speakers'] = result[:speakers].join(',') 401 | end 402 | 403 | # Add translation-specific fields if this is a translation 404 | if result[:type] == 'translation' 405 | message_data['original_language'] = result[:original_language] if result[:original_language] 406 | message_data['translation_of'] = result[:translation_of] if result[:translation_of] 407 | end 408 | 409 | # Retry logic for XADD command 410 | retry_count = 0 411 | max_retries = 3 412 | 413 | while retry_count < max_retries 414 | begin 415 | # Publish to Redis Stream with MAXLEN to automatically cap the stream size 416 | # Using '~' for approximate trimming is more efficient than exact trimming 417 | stream_id = client.call('XADD', @stream_key, 'MAXLEN', '~', '1000', '*', *message_data.flatten) 418 | 419 | if stream_id.nil? || stream_id.empty? 420 | raise "XADD returned invalid stream ID: #{stream_id.inspect}" 421 | end 422 | 423 | # Log final results and translations 424 | if result[:is_final] 425 | if result[:type] == 'translation' 426 | @logger.info "Published translation: #{result[:text][0..50]}..." 427 | else 428 | @logger.info "Published final transcription: #{result[:text][0..50]}..." 429 | end 430 | elsif ENV['DEBUG'] 431 | @logger.debug "Published partial (ID: #{stream_id}): #{result[:text][0..30]}..." 432 | end 433 | 434 | # Success - exit retry loop 435 | break 436 | 437 | rescue => e 438 | retry_count += 1 439 | @logger.error "Failed to publish to Redis (attempt #{retry_count}/#{max_retries}): #{e.message}" 440 | 441 | if retry_count < max_retries 442 | sleep(retry_count * 0.5) # Small backoff 443 | else 444 | # Report to Sentry only after all retries exhausted 445 | Sentry.capture_exception(e) do |scope| 446 | scope.set_tag('component', 'redis_publish') 447 | scope.set_context('message', result) 448 | scope.set_context('retry_info', { 449 | attempts: retry_count, 450 | stream_key: @stream_key 451 | }) 452 | end 453 | 454 | # Re-raise to trigger connection retry in parent 455 | raise e 456 | end 457 | end 458 | end 459 | end 460 | 461 | def monitor_services 462 | @logger.info "Monitoring services..." 463 | last_status_time = Time.now 464 | status_interval = 10 465 | 466 | while @running 467 | # Check if components are still running 468 | unless @audio_stream&.running? 469 | @logger.error "Audio stream stopped unexpectedly" 470 | @logger.error "Final buffer size: #{@audio_stream.buffer.size}" if @audio_stream 471 | 472 | # Report to Sentry 473 | Sentry.capture_message( 474 | "Audio stream stopped unexpectedly", 475 | level: 'error' 476 | ) 477 | 478 | break 479 | end 480 | 481 | unless @transcribe_client&.running? 482 | @logger.error "Transcribe client stopped unexpectedly" 483 | @logger.error "Buffer size when stopped: #{@audio_stream.buffer.size}" if @audio_stream 484 | 485 | # Report to Sentry 486 | Sentry.capture_message( 487 | "Transcribe client stopped unexpectedly", 488 | level: 'error' 489 | ) 490 | 491 | break 492 | end 493 | 494 | # Print status at regular intervals 495 | if Time.now - last_status_time >= status_interval 496 | buffer_size = @audio_stream.buffer.size 497 | results_count = @transcribe_client.results.size 498 | @logger.info "[Status] Buffer: #{buffer_size} bytes, Results: #{results_count}, FFmpeg: #{@audio_stream.running? ? 'running' : 'stopped'}" 499 | 500 | # Warn if buffer is consistently empty 501 | if buffer_size == 0 502 | @logger.warn "Audio buffer is empty - FFmpeg may not be receiving RTMP data" 503 | end 504 | 505 | last_status_time = Time.now 506 | end 507 | 508 | sleep(1) 509 | end 510 | 511 | @logger.info "Monitor loop ended" 512 | stop 513 | end 514 | 515 | def should_translate?(result) 516 | text = result[:text] 517 | return false if text.nil? || text.strip.empty? 518 | 519 | # Translate if it's final OR if it's long enough 520 | if result[:is_final] 521 | @logger.debug "Queueing final text for translation" if ENV['DEBUG'] 522 | return true 523 | elsif text.length >= @min_translation_length 524 | @logger.debug "Queueing partial text for translation (length: #{text.length})" if ENV['DEBUG'] 525 | return true 526 | else 527 | @logger.debug "Skipping translation - text too short (length: #{text.length}, min: #{@min_translation_length})" if ENV['DEBUG'] 528 | return false 529 | end 530 | end 531 | end 532 | --------------------------------------------------------------------------------