Nokia Composer Ringtone Synthesizer dalam 512 Bytes

Sedikit nostalgia dalam terjemahan baru kami - mencoba menulis Nokia Composer dan mengarang melodi kami sendiri.


Apakah ada pembaca Anda yang menggunakan Nokia lama, misalnya, model 3310 atau 3210? Anda harus mengingat fitur hebatnya - kemampuan untuk membuat nada dering Anda sendiri langsung di keyboard ponsel. Dengan menyusun not dan jeda dalam urutan yang diinginkan, Anda dapat memainkan melodi populer dari speaker ponsel dan bahkan membagikan kreasi tersebut dengan teman! Jika Anda melewatkan era itu, seperti inilah tampilannya:







Tidak terkesan? Percayalah, dulu itu terdengar sangat keren, terutama bagi mereka yang menyukai musik.



Notasi musik (notasi musik) dan format yang digunakan dalam Nokia Composer dikenal sebagai RTTTL (Bahasa Transfer Teks Nada Dering). RTTL masih banyak digunakan oleh amatir untuk memainkan melodi monofonik di Arduino, dll.



RTTTL memungkinkan Anda untuk menulis musik hanya untuk satu suara, not hanya dapat dimainkan secara berurutan, tanpa akord dan polifoni. Namun, batasan ini ternyata menjadi fitur yang mematikan, karena format seperti itu mudah untuk ditulis dan dibaca, mudah untuk dianalisis dan direproduksi.



Dalam artikel ini, kami akan mencoba membuat pemutar RTTTL dalam JavaScript, menambahkan sedikit kode golf dan matematika untuk bersenang-senang agar kode tetap sesingkat mungkin.



Mengurai RTTTL



Untuk RTTTL, tata bahasa formal digunakan. Format RTTL adalah string yang terdiri dari tiga bagian: nama melodi, karakteristiknya, seperti tempo (BPM - ketukan per menit, yaitu jumlah ketukan per menit), oktaf dan durasi not, serta kode melodi itu sendiri. Namun, kami akan mensimulasikan perilaku Nokia Composer itu sendiri, mengurai hanya sebagian melodi dan menganggap tempo BPM sebagai parameter input terpisah. Nama melodi dan karakteristik layanannya ditinggalkan di luar cakupan artikel ini.



