Production Ses Eşitleme Rehberi

~32.000 şarkıyı iki geçişli loudnorm + alimiter ile yeniden encode et

Kod değişikliği gerektirmez — bağımsız script

Herkes İçin Özet

Sorun: Şarkılar arasında ses seviyesi farkı var. Bazıları patlıyor, bazıları çok kısık.

Çözüm: Her şarkıyı ölçüp aynı ses seviyesine getiren bir script. Orijinal dosyalara dokunmuyor.

Süre: 3 paralel terminal ile ~15 saat (gece başlatılabilir).

Risk: Sıfır. Orijinal MP3'ler korunuyor. Sorun çıkarsa script'i durdur, eski dosyalar yerinde.

Teknik: Ne Değişiyor?

ESKİ — Sorunlu
  • Tek geçişli loudnorm (pumping artifact)
  • stereotools + lowpass + treble cut (hışırtı)
  • Şarkılar arası ~6 dB fark
  • 17 şarkıda clipping (peak 0.0 dB)
YENİ — Temiz
  • İki geçişli loudnorm (hassas ölçüm)
  • Sadece alimiter (peak koruması)
  • Şarkılar arası ~1 dB fark
  • Clipping imkansız (limit=0.95)
Filtre zinciri: loudnorm=I=-16:TP=-1.5:LRA=14:measured_I=X:measured_TP=X:measured_LRA=X:measured_thresh=X:linear=false,alimiter=limit=0.95:attack=5:release=50

Her Şarkı İçin Üretilen Dosyalar

📁 Orijinal (DOKUNULMAZ)
storage/tenant1001/app/public/muzibu/songs/song_xxxxx.mp3

📁 Re-encode Edilenler (script bunları üretir)
storage/tenant1001/app/public/muzibu/hls/{song_id}/
├── playlist.m3u8          ← high kalite (orijinal bitrate, 4sn segment)
├── segment-*.ts           ← high segment'ler
├── ultralow/playlist.m3u8 ← 32kbps, mono, 22050Hz
├── low/playlist.m3u8      ← 64kbps, mono, 22050Hz
├── mid/playlist.m3u8      ← 128kbps, stereo, 44100Hz
├── master.m3u8            ← 4 varyant referansı
├── enc.bin                ← DOKUNULMAZ (encryption key)
└── enc.keyinfo            ← DOKUNULMAZ

storage/tenant1001/app/public/muzibu/songs/
├── mp3_128/{song_id}.mp3  ← 128kbps MP3 fallback
└── mp3_64/{song_id}.mp3   ← 64kbps MP3 son çare fallback

Script: re-encode-songs.php

Bu dosyayı production sunucunun ana dizinine (/var/www/vhosts/muzibu.com/httpdocs/) kopyala.

<?php
/**
 * Bağımsız Re-encode Script — Production Sunucu İçin
 *
 * Laravel kodlarını DEĞİŞTİRMEDEN tüm şarkıları iki geçişli loudnorm + alimiter ile yeniden encode eder.
 *
 * Kullanım:
 *   php re-encode-songs.php                          # Tüm şarkılar
 *   php re-encode-songs.php --limit=100              # İlk 100 şarkı
 *   php re-encode-songs.php --offset=100 --limit=100 # 101-200 arası
 *   php re-encode-songs.php --dry-run                # Sadece say, encode yapma
 *   php re-encode-songs.php --skip-high              # High re-encode atla
 *   php re-encode-songs.php --only-mp3               # Sadece MP3 64k+128k üret
 *   php re-encode-songs.php --song=34455             # Tek şarkı test
 *
 * Paralel çalıştırma (3 terminal):
 *   Terminal 1: php re-encode-songs.php --offset=0     --limit=11000
 *   Terminal 2: php re-encode-songs.php --offset=11000 --limit=11000
 *   Terminal 3: php re-encode-songs.php --offset=22000 --limit=11000
 */

// ============================================================
// AYARLAR
// ============================================================
$TENANT_ID = 1001;
$LOUDNORM_TARGET = '-16';
$LOUDNORM_TP = '-1.5';
$LOUDNORM_LRA = '14';
$LIMITER = 'alimiter=limit=0.95:attack=5:release=50';
$HLS_SEGMENT_TIME = 4;
$FFMPEG = '/usr/bin/ffmpeg';

$VARIANTS = [
    'ultralow' => ['bitrate' => '32k', 'sample_rate' => 22050, 'channels' => 1],
    'low'      => ['bitrate' => '64k', 'sample_rate' => 22050, 'channels' => 1],
    'mid'      => ['bitrate' => '128k', 'sample_rate' => 44100, 'channels' => 2],
];

