DJ Tak Sampai Lagi. Jadi Aku Jadikan Dua Telefon dan Satu Tablet Sebagai Speaker.

Tiga peranti dengan satu garis mendatar melaluinya

Written by

in

Cerita ni bermula dengan satu masalah yang sangat kecil: aku nak dengar lagu.

Bukan lagu dalam telefon dengan satu speaker kecil yang bunyinya macam radio rosak. Aku nak dengar dalam bilik, kuat sikit, dan aku ada dua telefon plus satu tablet yang semuanya boleh jadi speaker. Jadi kenapa tidak guna ketiga-tiganya sekali?

Sebab bila tiga peranti main lagu yang sama tanpa diselaraskan, kau bukan dapat speaker besar. Kau dapat gema. Satu telefon dah masuk bait kedua, satu lagi masih di bait pertama, dan yang ketiga pula tersekat-sekat sebab buffer.

Jadi keperluan sebenar bukan “main lagu”. Keperluan sebenar ialah semua peranti dengar detik yang sama, pada masa yang sama. Itu yang aku bina: satu laman kecil bernama Sync Stream.

Paparan desktop Sync Stream
Paparan desktop: satu playhead, playlist yang sesiapa boleh kawal, dan baris status yang menunjukkan ralat sync semasa — di sini sync: +0 ms (net ±7ms).

Kenapa tak guna aplikasi yang dah ada?

Aku pernah cuba. Masalahnya bukan aplikasi itu buruk — masalahnya ia mahu lagu itu ada dalam katalog mereka, atau dalam akaun mereka, atau dalam langganan mereka. Fail aku ada dalam folder aku sendiri. Aku nak main fail itu.

Dan satu lagi: aku mahu satu tab, satu telefon, satu tablet, dan satu desktop boleh masuk ke “bilik” yang sama tanpa pasang apa-apa. Kalau tetamu datang dan nak dengar sekali, dia cuma buka pautan.

Jadi senarai keperluan aku jadi macam ni:

  • Satu playhead untuk semua orang — play, pause, seek, tukar lagu, semua ikut.
  • Sesiapa boleh kawal. Tiada “host” yang kena biarkan tab terbuka.
  • Stesen mesti terus jalan walaupun tiada siapa tengok. Tutup telefon, lagu seterusnya tetap masuk.
  • Bilik berasingan, supaya playlist aku tidak bercampur dengan orang lain.
  • Dan satu benda yang aku tak sangka aku perlukan: butang mic untuk “DJ” bercakap di atas lagu.

Bahagian yang susah: masa, bukan bunyi

Menyiarkan audio itu senang. Yang susah ialah membuat tiga peranti bersetuju apa itu “sekarang”.

Setiap peranti ada jam sendiri, dan setiap satu lewat sedikit berbeza semasa menerima data. Kalau kau hantar “main di detik 42.0” dan setiap peranti faham arahan itu pada waktu tempatan masing-masing, kau tetap dapat gema.

Jadi server jadi satu-satunya sumber kebenaran. Setiap perubahan keadaan disimpan sebagai satu anchor:

{ "mediaId": "song.mp3", "playing": true, "posMs": 0, "anchor": 1792125755489 }

anchor ialah jam server pada ketika posMs itu betul. Jadi setiap klien boleh kira sendiri: kalau sedang main, posisi sekarang ialah posMs + (server_sekarang − anchor).

Masalahnya, klien tak tahu jam server. Penyelesaiannya kecil dan tepat: klien hantar empat permintaan masa yang pantas, ambil sampel dengan round trip paling rendah, dan guna itu sebagai anggaran jam server. Ralat rangkaian dan beza jam dua-dua terbatal serentak.

Selepas itu, setiap 200 milisaat, klien buat satu daripada dua benda:

  • Snap — lompat terus ke posisi yang betul, bila ada perubahan keadaan, bila fail baru dimuatkan, bila ralat melebihi 600 ms, atau bila ralat melebihi 200 ms dan sudah 2.5 saat sejak snap terakhir.
  • Nudge — laraskan kelajuan mainan ±5% supaya jurang mengecil tanpa didengar. Ini yang buat gerakan kecil tidak terasa.

Snap ialah jaminan. Nudge cuma kemasan. Ini penting, sebab ada browser yang langsung mengabaikan perubahan kecil playbackRate — ia melaporkan 1.05 sementara jam audio berjalan 1.00. Kalau kau bergantung pada nudge sahaja, satu peranti akan sentiasa tercicir.

Hasil ukur antara dua browser: 10–150 ms. Telinga tak dapat bezakan itu dengan “serentak”.

Snap yang gagal, dan satu telefon yang tertinggal 20 saat

Ini kesilapan yang aku ambil masa lama untuk faham. Browser hanya boleh seek dalam bahagian fail yang sudah dimuatkan. Kalau kau suruh ia lompat ke detik yang belum ada dalam buffer, ia tidak beri ralat — ia hanya diam-diam kekal di tempat lama.

Dan kerana kod aku menandakan “snap berjaya” sebaik sahaja arahan dihantar, gelung sync nampak sihat selama-lamanya. Dalam satu ujian, satu telefon tertinggal 20 saat sementara dua lagi betul.