Melodi hanyalah urutan not / jeda, dipisahkan oleh koma dengan spasi tambahan. Setiap nada terdiri dari panjang (2/4/8/16/32/64), nada (c / d / e / f / g / a / b), ketajaman opsional (#), dan jumlah oktaf (dari 1 ke 3 karena hanya tiga oktaf yang didukung).



Cara termudah adalah dengan menggunakan ekspresi reguler . Browser baru hadir dengan fungsi matchAll yang sangat berguna yang mengembalikan satu set semua kecocokan dalam sebuah string:



const play = s => {
  for (m of s.matchAll(/(\d*)?(\.?)(#?)([a-g-])(\d*)/g)) {
    // m[1] is optional note duration
    // m[2] is optional dot in note duration
    // m[3] is optional sharp sign, yes, it goes before the note
    // m[4] is note itself
    // m[5] is optional octave number
  }
};
      
      





Hal pertama yang harus diketahui tentang setiap nada adalah bagaimana mengubahnya menjadi frekuensi gelombang suara. Tentu saja, kita dapat membuat HashMap untuk ketujuh surat catatan tersebut. Tetapi karena huruf-huruf ini berurutan, akan lebih mudah untuk menganggapnya sebagai angka. Untuk setiap surat catatan, kami menemukan kode karakter numerik yang sesuai (kode ASCII ). Untuk "A" nilainya menjadi 0x41, dan untuk "a" akan menjadi 0x61. Untuk "B / b" akan menjadi 0x42 / 0x62, untuk "C / c" akan menjadi 0x43 / 0x63, dan seterusnya:



// 'k' is an ASCII code of the note:
// A..G = 0x41..0x47
// a..g = 0x61..0x67
let k = m[4].charCodeAt();
      
      





Kita mungkin harus melewatkan bit yang paling signifikan, kita hanya akan menggunakan k & 7 sebagai indeks nada (a = 1, c = 2,…, g = 7). Apa berikutnya? Tahap selanjutnya sangat tidak menyenangkan, karena berhubungan dengan teori musik. Jika kita hanya memiliki 7 nada, maka kita menghitungnya sebagai semua 12. Ini karena nada kres / datar tersembunyi secara tidak merata di antara nada biasa:



         A#        C#    D#       F#    G#    A#         <- black keys
      A     B | C     D     E  F     G     A     B | C   <- white keys
      --------+------------------------------------+---
k&7:  1     2 | 3     4     5  6     7     1     2 | 3
      --------+------------------------------------+---
note: 9 10 11 | 0  1  2  3  4  5  6  7  8  9 10 11 | 0
      
      





Seperti yang Anda lihat, indeks not dalam oktaf meningkat lebih cepat daripada kode not (k & 7). Selain itu, ia meningkat secara non-linier: jarak antara E dan F atau antara B dan C adalah 1 seminada, bukan 2 seperti antara nada lainnya.



Secara intuitif, kita dapat mencoba mengalikan (k & 7) dengan 12/7 (12 seminada dan 7 nada):



note:          a     b     c     d     e      f     g
(k&7)*12/7: 1.71  3.42  5.14  6.85  8.57  10.28  12.0
      
      





Jika kita melihat angka-angka ini tanpa tempat desimal, kita akan segera melihat bahwa angka-angka ini non-linier, seperti yang kita harapkan:



note:                 a     b     c     d     e      f     g
(k&7)*12/7:        1.71  3.42  5.14  6.85  8.57  10.28  12.0
floor((k&7)*12/7):    1     3     5     6     8     10    12
                                  -------
      
      





Tapi tidak terlalu ... Jarak "halftone" harus antara B / C dan E / F, bukan antara C / D. Mari kita coba rasio lain (garis bawah menunjukkan seminada):



note:              a     b     c     d     e      f     g
floor((k&7)*1.8):  1     3     5     7     9     10    12
                                           --------

floor((k&7)*1.7):  1     3     5     6     8     10    11
                               -------           --------

floor((k&7)*1.6):  1     3     4     6     8      9    11
                         -------           --------

floor((k&7)*1.5):  1     3     4     6     7      9    10
                         -------     -------      -------
      
      





Jelas bahwa nilai 1.8 dan 1.5 tidak cocok: yang pertama hanya memiliki satu seminada, dan yang kedua terlalu banyak. Dua lainnya, 1.6 dan 1.7, tampaknya cocok untuk kita: 1.7 memberikan skala utama GA-BC-D-EF, dan 1.6 memberikan skala utama AB-CD-EFG. Hanya yang kita butuhkan!



Sekarang kita perlu mengubah nilainya sedikit sehingga C adalah 0, D adalah 2, E adalah 4, F adalah 5, dan seterusnya. Kita harus diimbangi dengan 4 seminada, tetapi mengurangi 4 akan membuat nada A di bawah nada C, jadi sebagai gantinya kita menambahkan 8 dan menghitung modulo 12 jika nilainya di luar satu oktaf:



let n = (((k&7) * 1.6) + 8) % 12;
// A  B C D E F G A  B C ...
// 9 11 0 2 4 5 7 9 11 0 ...
      
      





Kita juga harus memperhitungkan karakter "tajam", yang ditangkap oleh kelompok m [3] ekspresi reguler. Jika ada, tingkatkan nilai not sebesar 1 semitone:



// we use !!m[3], if m[3] is '#' - that would evaluate to `true`
// and gets converted to `1` because of the `+` sign.
// If m[3] is undefined - it turns into `false` and, thus, into `0`:
let n = (((k&7) * 1.6) + 8)%12 + !!m[3];

      
      





Akhirnya, kita harus menggunakan oktaf yang benar. Oktaf sudah disimpan sebagai angka dalam grup ekspresi reguler m [5]. Menurut teori musik, setiap oktaf adalah 12 Seminot, jadi kita bisa mengalikan angka oktaf dengan 12 dan menjumlahkan nilai nada:



// n is a note index 0..35 where 0 is C of the lowest octave,
// 12 is C of the middle octave and 35 is B of the highest octave.
let n =
  (((k&7) * 1.6) + 8)%12 + // note index 0..11
  !!m[3] +                 // semitote 0/1
  m[5] * 12;               // octave number
      
      





Menjepit



Apa yang terjadi jika seseorang menunjukkan jumlah oktaf 10 atau 1000? Ini dapat menyebabkan USG! Kita hanya boleh mengizinkan set nilai yang benar untuk parameter tersebut. Membatasi bilangan di antara dua lainnya biasa disebut "penjepitan". JS modern memiliki fungsi khusus Math.clamp (x, low, high) , yang, bagaimanapun, belum tersedia di sebagian besar browser. Alternatif paling sederhana adalah dengan menggunakan:



clamp = (x, a, b) => Math.max(Math.min(x, b), a);
      
      





Tetapi karena kami mencoba untuk menjaga kode kami sekecil mungkin, kami dapat menemukan kembali roda dan berhenti menggunakan fungsi matematika. Kami menggunakan default x = 0 untuk membuat penjepitan berfungsi dengan nilai yang tidak ditentukan juga:



clamp = (x=0, a, b) => (x < a && (x = a), x > b ? b : x);

clamp(0, 1, 3) // => 1
clamp(2, 1, 3) // => 2
clamp(8, 1, 3) // => 3
clamp(undefined, 1, 3) // => 1
      
      





Perhatikan Tempo dan Durasi



Kami berharap BPM akan diteruskan sebagai parameter ke fungsi play () keluar . Kami hanya perlu memvalidasinya:



bpm = clamp(bpm, 40, 400);
      
      





Nah, untuk menghitung berapa lama not harus bertahan dalam hitungan detik, kita bisa mendapatkan durasi musiknya (whole / half / quarter /…), yang disimpan di regex group m [1]. Kami menggunakan rumus berikut:



note_duration = m[1]; // can be 1,2,4,8,16,32,64
// since BPM is "beats per minute", or usually "quarter note beats per minute",
// BPM/4 would be "whole notes per minute" and BPM/60/4 would be "whole
// notes per second":
whole_notes_per_second = bpm / 240;
duration = 1 / (whole_notes_per_second * note_duration);
      
      





Jika kami menggabungkan rumus ini menjadi satu dan membatasi durasi catatan, kami mendapatkan:



// Assuming that default note duration is 4:
duration = 240 / bpm / clamp(m[1] || 4, 1, 64);
      
      





Selain itu, jangan lupa tentang kemampuan untuk menentukan nada dengan titik, yang menambah panjang nada saat ini sebesar 50%. Kami memiliki grup m [2], yang nilainya bisa menjadi satu poin . atau tidak ditentukan . Menerapkan metode yang sama yang kami gunakan sebelumnya untuk tanda tajam, kami mendapatkan:



// !!m[2] would be 1 if it's a dot, 0 otherwise
// 1+!![m2]/2 would be 1 for normal notes and 1.5 for dotted notes
duration = 240 / bpm / clamp(m[1] || 4, 1, 64) * (1+!!m[2]/2);
      
      





Sekarang kita dapat menghitung jumlah dan durasi untuk setiap not. Saatnya menggunakan API WebAudio untuk memainkan lagu.



WEBAUDIO



Kita hanya membutuhkan 3 bagian dari keseluruhan API WebAudio : konteks audio, osilator untuk memproses gelombang suara dan node penguatan untuk menghidupkan / mematikan suara. Saya akan menggunakan osilator persegi panjang untuk membuat melodi terdengar seperti dering telepon lama yang mengerikan itu:



// Osc -> Gain -> AudioContext
let audio = new (AudioContext() || webkitAudioContext);
let gain = audio.createGain();
let osc = audio.createOscillator();
osc.type = 'square';
osc.connect(gain);
gain.connect(audio.destination);
osc.start();
      
      





Kode ini dengan sendirinya tidak akan membuat musik, tetapi karena kita mengurai melodi RTTTL kita, kita dapat memberi tahu WebAudio nada apa yang harus diputar, kapan, dengan frekuensi apa dan untuk berapa lama.



Semua node WebAudio memiliki metode setValueAtTime khusus yang menjadwalkan peristiwa perubahan nilai (frekuensi atau perolehan node).



Jika Anda ingat, sebelumnya di artikel kami sudah memiliki kode ASCII untuk catatan yang disimpan sebagai k, indeks catatan sebagai n, dan kami memiliki durasi catatan dalam hitungan detik. Sekarang, untuk setiap nada, kita dapat melakukan hal berikut:



t = 0; // current time counter, in seconds
for (m of ......) {
  // ....we parse notes here...

  // Note frequency is calculated as (F*2^(n/12)),
  // Where n is note index, and F is the frequency of n=0
  // We can use C2=65.41, or C3=130.81. C2 is a bit shorter.
  osc.frequency.setValueAtTime(65.4 * 2 ** (n / 12), t);
  // Turn on gain to 100%. Besides notes [a-g], `k` can also be a `-`,
  // which is a rest sign. `-` is 0x2d in ASCII. So, unlike other note letters,
  // (k&8) would be 0 for notes and 8 for rest. If we invert `k`, then
  // (~k&8) would be 8 for notes and 0 for rest. Shifing it by 3 would be
  // ((~k&8)>>3) = 1 for notes and 0 for rests.
  gain.gain.setValueAtTime((~k & 8) >> 3, t);
  // Increate the time marker by note duration
  t = t + duration;
  // Turn off the note
  gain.gain.setValueAtTime(0, t);
}
      
      





Semuanya. Program play () kami sekarang dapat memainkan seluruh melodi yang ditulis dalam notasi RTTTL. Berikut kode lengkapnya, dengan beberapa klarifikasi kecil, seperti menggunakan v sebagai pintasan untuk setValueAtTime atau menggunakan variabel satu huruf (C = konteks, z = osilator karena menghasilkan suara yang mirip, g = penguatan, q = bpm, c = penjepit):



c = (x=0,a,b) => (x<a&&(x=a),x>b?b:x); // clamping function (a<=x<=b)
play = (s, bpm) => {
  C = new AudioContext;
  (z = C.createOscillator()).connect(g = C.createGain()).connect(C.destination);
  z.type = 'square';
  z.start();
  t = 0;
  v = (x,v) => x.setValueAtTime(v, t); // setValueAtTime shorter alias
  for (m of s.matchAll(/(\d*)?(\.?)([a-g-])(#?)(\d*)/g)) {
    k = m[4].charCodeAt(); // note ASCII [0x41..0x47] or [0x61..0x67]
    n = 0|(((k&7) * 1.6)+8)%12+!!m[3]+12*c(m[5],1,3); // note index [0..35]
    v(z.frequency, 65.4 * 2 ** (n / 12));
    v(g.gain, (~k & 8) / 8);
    t = t + 240 / bpm / (c(m[1] || 4, 1, 64))*(1+!!m[2]/2);
    v(g.gain, 0);
  }
};

// Usage:
play('8c 8d 8e 8f 8g 8a 8b 8c2', 120);
      
      





Saat dikecilkan dengan terser, kode ini hanya 417 byte. Ini masih di bawah ambang batas 512 byte. Mengapa kita tidak menambahkan fungsi stop () untuk menghentikan pemutaran:



C=0; // initialize audio conteext C at the beginning with zero
stop = _ => C && C.close(C=0);
// using `_` instead of `()` for zero-arg function saves us one byte :)
      
      





Ini masih sekitar 445 byte. Jika Anda menempelkan kode ini ke konsol pengembang, Anda dapat memainkan RTTTL dan berhenti bermain dengan memanggil fungsi JS play () dan stop () .



UI



Saya rasa menambahkan sedikit UI ke synthesizer kami akan membuat momen membuat musik menjadi lebih menyenangkan. Pada titik ini, saya sarankan untuk melupakan kode golf. Dimungkinkan untuk membuat editor kecil untuk nada dering RTTTL tanpa menyimpan byte menggunakan HTML dan CSS normal dan termasuk skrip minified hanya-putar.



Saya memutuskan untuk tidak memposting kode di sini karena cukup membosankan. Anda dapat menemukannya di github . Anda juga dapat mencoba versi demo di sini: https://zserge.com/nokia-composer/ .







Jika muse telah meninggalkan Anda dan Anda tidak ingin menulis musik sama sekali, cobalah beberapa lagu yang ada dan nikmati suaranya yang familiar:





Ngomong-ngomong, jika Anda benar-benar menulis sesuatu, bagikan url (semua lagu dan BPM disimpan di bagian hash url, jadi menyimpan / membagikan lagu Anda semudah menyalin atau menandai tautan.



Semoga Anda menikmatinya. Lihat artikel ini Anda dapat mengikuti berita di Github , Twitter atau berlangganan melalui rss .



All Articles