// ============================================================
// ARGÜMAN PARSE
// ============================================================
$options = getopt('', ['limit:', 'offset:', 'dry-run', 'skip-high', 'only-mp3', 'song:', 'help']);

if (isset($options['help'])) { echo "Kullanım için dosya başını oku.\n"; exit(0); }

$limit = isset($options['limit']) ? (int)$options['limit'] : 0;
$offset = isset($options['offset']) ? (int)$options['offset'] : 0;
$dryRun = isset($options['dry-run']);
$skipHigh = isset($options['skip-high']);
$onlyMp3 = isset($options['only-mp3']);
$singleSong = $options['song'] ?? null;

// ============================================================
// LARAVEL BOOT
// ============================================================
echo "🎵 Ses Eşitleme Re-encode Script v3\n";
echo str_repeat('=', 50) . "\n";

require __DIR__ . '/vendor/autoload.php';
$app = require_once __DIR__ . '/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();

$tenant = App\Models\Tenant::find($TENANT_ID);
if (!$tenant) { echo "❌ Tenant {$TENANT_ID} bulunamadı!\n"; exit(1); }
tenancy()->initialize($tenant);
echo "✅ Tenant: {$TENANT_ID}\n";

// ============================================================
// ŞARKILARI BUL
// ============================================================
$query = DB::connection('tenant')
    ->table('muzibu_songs')
    ->whereNotNull('hls_path')
    ->where('hls_path', '!=', '')
    ->orderBy('song_id');

if ($singleSong) $query->where('song_id', $singleSong);
if ($offset > 0) $query->skip($offset);
if ($limit > 0) $query->take($limit);

$songs = $query->get();
$total = $songs->count();

echo "📊 Toplam şarkı: {$total}\n";
if ($offset > 0) echo "   Offset: {$offset}\n";
if ($limit > 0) echo "   Limit: {$limit}\n";
echo "   Mod: " . ($dryRun ? 'DRY-RUN' : ($onlyMp3 ? 'SADECE MP3' : ($skipHigh ? 'VARIANT+MP3' : 'TAM RE-ENCODE'))) . "\n";
echo str_repeat('=', 50) . "\n\n";

if ($dryRun) { echo "🔍 Dry-run: İşlem yapılmadı.\n"; exit(0); }

// ============================================================
// YARDIMCI FONKSİYONLAR
// ============================================================

function buildTwoPassFilter(string $inputPath): string
{
    global $FFMPEG, $LOUDNORM_TARGET, $LOUDNORM_TP, $LOUDNORM_LRA, $LIMITER;
    $base = "loudnorm=I={$LOUDNORM_TARGET}:TP={$LOUDNORM_TP}:LRA={$LOUDNORM_LRA}";

    $cmd = "{$FFMPEG} -hide_banner -i " . escapeshellarg($inputPath) .
           " -af \"{$base}:print_format=json\" -f null /dev/null 2>&1";
    $output = shell_exec($cmd);

    if ($output && preg_match('/\{[^{}]*"input_i"\s*:\s*"[^"]*"[^{}]*\}/s', $output, $match)) {
        $json = json_decode($match[0], true);
        if ($json && isset($json['input_i'], $json['input_tp'], $json['input_lra'], $json['input_thresh'])) {
            return sprintf('%s:measured_I=%s:measured_TP=%s:measured_LRA=%s:measured_thresh=%s:linear=false,%s',
                $base, $json['input_i'], $json['input_tp'], $json['input_lra'], $json['input_thresh'], $LIMITER);
        }
    }
    echo "      ⚠️ Ölçüm başarısız, tek geçiş\n";
    return "{$base},{$LIMITER}";
}

function findMp3(object $song): ?string
{
    global $TENANT_ID;
    $paths = [
        storage_path("app/public/muzibu/songs/{$song->file_path}"),
        storage_path("../tenant{$TENANT_ID}/app/public/muzibu/songs/{$song->file_path}"),
    ];
    foreach ($paths as $p) { if (!empty($song->file_path) && file_exists($p)) return $p; }
    return null;
}

function findHlsDir(object $song): ?string
{
    global $TENANT_ID;
    $relDir = str_contains($song->hls_path, 'playlist.m3u8')
        ? dirname($song->hls_path) : rtrim($song->hls_path, '/');
    $paths = [
        storage_path("app/public/{$relDir}"),
        storage_path("../tenant{$TENANT_ID}/app/public/{$relDir}"),
        storage_path("../tenant{$TENANT_ID}/app/public/muzibu/hls/{$song->song_id}"),
    ];
    foreach ($paths as $p) {
        if (file_exists($p) && file_exists("{$p}/playlist.m3u8")) return $p;
    }
    return null;
}