Pembetulan yang aku buat:

  • Selepas snap, tunggu ~800 ms dan bandingkan currentTime dengan sasaran. Beza lebih 2 saat bermakna seek itu ditolak.
  • Baiki dengan buka semula sumber pada offset itu — src#t=detik membuat browser minta julat dari detik tersebut, bukan muat turun dari kosong.
  • Hadkan kekerapan pembaikan (ia kos satu reload, jadi ~8 saat sekali), dan namakan keadaan itu di baris status: 🔄 catching up….

Peraturan yang aku pegang sekarang: satu peranti yang lambat mesti nampak seperti sedang dibaiki, bukan seperti misteri.

Stesen mesti jalan walaupun tiada siapa dengar

Ini satu lagi benda yang aku jumpa selepas guna sendiri: tutup browser, dan muzik berhenti di hujung lagu. Sebabnya struktur — playlist yang dimajukan oleh klien sahaja akan tersekat bila tiada klien.

Jadi server memajukan bilik sendiri, sekali sesaat, berdasarkan durasi fail:

  • Tiada pendengar langsung → maju tepat pada hujung lagu. Satu fail sahaja? Ia ulang selama-lamanya.
  • Ada pendengar → tunggu durasi + 2 saat. Event ended daripada klien lebih tepat dan tiba dahulu; server hanya menyelamatkan tab yang beku atau di belakang.

Nota penting: jangan maju sebelum durasi. Aku pernah cuba 250 ms lebih awal, dan setiap lagu kehilangan hujungnya. Pendengar dengar lagu terpotong, bukan lagu habis.

Telefon: browser akan lawan kau

Dua perkara dalam browser telefon yang tak boleh dielakkan dari dalam halaman:

Satu, audio tidak akan bermula tanpa sentuhan pengguna. Dua, tab yang berada di belakang boleh dibunuh bila-bila masa. Dua-dua bukan bug yang boleh ditampal dengan JavaScript.

Jadi aku tulis shell Android yang kecil — WebView plus foreground service. Kerjanya cuma empat:

  • mediaPlaybackRequiresUserGesture = false — bunyi mula sendiri setiap kali app dibuka.
  • Foreground service — tukar app atau kunci skrin, muzik tetap main.
  • Kawalan skrin kunci — butang play/pause pada notifikasi menekan butang bilik, jadi pause dari skrin kunci bermakna pause untuk semua orang.
  • Cookie sesi kekal — kata laluan ditaip sekali sahaja.

Tiada pustaka pihak ketiga. Guna API platform sahaja.

Paparan mudah alih Sync Stream
Pada telefon, pemain menjadi kad padat supaya playlist dan bilik boleh dicapai tanpa skrol.

Bilik, volume, dan DJ

Setiap bilik ada stream sendiri, posisi sendiri, dan senarai pendengar sendiri. Pautannya mudah: /r/nama-bilik. Bilik dan apa yang sedang dimainkan kekal selepas server restart.

Volume pula satu tetapan bilik, bukan tetapan peranti. Kecilkan sekali, kecil di semua tempat — sama seperti satu speaker. Ini yang buat tiga peranti rasa seperti satu sistem, bukan tiga aplikasi berasingan.

Dan DJ: tekan butang mic, suara kau dihantar ke semua pendengar, dan muzik dikecilkan ke 18% — bukan dimatikan. Bila berhenti bercakap, muzik naik balik. Bunyi mic dihantar sebagai PCM mentah 12 kHz supaya semua browser boleh main tanpa risau format; kosnya sekitar 32 KB/s setiap pendengar, dan itu hanya semasa bercakap.

DJ sedang on air dalam Sync Stream
Semasa DJ bercakap, baris status menamakan siapa yang on air dan setiap peranti mengecilkan muzik ke 18%.

Apa yang aku belajar

Beberapa perkara yang aku akan ingat, dan bukan tentang muzik:

  • Ukur dulu, jangan rasa. “Rasa lambat” dan “memang lambat” dua benda berbeza. Setiap kali aku lompat terus ke penyelesaian, aku betulkan tempat yang salah.
  • Jaminan mesti ada walaupun kemasan gagal. Snap yang menyelamatkan sync; nudge cuma buat ia licin.
  • Keadaan yang “nampak sihat” boleh menyembunyikan kegagalan. Snap yang ditolak browser tetap dilaporkan sebagai berjaya. Kena sahkan kesan, bukan sahaja arahan.
  • Jangan simpan penanda sesi dalam sessionStorage. Ia mati bersama tab, dan pada Android setiap kali app dibuka adalah sesi baru — jadi pengguna kena tekan “Join” setiap kali. localStorage sahaja yang betul di sini.
  • Ralat rangkaian bukan navigasi. EventSource menyambung semula sendiri. Handler yang reload halaman bila sambungan putus akan menjadikan “internet kejap” sebagai “kena join balik”.

Sekarang

Aku dengar lagu dari tiga peranti dalam satu bilik. Satu telefon di atas meja, satu di sebelah, tablet di rak. Semuanya pada detik yang sama, dan aku boleh pause dari mana-mana satu.

DJ masih belum sampai. Tapi bila dia sampai nanti, dia cuma perlu buka pautan, taip nama, dan tekan butang mic. Tiada apa perlu dipasang.

Kodnya MIT dan terbuka di github.com/wzul/sync-stream — satu fail Node, sifar dependency npm, tiada build step. Kalau kau ada dua telefon yang tak dipakai dan satu playlist sendiri, kau boleh jalankan dalam satu petang.

Comments

Leave a Reply