function detectBitrate(string $filePath): int
{
    $cmd = "ffprobe -v quiet -print_format json -show_format " . escapeshellarg($filePath);
    $data = json_decode(shell_exec($cmd), true);
    $kbps = round((int)($data['format']['bit_rate'] ?? 128000) / 1000);
    if ($kbps <= 128) return 128;
    if ($kbps <= 160) return 160;
    if ($kbps <= 192) return 192;
    if ($kbps <= 256) return 256;
    if ($kbps <= 320) return 320;
    return $kbps;
}

function reEncodeHigh(string $mp3Path, string $hlsDir, int $songId): bool
{
    global $FFMPEG, $HLS_SEGMENT_TIME;
    $keyInfo = "{$hlsDir}/enc.keyinfo";
    if (!file_exists($keyInfo)) { echo "      ❌ enc.keyinfo yok\n"; return false; }

    foreach (glob("{$hlsDir}/segment-*.ts") as $seg) unlink($seg);

    $bitrate = detectBitrate($mp3Path);
    $filters = buildTwoPassFilter($mp3Path);

    $cmd = sprintf('%s -hide_banner -y -i %s -map 0:a -c:a aac -b:a %dk -profile:a aac_low -ar 48000 -ac 2 -af %s -hls_key_info_file %s -start_number 0 -hls_time %d -hls_list_size 0 -hls_playlist_type vod -hls_segment_filename %s -f hls %s 2>&1',
        $FFMPEG, escapeshellarg($mp3Path), $bitrate, escapeshellarg($filters),
        escapeshellarg($keyInfo), $HLS_SEGMENT_TIME,
        escapeshellarg("{$hlsDir}/segment-%03d.ts"), escapeshellarg("{$hlsDir}/playlist.m3u8"));
    exec($cmd, $output, $ret);
    return $ret === 0;
}

function generateVariant(string $mp3Path, string $hlsDir, string $name, array $config): bool
{
    global $FFMPEG, $HLS_SEGMENT_TIME;
    $varDir = "{$hlsDir}/{$name}";

    if (file_exists("{$varDir}/playlist.m3u8")) {
        foreach (glob("{$varDir}/segment-*.ts") as $seg) unlink($seg);
        @unlink("{$varDir}/playlist.m3u8");
    }
    if (!file_exists($varDir)) mkdir($varDir, 0755, true);

    $keyInfoPath = null;
    $mainKeyInfo = "{$hlsDir}/enc.keyinfo";
    if (file_exists($mainKeyInfo)) {
        $lines = explode("\n", trim(file_get_contents($mainKeyInfo)));
        $sep = str_contains($lines[0], '?') ? '&' : '?';
        $keyInfoPath = "{$varDir}/enc.keyinfo";
        file_put_contents($keyInfoPath, implode("\n", [
            "{$lines[0]}{$sep}v={$name}", $lines[1], bin2hex(random_bytes(16))
        ]));
    }

    $filters = buildTwoPassFilter($mp3Path);
    $cmd = sprintf('%s -hide_banner -y -i %s -c:a aac -b:a %s -profile:a aac_low -ar %d -ac %d -vn -af %s',
        $FFMPEG, escapeshellarg($mp3Path), $config['bitrate'], $config['sample_rate'], $config['channels'],
        escapeshellarg($filters));
    if ($keyInfoPath) $cmd .= ' -hls_key_info_file ' . escapeshellarg($keyInfoPath);
    $cmd .= sprintf(' -hls_time %d -hls_list_size 0 -hls_playlist_type vod -hls_segment_filename %s %s 2>&1',
        $HLS_SEGMENT_TIME, escapeshellarg("{$varDir}/segment-%03d.ts"), escapeshellarg("{$varDir}/playlist.m3u8"));

    exec($cmd, $output, $ret);
    return $ret === 0;
}

function generateMaster(string $hlsDir): void
{
    global $VARIANTS;
    $highBitrate = 256000;
    $pf = "{$hlsDir}/playlist.m3u8";
    if (file_exists($pf)) {
        $content = file_get_contents($pf); $dur = 0; $sz = 0;
        if (preg_match_all('/#EXTINF:([\d.]+),/', $content, $m))
            foreach ($m[1] as $d) $dur += (float)$d;
        foreach (glob("{$hlsDir}/segment-*.ts") as $s) $sz += filesize($s);
        if ($dur > 0 && $sz > 0) $highBitrate = (int)round($sz * 8 / $dur);
    }
    $bw = ['ultralow' => 32000, 'low' => 64000, 'mid' => 128000];
    $master = "#EXTM3U\n";
    foreach ($VARIANTS as $n => $c) {
        if (file_exists("{$hlsDir}/{$n}/playlist.m3u8"))
            $master .= "#EXT-X-STREAM-INF:BANDWIDTH={$bw[$n]},CODECS=\"mp4a.40.2\",NAME=\"{$n}\"\n{$n}/playlist.m3u8\n";
    }
    if (file_exists($pf))
        $master .= "#EXT-X-STREAM-INF:BANDWIDTH={$highBitrate},CODECS=\"mp4a.40.2\",NAME=\"high\"\nplaylist.m3u8\n";
    file_put_contents("{$hlsDir}/master.m3u8", $master);
}

function generateMp3(string $mp3Path, int $songId, string $quality): bool
{
    global $FFMPEG, $TENANT_ID;
    $dir = storage_path("../tenant{$TENANT_ID}/app/public/muzibu/songs/mp3_{$quality}");
    if (!file_exists($dir)) mkdir($dir, 0755, true);
    $outPath = "{$dir}/{$songId}.mp3";
    if (file_exists($outPath)) unlink($outPath);

    $filters = buildTwoPassFilter($mp3Path);
    $sr = $quality === '64' ? 22050 : 44100;
    $ch = $quality === '64' ? 1 : 2;

    $cmd = sprintf('%s -hide_banner -y -i %s -b:a %sk -ar %d -ac %d -af %s -map_metadata -1 -vn %s 2>&1',
        $FFMPEG, escapeshellarg($mp3Path), $quality, $sr, $ch,
        escapeshellarg($filters), escapeshellarg($outPath));
    exec($cmd, $output, $ret);
    return $ret === 0;
}

// ============================================================
// ANA DÖNGÜ
// ============================================================
$success = 0; $failed = 0; $startTime = microtime(true);

foreach ($songs as $i => $song) {
    $num = $i + 1;
    $elapsed = round(microtime(true) - $startTime);
    $eta = $num > 1 ? round(($elapsed / $num) * ($total - $num) / 60) : '?';
    echo "[{$num}/{$total}] Song #{$song->song_id} (ETA: {$eta}dk)\n";

    $mp3Path = findMp3($song);
    if (!$mp3Path) { echo "   ❌ MP3 bulunamadı\n"; $failed++; continue; }

    $hlsDir = findHlsDir($song);
    if (!$hlsDir && !$onlyMp3) { echo "   ❌ HLS dizini yok\n"; $failed++; continue; }

    if (!$onlyMp3 && !$skipHigh) {
        echo "   🔄 High re-encode... ";
        if (reEncodeHigh($mp3Path, $hlsDir, $song->song_id)) { echo "✅\n"; }
        else { echo "❌\n"; $failed++; continue; }
    }

    if (!$onlyMp3) {
        foreach ($VARIANTS as $name => $config) {
            echo "   🔄 {$name}... ";
            echo generateVariant($mp3Path, $hlsDir, $name, $config) ? "✅\n" : "❌\n";
        }
        generateMaster($hlsDir);
        echo "   📋 master.m3u8 ✅\n";
    }

    echo "   🔄 MP3 128k... ";
    echo generateMp3($mp3Path, $song->song_id, '128') ? "✅\n" : "❌\n";

    echo "   🔄 MP3 64k... ";
    echo generateMp3($mp3Path, $song->song_id, '64') ? "✅\n" : "❌\n";

    $success++; echo "\n";
}

$totalTime = round((microtime(true) - $startTime) / 60, 1);
echo str_repeat('=', 50) . "\n";
echo "✅ Tamamlandı: {$success} başarılı, {$failed} başarısız ({$totalTime} dk)\n";

Adım Adım Talimatlar

1
Script'i Production Sunucuya Kopyala

Yukarıdaki script'i kopyalayıp production sunucunun ana dizinine yapıştır:

cd /var/www/vhosts/muzibu.com/httpdocs/
nano re-encode-songs.php
# İçeriği yapıştır, kaydet
⚠️ HTML'deki &gt; &amp; gibi karakterler var — script'i doğrudan mztest'ten indir veya aşağıdaki raw linki kullan.
2
Önce 1 Şarkı ile Dene
# Rastgele bir şarkı ID bul
php artisan tenants:run "hls:add-variants --dry-run" --tenants=1001 2>&1 | head -20

# Tek şarkıyla test et
php re-encode-songs.php --song=ŞARKI_ID_BURAYA
Beklenen çıktı:
[1/1] Song #XXXXX (ETA: ?dk)
   🔄 High re-encode... ✅
   🔄 ultralow... ✅
   🔄 low... ✅
   🔄 mid... ✅
   📋 master.m3u8 ✅
   🔄 MP3 128k... ✅
   🔄 MP3 64k... ✅

==================================================
✅ Tamamlandı: 1 başarılı, 0 başarısız (X.X dk)
3
Sonuçları Paylaş — Şu Soruları Yanıtla
PROMPT: Test Sonucu Paylaşım Şablonu
1. Script çalıştı mı? (Evet/Hayır)
   Varsa hata mesajını yapıştır.

2. Çıktıdaki tüm satırlar ✅ mi?
   ❌ olan varsa hangisi?

3. Süre ne kadar sürdü? (X.X dk)

4. ffprobe ile doğrulama:
   HLS dizinindeki playlist.m3u8 dosyasının yolunu yaz:
   ffmpeg -hide_banner -i [PLAYLIST_YOLU] -af volumedetect -f null /dev/null 2>&1 | grep mean_volume

5. Sunucu PHP versiyonu:
   php -v | head -1

6. FFmpeg versiyonu:
   ffmpeg -version | head -1

7. Toplam HLS'li şarkı sayısı:
   php re-encode-songs.php --dry-run
4
10 Şarkıyla Küçük Test
php re-encode-songs.php --limit=10

Hepsi ✅ ise devam. ❌ varsa DURMA, hata mesajını paylaş.

5
Tam Re-encode (3 Paralel Terminal)

Her terminali screen veya tmux içinde aç (SSH kopsa bile devam etsin):

# Terminal 1
screen -S encode1
php re-encode-songs.php --offset=0 --limit=11000

# Terminal 2
screen -S encode2
php re-encode-songs.php --offset=11000 --limit=11000

# Terminal 3
screen -S encode3
php re-encode-songs.php --offset=22000 --limit=11000

screen ipuçları:

Ctrl+A D → Oturumu arkada bırak (detach)

screen -r encode1 → Tekrar bağlan

screen -ls → Tüm oturumları listele

6
Doğrulama
HLSDIR="storage/tenant1001/app/public/muzibu/hls"
SONGDIR="storage/tenant1001/app/public/muzibu/songs"

echo "HLS klasör: $(ls -d $HLSDIR/*/ | wc -l)"
echo "ultralow: $(find $HLSDIR -maxdepth 2 -name ultralow -type d | wc -l)"
echo "low: $(find $HLSDIR -maxdepth 2 -name low -type d | wc -l)"
echo "mid: $(find $HLSDIR -maxdepth 2 -name mid -type d | wc -l)"
echo "master: $(find $HLSDIR -name master.m3u8 | wc -l)"
echo "mp3_128: $(ls $SONGDIR/mp3_128/*.mp3 2>/dev/null | wc -l)"
echo "mp3_64: $(ls $SONGDIR/mp3_64/*.mp3 2>/dev/null | wc -l)"
7
Temizlik + Cache
# Script'i sil (güvenlik)
rm re-encode-songs.php

# Cache temizle
php artisan cache:clear
php artisan view:clear
php artisan responsecache:clear

Tahmini Süreler

Şarkı Sayısı 1 Terminal 3 Paralel
1 şarkı ~2 dk
10 şarkı ~20 dk
1.000 şarkı ~33 saat ~11 saat
~32.000 şarkı ~44 saat ~15 saat

* Test sunucusu ölçümü: 1 şarkı = 1.9 dk. Production sunucu daha hızlı olabilir.

Sorun Giderme

❌ "enc.keyinfo yok" hatası

O şarkının HLS encryption dosyası eksik. --skip-high ile devam edebilirsin, sadece variant ve MP3 üretir.

❌ "MP3 bulunamadı" hatası

Orijinal MP3 dosyası silinmiş. O şarkıyı atlar, devam eder.

❌ FFmpeg hatası

ffmpeg -version ile versiyon kontrol et. Minimum 4.x gerekli.

⚠️ Yarıda kaldı / SSH koptu

Sorun yok. Aynı komutu tekrar çalıştır — script mevcut dosyaları siler ve yeniden oluşturur. Kaldığı yerden devam etmek için --offset kullan.

🔙 Geri almak istiyorum

Orijinal MP3'ler yerinde. Eski HLS zaten üzerine yazıldı. Eski haline döndürmek için eski filtreli script ile tekrar encode etmek gerekir (ama genellikle gerekmez).

26 Şubat 2026 • Muzibu.com.tr