MIDI:旋鈕、推桿、打擊墊、琴鍵#
像讀滑鼠一樣讀控制器(knob1、notePressed),以及背後的 MIDI 訊息。 39
oav-p5
controlChange()#
事件 oav-p5 function controlChange(cc, value, channel) {}
你來定義這個函式。每收到一個 MIDI 控制變化(旋鈕、推桿、調變輪、延音踏板),oav-p5 就呼叫它,不管控制器認不認得。
用宣告 keyPressed() 的方式宣告它:
js
function controlChange(cc, value, channel) {
// 有一個控制動了
}每個控制它都聽得到,包括不是旋鈕也不是推桿的那些,像調變輪和踏板,knobMoved() 永遠不會回報它們。每個控制器號碼的最新值也放在 OAVp5.cc ,所以只想看一眼的草圖可以在 draw() 裡讀 OAVp5.cc[1]。
同一則訊息進來時,呼叫的順序是:先 midiMessage(),再來是對應的那一個(noteOn()、controlChange()、pitchBend()),最後才是比較親切的那一個(padPressed()、knobMoved()、notePressed() 和它們的另一半)。
要先做什麼才有值
只要定義這個函式就夠了。oav-p5 在載入的時候就會向瀏覽器要 Web MIDI(預設,?oav_hardware=auto ),而草圖有定義這個函式的話,Web MIDI 不存在或被擋下時還會多跳出一張卡片。🎹、手機、OAVp5.input() 和其他程式送來的訊息也都會呼叫它。OAVp5.on('controlChange', fn) 是不必定義全域函式的同樣功能,它會回傳一個停止監聽的函式。
參數 名稱 型別 說明 ccNumber控制器號碼,0 到 127(1 是調變輪,64 是延音踏板,MiniLab 3 的第一顆旋鈕是 74)。 valueNumber這個控制現在的位置,0 到 1(MIDI 的 0 到 127 除以 127)。 channelNumberMIDI 通道,1 到 16。
注意 裡面出錯不會讓音樂停掉:下一則訊息還是照樣處理,錯誤訊息會出現在 console,也會在 🎹 旁邊的卡片上顯示檔案與行號。 範例
let shown = 'move a knob, the mod wheel or a pedal' ;
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(24);
}
function controlChange(cc, value, channel) {
shown = 'CC ' + cc + ' = ' + nf(value, 1, 2) + ' (channel ' + channel + ')' ;
}
function draw() {
background(20);
fill(255);
text(shown, width / 2, height / 2);
}
顯示你最後動的那個控制的號碼、值和通道。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
let sustain = false ;
function setup() {
createCanvas(400, 300);
}
function controlChange(cc, value) {
if (cc === 64) sustain = value >= 0.5; // the sustain pedal: 64 and up counts as down
}
function mousePressed() {
OAVp5.input([0xB0, 64, 127]); // no pedal? press the mouse
}
function mouseReleased() {
OAVp5.input([0xB0, 64, 0]);
}
function draw() {
background(sustain ? 90 : 20);
}
延音踏板踩著的時候背景變亮。沒有踏板的話,按住滑鼠:它送出同樣的訊息。
相關 knobMoved() 你來定義這個函式。旋鈕一轉動,oav-p5 就呼叫它。 faderMoved() 你來定義這個函式。推桿一移動,oav-p5 就呼叫它。 OAVp5.cc 128 個數字的陣列,每個 MIDI 控制器號碼一個:每個控制最後停在哪裡,0 到 1。OAVp5.cc[1] 是調變輪,OAVp5.cc[64] 是延音踏板。 midiMessage() 你來定義這個函式。每收到一則 MIDI 訊息,oav-p5 就呼叫它,帶著原始的位元組和它從哪裡來。 OAVp5.on() 用名字監聽一個 MIDI 事件,不必定義全域函式,並回傳一個停止監聽的函式。 OAVp5.input() 用位元組手動送一則 MIDI 訊息給 oav-p5:整個草圖的反應就像是控制器送出來的。 p5/oav-p5.js 第 255 行
fader(n)
回傳第 n 支推桿的位置,0 到 1:跟 fader1 到 fader8 一樣,但 n 可以是任何數字。
fader1 的函式版本,推桿的編號是變數的時候用。
參數 名稱 型別 說明 nNumber第幾支推桿,從 1 開始算。
回傳 Number 推桿的位置,0..1。還沒推過之前是 0。
範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
for (let i = 1; i <= 4; i++) circle(i * 80, height - 40 - fader(i) * 200, 30);
}
四個點,跟著四支推桿升高。
相關 fader1 控制器上推桿(滑桿)的位置,從 0 到 1:fader1 是第一支推桿,一直到 fader8。 knob() 回傳第 n 顆旋鈕的位置,0 到 1:跟 knob1 到 knob8 一樣,但 n 可以是任何數字。 pad() 回傳第 n 個打擊墊被按得多用力,0 到 1:跟 pad1 到 pad16 一樣,但 n 可以是任何數字。 p5/oav-p5.js 第 361 行
fader1同一條也包含: fader2 fader3 fader4 fader5 fader6 fader7 fader8
控制器上推桿(滑桿)的位置,從 0 到 1:fader1 是第一支推桿,一直到 fader8。
用法跟 knob1 完全一樣,只是對應控制器上的推桿,編號依控制器設定檔列出的順序(也就是機器上印的順序)。只有四支推桿的控制器(像 MiniLab 3)就只有 fader1 到 fader4。
型別 Number
範圍 0..1推桿移動的時候才會改變。還沒推過之前是 0。
要先做什麼才有值
不用呼叫任何東西。Web MIDI 在載入時就會去要,跟旋鈕一樣(見 knob1 )。讀推桿還會讓「Web MIDI 不存在或被拒絕」顯示成一張卡片。
範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
rect(40, height - 40, fader1 * (width - 80), 24); // fader 1 is a level bar
}
一條跟著推桿 1 走的橫條。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
相關 fader() 回傳第 n 支推桿的位置,0 到 1:跟 fader1 到 fader8 一樣,但 n 可以是任何數字。 knob1 控制器上旋鈕的位置,從 0 到 1:knob1 是第一顆旋鈕,knob2 是第二顆,一直到 knob8。 faderMoved() 你來定義這個函式。推桿一移動,oav-p5 就呼叫它。 p5/oav-p5.js 第 1 行
function faderMoved(n, value) {}
你來定義這個函式。推桿一移動,oav-p5 就呼叫它。
knobMoved() 的推桿版本:function faderMoved(n, value) { … }。
參數 名稱 型別 說明 nNumber哪一支推桿(1 是第一支)。 valueNumber現在的位置,0..1。
範例
let level = 0;
function setup() {
createCanvas(400, 300);
}
function faderMoved(n, value) {
if (n === 1) level = value; // fader 1 only
}
function draw() {
background(20);
fill(255);
rect(40, height - 40, level * (width - 80), 24);
}
一條跟著推桿 1 的橫條,用事件驅動,不是每個畫面去讀。
相關 fader1 控制器上推桿(滑桿)的位置,從 0 到 1:fader1 是第一支推桿,一直到 fader8。 knobMoved() 你來定義這個函式。旋鈕一轉動,oav-p5 就呼叫它。 controlChange() 你來定義這個函式。每收到一個 MIDI 控制變化(旋鈕、推桿、調變輪、延音踏板),oav-p5 就呼叫它,不管控制器認不認得。 p5/oav-p5.js 第 254 行
knob(n)
回傳第 n 顆旋鈕的位置,0 到 1:跟 knob1 到 knob8 一樣,但 n 可以是任何數字。
旋鈕的編號是變數的時候用它:for (let i = 1; i <= 16; i++) bar(i, knob(i));。草圖自己已經有 knob1 的話也可以用,oav-p5 不會去動那個名稱。
參數 名稱 型別 說明 nNumber第幾顆旋鈕,從 1 開始算(照控制器上印的)。
回傳 Number 旋鈕的位置,0..1。還沒轉過之前是 0。
範例
function setup() {
createCanvas(400, 300);
noStroke();
}
function draw() {
background(20);
fill(255);
for (let i = 1; i <= 8; i++) {
rect(i * 44 - 20, height - 20, 30, -10 - knob(i) * 250); // one bar per knob
}
}
八根長條,一顆旋鈕一根。還沒碰過的旋鈕停在最低。
相關 knob1 控制器上旋鈕的位置,從 0 到 1:knob1 是第一顆旋鈕,knob2 是第二顆,一直到 knob8。 fader() 回傳第 n 支推桿的位置,0 到 1:跟 fader1 到 fader8 一樣,但 n 可以是任何數字。 pad() 回傳第 n 個打擊墊被按得多用力,0 到 1:跟 pad1 到 pad16 一樣,但 n 可以是任何數字。 p5/oav-p5.js 第 360 行
knob1同一條也包含: knob2 knob3 knob4 knob5 knob6 knob7 knob8
控制器上旋鈕的位置,從 0 到 1:knob1 是第一顆旋鈕,knob2 是第二顆,一直到 knob8。
像讀 mouseX 一樣在 draw() 裡讀:background(knob1 * 255)。旋鈕的編號照控制器上印的順序,所以在 OAV 有設定檔 的任何控制器上,旋鈕 1 就是旋鈕 1:MiniLab 3 的第一顆是 CC 74,LPD8 的是 CC 70。草圖完全不用知道 CC 號碼。
遇到 OAV 沒有設定檔的控制器,你轉的第一顆旋鈕就變成 knob1,下一顆是 knob2,依你轉的順序往下排。超過八顆就用 knob(n) 。
型別 Number
範圍 0..1旋鈕轉動的時候才會改變,不是每個畫面一次。草圖只要讀最新的值。旋鈕還沒轉過之前是 0。
要先做什麼才有值
不用呼叫任何東西。預設的 hardware=auto 下,oav-p5 一載入就會向瀏覽器要 Web MIDI,不管草圖有沒有讀東西(Chrome、Edge、Firefox 有。Safari 和 iPad 沒有,那裡改用 🎹 或手機)。讀旋鈕還會讓 oav-p5 知道這份草圖要用 MIDI 玩,所以 Web MIDI 不存在或被拒絕的時候,🎹 旁邊會有一張卡片說明。完全沒用到這些的草圖不會看到那張卡片。?oav_hardware=click 則是等你按 🎹 面板裡的按鈕才去要。
注意 草圖如果本來就有自己的 knob1(let、函式都算),就由草圖保留,🎹 旁邊會有一張卡片說明改讀什麼(knob(1))。 搭配 OP Configurator 時,旋鈕也會依宣告順序推動 OPC 的滑桿。草圖自己有讀的那顆旋鈕就歸草圖:對應的滑桿不再跟著動。 範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(knob1 * 255); // knob 1 sets the brightness
}
轉旋鈕 1,整個畫布從黑變到白。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
function setup() {
createCanvas(400, 300);
colorMode(HSB, 1);
}
function draw() {
background(0.65, 0.5, 0.15);
fill(knob2, 0.8, 1); // knob 2: colour
circle(width / 2, height / 2, 40 + knob1 * 300); // knob 1: size
}
兩顆旋鈕,兩件事:大小與顏色。
相關 knob() 回傳第 n 顆旋鈕的位置,0 到 1:跟 knob1 到 knob8 一樣,但 n 可以是任何數字。 fader1 控制器上推桿(滑桿)的位置,從 0 到 1:fader1 是第一支推桿,一直到 fader8。 pad1 打擊墊被按得多用力,從 0 到 1:pad1 是第一個墊子,一直到 pad16。放開就是 0。 knobMoved() 你來定義這個函式。旋鈕一轉動,oav-p5 就呼叫它。 noteIsDown() 鍵 n(MIDI 號碼,或像 'C4' 這樣的音名)按著的時候是 true。 p5/oav-p5.js 第 288 行
function knobMoved(n, value) {}
你來定義這個函式。旋鈕一轉動,oav-p5 就呼叫它。
就像旋鈕版的 mouseMoved():
js
function knobMoved(n, value) {
// 旋鈕 n 現在在 value
}旋鈕每送出一格就呼叫一次,轉動的時候每秒會呼叫很多次。想要每個畫面讀一次,就改用 knob1 。
參數 名稱 型別 說明 nNumber哪一顆旋鈕(1 是第一顆,照控制器上印的)。 valueNumber現在的位置,0..1。
範例
let last = 'turn a knob' ;
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(32);
}
function knobMoved(n, value) {
last = 'knob ' + n + ': ' + nf(value, 1, 2);
}
function draw() {
background(20);
fill(255);
text(last, width / 2, height / 2);
}
顯示哪一顆旋鈕動了、現在在哪。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
相關 knob1 控制器上旋鈕的位置,從 0 到 1:knob1 是第一顆旋鈕,knob2 是第二顆,一直到 knob8。 faderMoved() 你來定義這個函式。推桿一移動,oav-p5 就呼叫它。 controlChange() 你來定義這個函式。每收到一個 MIDI 控制變化(旋鈕、推桿、調變輪、延音踏板),oav-p5 就呼叫它,不管控制器認不認得。 p5/oav-p5.js 第 254 行
lastNoteFrameCount#
變數 oav-p5 lastNoteFrameCount
看到上一個鍵按下的那一格的 frameCount:frameCount - lastNoteFrameCount 就是幾格以前。
lastNoteTs 的「數格子」版本:按下之後的第一格是 0,接下來 1、2、3……
型別 Number畫面的編號,跟 frameCount 同一套數法,跟 note 一起更新。還沒按過任何鍵之前是 -Infinity。oav-p5 0.8.3 起。
範例
function setup() {
createCanvas(400, 300);
noStroke();
}
function draw() {
background(20);
const f = frameCount - lastNoteFrameCount; // frames since a key went down
fill(120, 190, 255);
circle(200, 150, max(0, 200 - f * 5)); // shrinks by 5 px a frame
}
每按一個鍵(🎹 也可以),大圓就回來,然後每一格縮 5 px。
相關 lastNoteTs 上一個鍵是什麼時候按下的,用 millis() 的時鐘:millis() - lastNoteTs 就是幾毫秒前。 note 最後被按下的那個鍵的 MIDI 號碼(0 到 127)。 notePressed() 你來定義這個函式。每當有一個鍵(或打擊墊)按下,oav-p5 就呼叫它。 lastKickFrameCount 看到上一下大鼓(小鼓、鈸、拍子)的那一格的 frameCount:frameCount - lastKickFrameCount 就是幾格以前。 p5/oav-p5.js 第 333 行
lastNoteTs
上一個鍵是什麼時候按下的,用 millis() 的時鐘:millis() - lastNoteTs 就是幾毫秒前。
任何鍵都算:MIDI 鍵盤、畫面上的 🎹、手機。放開鍵不會改變它。
型別 Number毫秒,跟 millis() 同一個時鐘,跟 note 、velocity 一起更新。還沒按過任何鍵之前是 -Infinity。oav-p5 0.8.2 起。
範例
function setup() {
createCanvas(400, 300);
noStroke();
}
function draw() {
background(20);
const ago = millis() - lastNoteTs; // ms since a key went down
fill(255, 200 * exp(-ago / 400) + 30);
circle(200, 150, 60 + 160 * exp(-ago / 400));
}
每按一個鍵(🎹 也可以),圓就變亮變大,然後慢慢暗下來。
相關 note 最後被按下的那個鍵的 MIDI 號碼(0 到 127)。 velocity 最後那個鍵被按得多用力,從 0 到 1。 notePressed() 你來定義這個函式。每當有一個鍵(或打擊墊)按下,oav-p5 就呼叫它。 lastKickTs 上一下大鼓(小鼓、鈸、拍子)是什麼時候來的,用 millis() 的時鐘:millis() - lastKickTs 就是幾毫秒前。 p5/oav-p5.js 第 330 行
function midiMessage(bytes, source) {}
你來定義這個函式。每收到一則 MIDI 訊息,oav-p5 就呼叫它,帶著原始的位元組和它從哪裡來。
用宣告 keyPressed() 的方式宣告它:
js
function midiMessage(bytes, source) {
// 任何進來的訊息
}它是網子最大的一個:每個音符、控制變化、音高輪訊息都會先呼叫它,而且 oav-p5 沒有比較親切的名字可以叫的訊息,只有它聽得到,像是通道壓力(0xD0)、各鍵觸後(0xA0)、音色切換(0xC0)。OAVp5.last 只留最新的音符、控制變化或音高輪,不留這些。
系統訊息它聽不到:MIDI 時鐘、start、stop、song position 都交給 OAVp5.clock ,系統專用(sysex)訊息直接丟掉。力道是 0 的 note-on 在這裡照送出的樣子到達(note-on,力道 0),把它當成放開的是 noteOff() 。
同一則訊息進來時,呼叫的順序是:先 midiMessage(),再來是對應的那一個(noteOn()、controlChange()、pitchBend()),最後才是比較親切的那一個(padPressed()、knobMoved()、notePressed() 和它們的另一半)。
要先做什麼才有值
只要定義這個函式就夠了。oav-p5 在載入的時候就會向瀏覽器要 Web MIDI(預設,?oav_hardware=auto ),而草圖有定義這個函式的話,Web MIDI 不存在或被擋下時還會多跳出一張卡片。🎹、手機、OAVp5.input() 和其他程式送來的訊息也都會呼叫它。OAVp5.on('midiMessage', fn) 是不必定義全域函式的同樣功能,它會回傳一個停止監聽的函式。
參數 名稱 型別 說明 bytesArray<Number>這則訊息,三個數字 [status, data1, data2]。status 位元組的上半是種類,下半是通道(0x90 是通道 1 的 note-on,0x93 是通道 4 的)。資料位元組是 0 到 127,訊息沒有的那個位元組是 0。 sourceString它從哪裡來:'hardware'(真的控制器)、'screen'(🎹)、'phone'、'virtual-out'(某個程式送進 OAV Virtual MIDI)、'link'(同一個房間的另一頁),或你在 OAVp5.input() 給的標籤。
注意 裡面出錯不會讓音樂停掉:下一則訊息還是照樣處理,錯誤訊息會出現在 console,也會在 🎹 旁邊的卡片上顯示檔案與行號。 範例
let shown = 'play something' ;
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(20);
}
function midiMessage(bytes, source) {
shown = bytes.map((b) => '0x' + b.toString(16).toUpperCase()).join(' ' ) + ' from ' + source;
}
function draw() {
background(20);
fill(255);
text(shown, width / 2, height / 2);
}
印出最後一則訊息的原始位元組,和它從哪裡來。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
let pressure = 0;
function setup() {
createCanvas(400, 300);
}
function midiMessage(bytes) {
if ((bytes[0] & 0xF0) === 0xD0) pressure = bytes[1] / 127; // channel pressure (aftertouch)
}
function mouseMoved() {
OAVp5.input([0xD0, constrain(round(mouseX / width * 127), 0, 127)], 'mouse' ); // no aftertouch? move the mouse
}
function draw() {
background(20);
circle(width / 2, height / 2, 40 + pressure * 260);
}
圓跟著觸後(aftertouch)走,這個沒有比較親切的函式會回報。沒有支援觸後的鍵盤的話,在畫布上移動滑鼠:它送出同樣的訊息。
相關 noteOn() 你來定義這個函式。每收到一個 MIDI 的 note-on,oav-p5 就呼叫它,帶著是哪個鍵、多用力、哪個通道。 controlChange() 你來定義這個函式。每收到一個 MIDI 控制變化(旋鈕、推桿、調變輪、延音踏板),oav-p5 就呼叫它,不管控制器認不認得。 pitchBend() 你來定義這個函式。音高輪一動,oav-p5 就呼叫它,帶著它的位置,從 −1 到 1。 OAVp5.last 最近收到的音符、控制變化或音高輪訊息:它的種類、號碼、值、通道、來自哪裡、是哪個控制。在任何訊息之前是 null。 OAVp5.on() 用名字監聽一個 MIDI 事件,不必定義全域函式,並回傳一個停止監聽的函式。 OAVp5.input() 用位元組手動送一則 MIDI 訊息給 oav-p5:整個草圖的反應就像是控制器送出來的。 p5/oav-p5.js 第 255 行
note
最後被按下的那個鍵的 MIDI 號碼(0 到 127)。
中央 C 是 60,A4 是 69。noteName(note) 把號碼寫成音名(60 → 'C4'),noteFreq(note) 換成赫茲。velocity 告訴你按得多用力。想要每個鍵都知道、不只最後一個,就定義 notePressed() 。
型別 Number
範圍 0..127鍵按下的時候設定,放開之後也保留。還沒有按過任何鍵之前是 0。
要先做什麼才有值
不用呼叫任何東西。Web MIDI 在載入時就會去要(見 knob1 )。跟旋鈕不一樣,單單讀 note 並不會讓 oav-p5 知道草圖要用 MIDI 玩:定義 notePressed() ,或讀旋鈕、墊子才會,也才會讓「Web MIDI 不存在或被拒絕」顯示成卡片。🎹 和手機隨時都能用。
注意 草圖如果自己有 note(let note、函式都算),就由草圖保留。改讀 midi.last.note。oav-p5 第一次會用卡片告訴你。 範例
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(64);
}
function draw() {
background(20);
fill(255);
text(noteName(note), width / 2, height / 2); // the last key, as a name
}
顯示你最後彈的那個鍵的名字。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
p5/oav-p5.js 第 326 行
noteIsDown(n)
鍵 n(MIDI 號碼,或像 'C4' 這樣的音名)按著的時候是 true。
就是 MIDI 版的 keyIsDown()。noteIsDown('C4') 跟 noteIsDown(60) 一樣:C4 是中央 C。不是音符的名稱('H4'、'C-2')會讓草圖停下來,並用一張卡片告訴你怎麼寫。
參數 名稱 型別 說明 nNumber | String哪個鍵:MIDI 號碼(60)或音名('C4'、'c#4'、'Db3')。
回傳 Boolean 那個鍵現在按著就是 true。
範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(noteIsDown('C4' ) ? color(255, 211, 107) : 80);
circle(width / 2, height / 2, 140); // lights up while middle C is held
}
只有按著 C4(中央 C)的時候才亮的圓。螢幕鍵盤上,標著 C4 的那個 C 就是中央 C。
p5/oav-p5.js 第 363 行
noteIsPressed
只要有任何一個鍵按著,就是 true。
想問某一個特定的鍵,用 noteIsDown() 。midi.held 列出現在按著的所有鍵,由低到高。
型別 Boolean像 keyIsPressed,但是對 MIDI 琴鍵。沒有任何鍵按著就是 false。
範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(noteIsPressed ? 90 : 20); // lighter while any key is down
}
只要有鍵按著,背景就變亮。
p5/oav-p5.js 第 326 行
function noteOff(note, channel) {}
你來定義這個函式。每收到一個 MIDI 的 note-off,oav-p5 就呼叫它,帶著是哪個鍵、哪個通道。
noteOn() 的另一半:
js
function noteOff(note, channel) {
// 有一個鍵或打擊墊放開了
}note-off 訊息會呼叫它,力道是 0 的 note-on 也會(很多鍵盤用那個來代替 note-off)。沒有力道這個參數:oav-p5 不傳放開時的力道。
同一則訊息進來時,呼叫的順序是:先 midiMessage(),再來是對應的那一個(noteOn()、controlChange()、pitchBend()),最後才是比較親切的那一個(padPressed()、knobMoved()、notePressed() 和它們的另一半)。
要先做什麼才有值
只要定義這個函式就夠了。oav-p5 在載入的時候就會向瀏覽器要 Web MIDI(預設,?oav_hardware=auto ),而草圖有定義這個函式的話,Web MIDI 不存在或被擋下時還會多跳出一張卡片。🎹、手機、OAVp5.input() 和其他程式送來的訊息也都會呼叫它。OAVp5.on('noteOff', fn) 是不必定義全域函式的同樣功能,它會回傳一個停止監聽的函式。
參數 名稱 型別 說明 noteNumber放開的那個鍵的 MIDI 號碼,0 到 127。 channelNumberMIDI 通道,1 到 16。
注意 裡面出錯不會讓音樂停掉:下一則訊息還是照樣處理,錯誤訊息會出現在 console,也會在 🎹 旁邊的卡片上顯示檔案與行號。 範例
const down = {};
let held = 'play a key and let it go' ;
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(24);
}
function noteOn(note) { down[note] = millis(); }
function noteOff(note) {
if (down[note] !== undefined ) {
held = noteName(note) + ' was held ' + round(millis() - down[note]) + ' ms' ;
delete down[note];
}
}
function draw() {
background(20);
fill(255);
text(held, width / 2, height / 2);
}
把每個 noteOn() 和它的 noteOff() 配成一對,說出這個鍵按了多久。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
p5/oav-p5.js 第 255 行
function noteOn(note, velocity, channel) {}
你來定義這個函式。每收到一個 MIDI 的 note-on,oav-p5 就呼叫它,帶著是哪個鍵、多用力、哪個通道。
用宣告 keyPressed() 的方式宣告它:
js
function noteOn(note, velocity, channel) {
// 有一個鍵或打擊墊按下去了
}每則 note-on 訊息進來就呼叫一次,照它進來的樣子。它比 notePressed() 低一層:同一個時刻,但多了通道,而且打擊墊也會呼叫,就算草圖有定義 padPressed() 也一樣。力道是 0 的 note-on(有些鍵盤用它來代替 note-off)在這裡不算 note-on,會去呼叫 noteOff() 。
同一則訊息進來時,呼叫的順序是:先 midiMessage(),再來是對應的那一個(noteOn()、controlChange()、pitchBend()),最後才是比較親切的那一個(padPressed()、knobMoved()、notePressed() 和它們的另一半)。
要先做什麼才有值
只要定義這個函式就夠了。oav-p5 在載入的時候就會向瀏覽器要 Web MIDI(預設,?oav_hardware=auto ),而草圖有定義這個函式的話,Web MIDI 不存在或被擋下時還會多跳出一張卡片。🎹、手機、OAVp5.input() 和其他程式送來的訊息也都會呼叫它。OAVp5.on('noteOn', fn) 是不必定義全域函式的同樣功能,它會回傳一個停止監聽的函式。
參數 名稱 型別 說明 noteNumber這個鍵的 MIDI 號碼,0 到 127(60 是中央 C)。noteName(note) 可以把它轉成音名。 velocityNumber多用力,大於 0、最大 1(MIDI 的 1 到 127 除以 127)。 channelNumberMIDI 通道,1 到 16。MiniLab 3 和 LPD8 的打擊墊都是從通道 10 送出。
注意 裡面出錯不會讓音樂停掉:下一則訊息還是照樣處理,錯誤訊息會出現在 console,也會在 🎹 旁邊的卡片上顯示檔案與行號。 範例
let shown = 'play a key' ;
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(24);
}
function noteOn(note, velocity, channel) {
shown = noteName(note) + ' velocity ' + nf(velocity, 1, 2) + ' channel ' + channel;
}
function draw() {
background(20);
fill(255);
text(shown, width / 2, height / 2);
}
顯示你彈的那個鍵、多用力、哪個通道。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
let hits = [];
function setup() {
createCanvas(400, 300);
noStroke();
}
function noteOn(note, velocity, channel) {
hits.push({ note, velocity, pad: channel === 10, born: millis() }); // channel 10: the pads
}
function draw() {
background(20);
hits = hits.filter((h) => millis() - h.born < 1500);
for (const h of hits) {
const fade = 1 - (millis() - h.born) / 1500;
const x = map(h.note, 24, 96, 20, width - 20);
fill(h.pad ? color(255, 90, 31, fade * 255) : color(120, 190, 255, fade * 255));
if (h.pad) square(x - 25, 125, 50 + h.velocity * 40);
else circle(x, 150, 20 + h.velocity * 80);
}
}
鍵留下藍色圓,打擊墊留下橘色方塊,依音高排開,打得越用力越大,過一下就淡掉。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
p5/oav-p5.js 第 255 行
function notePressed(note, velocity) {}
你來定義這個函式。每當有一個鍵(或打擊墊)按下,oav-p5 就呼叫它。
用宣告 keyPressed() 的方式宣告它:
js
function notePressed(note, velocity) {
// 有一個鍵按下去了
}每個鍵呼叫一次,帶著是哪個鍵、多用力。打擊墊也會呼叫它,除非草圖有定義 padPressed() 。
要先做什麼才有值
只要定義了它就夠了:不管是哪一種來源,oav-p5 都會呼叫它。Web MIDI 不存在或被拒絕的時候,🎹 旁邊會有一張卡片說明(Web MIDI 本身在載入時就會去要,見 knob1 )。🎹 和手機上彈的鍵也會呼叫它。
參數 名稱 型別 說明 noteNumber這個鍵的 MIDI 號碼(60 是中央 C)。noteName(note) 可以把它轉成音名。 velocityNumber多用力,0..1。
注意 裡面出錯不會讓音樂停掉:下一個鍵還是照樣處理,錯誤訊息會出現在 console,也會在 🎹 旁邊的卡片上顯示檔案與行號。 範例
function setup() {
createCanvas(400, 300);
background(20);
noStroke();
}
function notePressed(note, velocity) {
fill(random(255), random(255), random(255), 200);
circle(map(note, 36, 96, 20, width - 20), height / 2, 10 + velocity * 120);
}
每個鍵留下一個圓:低音在左、高音在右,按得越用力圓越大。
相關 noteReleased() 你來定義這個函式。每當有一個鍵(或打擊墊)放開,oav-p5 就呼叫它。 padPressed() 你來定義這個函式。每打一下打擊墊,oav-p5 就呼叫它一次。 noteOn() 你來定義這個函式。每收到一個 MIDI 的 note-on,oav-p5 就呼叫它,帶著是哪個鍵、多用力、哪個通道。 note 最後被按下的那個鍵的 MIDI 號碼(0 到 127)。 noteName() 把 MIDI 號碼轉成音名:noteName(61) 是 'C#4'。 p5/oav-p5.js 第 254 行
function noteReleased(note) {}
你來定義這個函式。每當有一個鍵(或打擊墊)放開,oav-p5 就呼叫它。
notePressed() 的另一半,就像 keyReleased() 之於 keyPressed():
js
function noteReleased(note) {
// 有一個鍵放開了
}
參數 名稱 型別 說明 noteNumber被放開的那個鍵的 MIDI 號碼。
範例
let held = {};
function setup() {
createCanvas(400, 300);
}
function notePressed(note) { held[note] = millis(); }
function noteReleased(note) { delete held[note]; }
function draw() {
background(20);
fill(255);
text(Object.keys(held).map((n) => noteName(+n)).join(' ' ), 20, 40); // the keys down now
}
列出現在按著的那些鍵的名字。
p5/oav-p5.js 第 254 行
pad(n)
回傳第 n 個打擊墊被按得多用力,0 到 1:跟 pad1 到 pad16 一樣,但 n 可以是任何數字。
參數 名稱 型別 說明 nNumber第幾個墊子,從 1 開始算。
回傳 Number 墊子按著的時候是力道(0..1),放開是 0。
範例
function setup() {
createCanvas(400, 300);
noStroke();
}
function draw() {
background(20);
for (let i = 1; i <= 8; i++) {
fill(255, 90, 31, 60 + pad(i) * 195);
square(20 + ((i - 1) % 4) * 92, 40 + floor((i - 1) / 4) * 110, 80);
}
}
八個方塊排成格子,你打哪個墊子,哪個方塊就亮起來。
相關 pad1 打擊墊被按得多用力,從 0 到 1:pad1 是第一個墊子,一直到 pad16。放開就是 0。 knob() 回傳第 n 顆旋鈕的位置,0 到 1:跟 knob1 到 knob8 一樣,但 n 可以是任何數字。 padPressed() 你來定義這個函式。每打一下打擊墊,oav-p5 就呼叫它一次。 p5/oav-p5.js 第 362 行
pad1同一條也包含: pad2 pad3 pad4 pad5 pad6 pad7 pad8 pad9 pad10 pad11 pad12 pad13 pad14 pad15 pad16
打擊墊被按得多用力,從 0 到 1:pad1 是第一個墊子,一直到 pad16。放開就是 0。
像一個知道力道的滑鼠按鍵那樣讀:if (pad1 > 0) fill(255, 90, 31)。墊子編號依控制器設定檔列出的順序。想「每打一下反應一次」而不是每個畫面都問,就定義 padPressed() 。
型別 Number
範圍 0..1墊子按著的時候是這一下的力道(1 是控制器能送出的最大力道),一放開就變成 0。
要先做什麼才有值
不用呼叫任何東西。Web MIDI 在載入時就會去要,跟旋鈕一樣(見 knob1 )。讀墊子還會讓「Web MIDI 不存在或被拒絕」顯示成一張卡片。
範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255, 90, 31);
circle(width / 2, height / 2, 40 + pad1 * 260); // swells while pad 1 is held
}
按著墊子 1 的時候圓會脹大,打得越用力越大。用螢幕上的控制器就點第一個墊子。
相關 pad() 回傳第 n 個打擊墊被按得多用力,0 到 1:跟 pad1 到 pad16 一樣,但 n 可以是任何數字。 padPressed() 你來定義這個函式。每打一下打擊墊,oav-p5 就呼叫它一次。 padReleased() 你來定義這個函式。每當打擊墊放開,oav-p5 就呼叫它。 knob1 控制器上旋鈕的位置,從 0 到 1:knob1 是第一顆旋鈕,knob2 是第二顆,一直到 knob8。 p5/oav-p5.js 第 1 行
function padPressed(n, velocity) {}
你來定義這個函式。每打一下打擊墊,oav-p5 就呼叫它一次。
js
function padPressed(n, velocity) {
// 墊子 n 被打了一下
}草圖有定義 padPressed() 的話,打擊墊會呼叫它,而不會 呼叫 notePressed() 。沒有定義的話,打擊墊會像任何一個鍵一樣呼叫 notePressed()。
參數 名稱 型別 說明 nNumber哪個墊子(1 是第一個)。 velocityNumber多用力,0..1。
範例
let hits = [];
function setup() {
createCanvas(400, 300);
noStroke();
}
function padPressed(n, velocity) {
hits.push({ x: 40 + ((n - 1) % 4) * 105, y: 70 + floor((n - 1) / 4) * 130, r: 20 + velocity * 60 });
}
function draw() {
background(20, 40);
fill(255, 90, 31);
for (const h of hits) { circle(h.x, h.y, h.r); h.r *= 0.95; }
hits = hits.filter((h) => h.r > 2);
}
每打一下,就在那個墊子在 4×2 格子裡的位置冒出一個圓,然後慢慢消失。
p5/oav-p5.js 第 254 行
function padReleased(n) {}
你來定義這個函式。每當打擊墊放開,oav-p5 就呼叫它。
padPressed() 的另一半:function padReleased(n) { … }。沒有定義的話,打擊墊會呼叫 noteReleased() 。
參數 名稱 型別 說明 nNumber哪個墊子(1 是第一個)。
範例
let down = {};
function setup() {
createCanvas(400, 300);
}
function padPressed(n) { down[n] = true ; }
function padReleased(n) { down[n] = false ; }
function draw() {
background(20);
fill(255, 90, 31);
for (let i = 1; i <= 8; i++) if (down[i]) square(10 + (i - 1) * 48, 130, 40); // lit while held
}
一個墊子一個方塊,從按下到放開都亮著。
p5/oav-p5.js 第 254 行
function pitchBend(value, channel) {}
你來定義這個函式。音高輪一動,oav-p5 就呼叫它,帶著它的位置,從 −1 到 1。
用宣告 keyPressed() 的方式宣告它:
js
function pitchBend(value, channel) {
// 輪子動了
}音高輪放開之後通常會自己彈回中間,所以你會收到一串值,最後停在 0。MIDI 用 14 位元送出位置(0 到 16383,中間是 8192),oav-p5 轉成 −1 到 1 給你。最新的位置也放在 OAVp5.bend 。
同一則訊息進來時,呼叫的順序是:先 midiMessage(),再來是對應的那一個(noteOn()、controlChange()、pitchBend()),最後才是比較親切的那一個(padPressed()、knobMoved()、notePressed() 和它們的另一半)。
要先做什麼才有值
只要定義這個函式就夠了。oav-p5 在載入的時候就會向瀏覽器要 Web MIDI(預設,?oav_hardware=auto ),而草圖有定義這個函式的話,Web MIDI 不存在或被擋下時還會多跳出一張卡片。🎹、手機、OAVp5.input() 和其他程式送來的訊息也都會呼叫它。OAVp5.on('pitchBend', fn) 是不必定義全域函式的同樣功能,它會回傳一個停止監聽的函式。
參數 名稱 型別 說明 valueNumber輪子的位置:−1 是推到底往下,0 是中間,1 是推到底往上。 channelNumberMIDI 通道,1 到 16。
注意 音高輪沒有對應到別的東西:沒有叫 bend 的變數,只有這個函式和 OAVp5.bend。 裡面出錯不會讓音樂停掉:下一則訊息還是照樣處理,錯誤訊息會出現在 console,也會在 🎹 旁邊的卡片上顯示檔案與行號。 範例
let bend = 0;
function setup() {
createCanvas(400, 300);
}
function pitchBend(value) {
bend = value;
}
function draw() {
background(20);
circle(width / 2 + bend * 150, height / 2, 50); // wheel up: right, wheel down: left
}
圓隨音高輪往左右滑,輪子彈回中間時它也回到中間。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
let bend = 0;
function setup() {
createCanvas(400, 300);
}
function pitchBend(value) { bend = value; }
function mouseDragged() {
OAVp5.input([0xE0, 0, constrain(round(mouseX / width * 127), 0, 127)]); // no wheel? drag the mouse
}
function draw() {
background(20);
const x = spring(width / 2 + bend * 150, 120, 8); // a spring smooths the steps
circle(x, height / 2, 50);
}
同一個輪子,再用 spring() 處理。沒有輪子的話,在畫布上拖動滑鼠:它送出同樣的訊息。
p5/oav-p5.js 第 255 行
velocity
最後那個鍵被按得多用力,從 0 到 1。
MIDI 的力道是 0 到 127。oav-p5 給你的是除以 127 之後的值,所以 1 就是控制器能送出的最大力道。
型別 Number
範圍 0..1跟 note 一起在鍵按下的時候設定。放開之後保留。還沒按過任何鍵之前是 0。
注意 草圖自己有 velocity 的話就由草圖保留。改讀 midi.last.value。 範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255, 90, 31);
circle(width / 2, height / 2, 20 + velocity * 300); // a harder key makes a bigger circle
}
圓的大小停在最後那個鍵的力道上。
p5/oav-p5.js 第 326 行
OAVp5.bend
音高輪現在的位置,從 −1(推到底往下)、經過 0(中間)到 1(推到底往上)。
像一顆可以往兩邊轉的旋鈕那樣在 draw() 裡讀:x = width / 2 + OAVp5.bend * 150。MIDI 用 14 位元送出位置(0 到 16383,中間是 8192)。oav-p5 把兩半分別對應成 −1 到 0 和 0 到 1,所以推到底剛好是 −1 和 1。想每次移動都被通知,就定義 pitchBend() 。
型別 Number
範圍 -1..1輪子動的時候才會變。還沒動過之前是 0,有彈簧的輪子彈回中間時也是 0。
注意 midi.bend 是同一個數字。沒有叫 bend 的變數。範例
function setup() {
createCanvas(400, 300);
}
function mouseDragged() {
OAVp5.input([0xE0, 0, constrain(round(mouseX / width * 127), 0, 127)]); // no wheel? drag the mouse
}
function draw() {
background(20);
circle(width / 2 + OAVp5.bend * 150, height / 2, 50); // wheel up: right, wheel down: left
text('bend ' + nf(OAVp5.bend, 1, 2), 20, 30);
}
圓跟著音高輪走。沒有輪子的話,在畫布上拖動滑鼠:它送出同樣的訊息。
相關 pitchBend() 你來定義這個函式。音高輪一動,oav-p5 就呼叫它,帶著它的位置,從 −1 到 1。 OAVp5.cc 128 個數字的陣列,每個 MIDI 控制器號碼一個:每個控制最後停在哪裡,0 到 1。OAVp5.cc[1] 是調變輪,OAVp5.cc[64] 是延音踏板。 OAVp5.note 128 個數字的陣列,每個 MIDI 鍵一個:那個鍵被按得多用力,0 到 1,放開就是 0。OAVp5.note[60] 是中央 C。 p5/oav-p5.js 第 549 行
OAVp5.cc
128 個數字的陣列,每個 MIDI 控制器號碼一個:每個控制最後停在哪裡,0 到 1。OAVp5.cc[1] 是調變輪,OAVp5.cc[64] 是延音踏板。
controlChange() 的陣列版:不用定義函式,在 draw() 裡讀任何控制器。每個 CC 它都記著,包括不是旋鈕也不是推桿的那些,像調變輪(1)、音量(7)和延音踏板(64,0.5 以上算踩下去)。MIDI 通道被忽略:那個控制器號碼的最新值勝出,不管它是從哪個通道來的。
型別 Array<Number>
範圍 128 numbers, 0..1 each索引是控制器(CC)號碼,0 到 127,值是最後收到的那個除以 127。訊息進來的時候才會變。控制還沒動過之前是 0,這跟「剛好在 0」不是同一件事。
注意 midi.cc 是同一個陣列。比較親切的 knob1 和 fader1 是透過控制器的設定檔對應過去的,讀的是同樣的資料。範例
function setup() {
createCanvas(400, 300);
}
function mouseDragged() {
OAVp5.input([0xB0, 1, constrain(round(mouseX / width * 127), 0, 127)]); // no wheel? drag the mouse
}
function draw() {
background(OAVp5.cc[64] >= 0.5 ? 90 : 20); // sustain pedal down: lighter
circle(width / 2, height / 2, 40 + OAVp5.cc[1] * 300); // mod wheel: size
}
調變輪(CC 1)決定圓的大小,延音踏板(CC 64)讓背景變亮。沒有控制器的話,在畫布上拖動滑鼠:它會送出 CC 1。
相關 controlChange() 你來定義這個函式。每收到一個 MIDI 控制變化(旋鈕、推桿、調變輪、延音踏板),oav-p5 就呼叫它,不管控制器認不認得。 knob1 控制器上旋鈕的位置,從 0 到 1:knob1 是第一顆旋鈕,knob2 是第二顆,一直到 knob8。 fader1 控制器上推桿(滑桿)的位置,從 0 到 1:fader1 是第一支推桿,一直到 fader8。 OAVp5.note 128 個數字的陣列,每個 MIDI 鍵一個:那個鍵被按得多用力,0 到 1,放開就是 0。OAVp5.note[60] 是中央 C。 OAVp5.bend 音高輪現在的位置,從 −1(推到底往下)、經過 0(中間)到 1(推到底往上)。 p5/oav-p5.js 第 258 行
OAVp5.connectHardware()#
函式 oav-p5 OAVp5.connectHardware()
現在就向瀏覽器要 Web MIDI,並回傳一個 promise,告訴你聽不聽得到真的控制器。
它做的就是 ?oav_hardware=auto 在載入時做的事,只是時間由你挑:從一個按鈕、片頭結束之後,或是 hardware 設成 click 或 off 的時候。不管選項怎麼設它都能用。被拒絕之後可以再呼叫一次重試,但瀏覽器通常會記住拒絕、立刻回答,所以要解決得靠網址列左邊的鎖頭。
呼叫之前
要有 Web MIDI 的瀏覽器(Chrome、Edge、Firefox),網頁在 https 或 localhost 底下。有些瀏覽器要先有一次點擊:如果在 setup() 裡太早,就放在 mousePressed() 或按鈕的處理函式裡呼叫。Safari 和 iPad 沒有:promise 會 resolve 成 false,OAVp5.hardware 是 'unavailable'。
回傳 Promise<Boolean> 瀏覽器允許 Web MIDI 之後 resolve 成 true(不管有沒有插設備),被拒絕或這個瀏覽器沒有 Web MIDI 就 resolve 成 false。還在詢問的時候、或是已經答應之後再呼叫,會得到同樣的答案。
範例
window.OAV_P5 = { hardware: 'click' }; // do not ask at load
let answer = 'click the canvas to ask for Web MIDI' ;
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
OAVp5.connectHardware().then((ok) => { answer = ok ? 'Web MIDI allowed' : 'no Web MIDI for this page' ; });
}
function draw() {
background(20);
fill(255);
text(answer, 20, 30);
text('state: ' + OAVp5.hardware, 20, 60);
}
載入的時候什麼都沒有問。點一下畫布:瀏覽器才詢問,狀態從 "off" 經過 "asking" 變成 "connected"、"waiting" 或 "denied",文字也跟著變。
相關 OAVp5.hardware Web MIDI 目前的情形:'off'、'asking'、'connected'、'waiting'、'denied' 或 'unavailable'。 OAVp5.devices 插著的 MIDI 控制器的名稱,照瀏覽器回報的樣子:一個文字陣列,沒有的時候是空的。 hardware oav-p5 什麼時候向瀏覽器要 Web MIDI(真的控制器需要它):auto 是載入時,click 是 🎹 面板打開時,off 是不要。 p5/oav-p5.js 第 274 行
OAVp5.devices
插著的 MIDI 控制器的名稱,照瀏覽器回報的樣子:一個文字陣列,沒有的時候是空的。
用來顯示像「正在彈:Arturia MiniLab3」這樣的一行,或判斷有沒有真的控制器。名稱是作業系統給連接埠取的名字,所以比對時要寬鬆一點(name.toLowerCase().includes('minilab'))。🎹 面板就是用它們來跟著插上去的那一台控制器。
型別 Array<String>每個連上的輸入埠一項。每次有設備插上或拔掉都會換成新的。在 Web MIDI 被允許之前是空的(OAVp5.hardware ),而且永遠不會列出螢幕上的控制器或手機。
注意 midi.devices 是同一個陣列,是整個換掉,不是就地修改:變動之後要重新讀一次。範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text(OAVp5.devices.length ? 'playing: ' + OAVp5.devices.join(', ' ) : 'no MIDI controller plugged in' , 20, 30);
}
寫出找到的控制器,或說沒有。頁面開著的時候插上一台控制器,這一行就會改變。
相關 OAVp5.hardware Web MIDI 目前的情形:'off'、'asking'、'connected'、'waiting'、'denied' 或 'unavailable'。 OAVp5.connectHardware() 現在就向瀏覽器要 Web MIDI,並回傳一個 promise,告訴你聽不聽得到真的控制器。 profile 螢幕上的 🎹 顯示哪一種控制器,用設定檔 id 指定,例如 akai-lpd8。不指定的話,它跟著插上去的那一個。 hardware oav-p5 什麼時候向瀏覽器要 Web MIDI(真的控制器需要它):auto 是載入時,click 是 🎹 面板打開時,off 是不要。 p5/oav-p5.js 第 598 行
OAVp5.hardware
Web MIDI 目前的情形:'off'、'asking'、'connected'、'waiting'、'denied' 或 'unavailable'。
'off':還沒有去要過 Web MIDI(見 hardware )。'asking':等瀏覽器的權限回答。'connected':允許了,而且至少插著一台設備(OAVp5.devices 列出它們)。'waiting':允許了,但還沒有設備:插上一台就會自己變成 'connected'。'denied':權限被拒絕。'unavailable':這個瀏覽器沒有 Web MIDI(Safari、iPad),或是打不開它。
它只管真的硬體。螢幕上的控制器和手機在每一種狀態下都能用。
型別 String
範圍 'off' | 'asking' | 'connected' | 'waiting' | 'denied' | 'unavailable'瀏覽器回答的時候,以及每次有 MIDI 設備插上或拔掉的時候會變。
注意 在 🩺 體檢裡,waiting 寫成「沒偵測到裝置」。 範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(OAVp5.hardware === 'connected' ? color(20, 60, 30) : 20);
fill(255);
text('Web MIDI: ' + OAVp5.hardware, 20, 30);
text('devices: ' + (OAVp5.devices.join(', ' ) || 'none' ), 20, 60);
}
顯示 Web MIDI 的狀態和找到的設備。控制器一連上,背景就變綠。插一台上去看它改變。
p5/oav-p5.js 第 260 行
OAVp5.held
現在按著的鍵的 MIDI 號碼清單,由低到高。
OAVp5.note 的精簡版:只列按著的鍵,所以 OAVp5.held.length 是幾個鍵,OAVp5.held[0] 是最低的那個,OAVp5.held.map((n) => noteName(n)) 把和弦寫出來。noteIsPressed 就是 OAVp5.held.length > 0。
型別 Array<Number>沒有按著任何鍵的時候是空的。note-on 進來時加入,note-off 時移除,清單一直照由低到高排好。
注意 midi.held 是同一個陣列。只讀,不要改它。它每次都是同一個陣列物件,想把某一刻存下來的話要複製一份([...OAVp5.held])。範例
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(40);
}
function mousePressed() { OAVp5.play(60 + floor(random(12)), 0.8); } // no keys? click
function draw() {
background(20);
fill(255);
text(OAVp5.held.map((n) => noteName(n)).join(' ' ) || 'play a chord' , width / 2, height / 2);
}
把按著的鍵由低到高寫出來。點一下畫布就多按著一個鍵。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
p5/oav-p5.js 第 258 行
OAVp5.input(bytes, [source], [device])
用位元組手動送一則 MIDI 訊息給 oav-p5:整個草圖的反應就像是控制器送出來的。
每一種輸入都從這扇門進來,你也可以自己來敲:草圖可以在沒有控制器的時候測試自己、用計時器自己推動自己,或接收來自 WebSocket 或其他函式庫的訊息。草圖分不出差別:knob1 、notePressed() 、OAVp5.note 、開著 startSound() 時的聲音,還有鼓機的 cue 都會有反應。要送音符的話,OAVp5.play() 是比較親切的方式。
參數 名稱 型別 說明 bytesArray<Number>一則訊息,[status, data1, data2]:[0x90, 60, 100] 是通道 1 上鍵 60、力道 100 的 note-on,[0xB0, 74, 127] 是控制變化,[0xE0, 0, 64] 是在中間的音高輪。status 位元組決定種類(上半)和通道(下半)。只讀前三個數字,資料位元組會被限制在 0 到 127。系統訊息不是給它用的:時鐘位元組(0xF8、0xFA、0xFB、0xFC、0xF2)會交給 OAVp5.clock ,從 0xF0 起的其他東西都忽略。 sourceString一個說明來源的標籤,會出現在 OAVp5.last.source,也是 midiMessage() 的第二個參數。任何文字都行。'hardware' 和 'virtual-out' 會被當作真的那兩種來源,不會再轉給草圖自己的 Web MIDI 程式。 可省略 預設 'screen' deviceString設定檔 id,像 'akai-lpd8':這則訊息裡 knob1、pad1 … 的編號照那一台控制器來對應。不寫的話,就是螢幕面板上的那一台控制器。 可省略
注意 midi.input(…) 是同一個函式。它沒有回傳值。來源不是 'screen' 的訊息,會在螢幕控制器打開的時候也讓它的琴鍵亮起來。範例
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(24);
}
function mousePressed() {
OAVp5.input([0x90, 60 + floor(random(12)), 100]); // a note-on, as a key would send it
}
function notePressed(note, velocity) {
background(20);
fill(255);
text(noteName(note) + ' (sent by hand)' , width / 2, height / 2);
}
點一下畫布:草圖聽到一個 note-on,就跟你彈了一個鍵一樣,並說出它的名字。
function setup() {
createCanvas(400, 300);
}
function mouseDragged() {
OAVp5.input([0xB0, 74, constrain(round(mouseX / width * 127), 0, 127)]); // CC 74 is knob 1 on a MiniLab 3
}
function draw() {
background(20);
circle(width / 2, height / 2, 40 + knob1 * 300);
}
在畫布上拖動滑鼠:它送出 CC 74,也就是旋鈕 1,圓跟著動。
function setup() {
createCanvas(400, 300);
OAVp5.input([0xB0, 70, 127], 'test' , 'akai-lpd8' ); // CC 70 is knob 1 on an LPD8
}
function draw() {
background(20);
fill(255);
text('knob1 = ' + knob1 + ' (from the LPD8 numbering)' , 20, 30);
text('last: ' + OAVp5.last.control + ' from ' + OAVp5.last.source, 20, 60);
}
第三個參數說明這則訊息來自哪一台控制器,所以不管面板顯示哪個設定檔,這裡的 CC 70 都是旋鈕 1。
相關 OAVp5.play() 像控制器那樣彈一個鍵:midi.play(60, 0.8) 按下中央 C,midi.play(60, 0) 放開。 OAVp5.last 最近收到的音符、控制變化或音高輪訊息:它的種類、號碼、值、通道、來自哪裡、是哪個控制。在任何訊息之前是 null。 OAVp5.on() 用名字監聽一個 MIDI 事件,不必定義全域函式,並回傳一個停止監聽的函式。 midiMessage() 你來定義這個函式。每收到一則 MIDI 訊息,oav-p5 就呼叫它,帶著原始的位元組和它從哪裡來。 noteOn() 你來定義這個函式。每收到一個 MIDI 的 note-on,oav-p5 就呼叫它,帶著是哪個鍵、多用力、哪個通道。 controlChange() 你來定義這個函式。每收到一個 MIDI 控制變化(旋鈕、推桿、調變輪、延音踏板),oav-p5 就呼叫它,不管控制器認不認得。 p5/oav-p5.js 第 273 行
OAVp5.isDown(n)
鍵 n(MIDI 號碼,或像 'C4' 這樣的音名)按著的時候是 true:跟 noteIsDown(n) 一樣。
它是用 OAVp5 這個名字呼叫的 noteIsDown() ,給不能用一般名稱的草圖:OAVp5.isDown('C4')。不是音符的名稱會讓草圖停下來,並用卡片說明怎麼寫。
參數 名稱 型別 說明 nNumber | String哪個鍵:MIDI 號碼(60)或音名('C4'、'c#4'、'Db3')。
回傳 Boolean 那個鍵現在按著就是 true。
範例
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(32);
}
function mousePressed() { for (const n of ['C4' , 'E4' , 'G4' ]) OAVp5.play(n, 0.8); } // no keys? click for a chord
function mouseReleased() { for (const n of ['C4' , 'E4' , 'G4' ]) OAVp5.play(n, 0); }
function draw() {
const chord = OAVp5.isDown('C4' ) && OAVp5.isDown('E4' ) && OAVp5.isDown('G4' );
background(chord ? color(60, 100, 40) : 20);
fill(255);
text(chord ? 'C major' : 'hold C4 + E4 + G4' , width / 2, height / 2);
}
C、E、G 同時按著的時候,背景變綠。按住滑鼠就會彈出這個和弦。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
p5/oav-p5.js 第 271 行
OAVp5.last
最近收到的音符、控制變化或音高輪訊息:它的種類、號碼、值、通道、來自哪裡、是哪個控制。在任何訊息之前是 null。
它是每收到一則訊息就整個換掉的單一物件,所以只讀,不要留著。草圖用它知道訊息從哪裡來(OAVp5.last.source)或哪顆旋鈕動了(OAVp5.last.control),草圖自己有 note 或 velocity 而保留了一般名稱的時候,也是改讀它:OAVp5.last.note 和 OAVp5.last.value。
它只留音符、控制變化和音高輪。觸後和音色切換只會到 midiMessage() ,這裡不存。
型別 Object | null{ type, note | cc, value, channel, source, control }。type 是 'noteOn'、'noteOff'、'controlChange' 或 'pitchBend'(力道 0 的 note-on 算 'noteOff')。音符有 note,控制變化有 cc。value 是力道 0 到 1(note-off 是 0)、控制的位置 0 到 1,或輪子的 −1 到 1。channel 是 1 到 16。source 是 'hardware'、'screen'、'phone'、'virtual-out'、'link',或你在 OAVp5.input() 給的標籤。control 在控制器的設定檔認得它的時候是 'knob1'、'fader2'、'pad3' …,否則是 null。
注意 midi.last 是同一個物件。時鐘訊息(tick、start、stop)不在這裡,請看 OAVp5.clock 。範例
function setup() {
createCanvas(400, 300);
textSize(16);
}
function mousePressed() { OAVp5.input([0x90, 60 + floor(random(12)), 100], 'mouse' ); } // no controller? click
function draw() {
background(20);
fill(255);
text(OAVp5.last ? JSON.stringify(OAVp5.last, null , 1) : 'play something' , 20, 30);
}
把最後一則訊息印成物件。點一下畫布就從滑鼠送一則,並看 source。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
相關 midiMessage() 你來定義這個函式。每收到一則 MIDI 訊息,oav-p5 就呼叫它,帶著原始的位元組和它從哪裡來。 noteOn() 你來定義這個函式。每收到一個 MIDI 的 note-on,oav-p5 就呼叫它,帶著是哪個鍵、多用力、哪個通道。 note 最後被按下的那個鍵的 MIDI 號碼(0 到 127)。 velocity 最後那個鍵被按得多用力,從 0 到 1。 OAVp5.input() 用位元組手動送一則 MIDI 訊息給 oav-p5:整個草圖的反應就像是控制器送出來的。 p5/oav-p5.js 第 524 行
OAVp5.note
128 個數字的陣列,每個 MIDI 鍵一個:那個鍵被按得多用力,0 到 1,放開就是 0。OAVp5.note[60] 是中央 C。
在 draw() 裡一次讀每個鍵:OAVp5.note[60] 讀一個鍵,或用迴圈跑過一個範圍來畫鋼琴捲簾。它是 noteIsDown() 的陣列版,而且裡面有力道。它不是變數 note ,那個只是最後按下的鍵的號碼。OAVp5.held 是同樣的資訊,做成只列出按著的鍵的短清單。
型別 Array<Number>
範圍 128 numbers, 0..1 each索引是 MIDI 音符號碼,0 到 127。按著的鍵放的是它 note-on 的力道除以 127,放開(或從沒按過)的鍵是 0。訊息進來的時候才會變,不是每個畫面一次。
注意 midi.note 是同一個陣列。力道是 0 的 note-on 會讓那個鍵變成 0,就像 note-off。範例
function setup() {
createCanvas(400, 300);
noStroke();
}
function draw() {
background(20);
for (let n = 36; n < 96; n++) { // five octaves, C2 to B6
const v = OAVp5.note[n];
fill(v > 0 ? color(255, 211, 107) : 60);
rect(20 + (n - 36) * 6, height - 40 - v * 200, 5, 40 + v * 200);
}
}
每個鍵一根長條:按著的時候升起,按得越用力越高。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const v = lag(OAVp5.note[60], 0.05, 0.4); // middle C, softened
circle(width / 2, height / 2, 40 + v * 260);
}
中央 C(60)按得多用力,圓就脹得多大,放開之後慢慢縮回去。
相關 OAVp5.held 現在按著的鍵的 MIDI 號碼清單,由低到高。 OAVp5.isDown() 鍵 n(MIDI 號碼,或像 'C4' 這樣的音名)按著的時候是 true:跟 noteIsDown(n) 一樣。 noteIsDown() 鍵 n(MIDI 號碼,或像 'C4' 這樣的音名)按著的時候是 true。 note 最後被按下的那個鍵的 MIDI 號碼(0 到 127)。 velocity 最後那個鍵被按得多用力,從 0 到 1。 OAVp5.cc 128 個數字的陣列,每個 MIDI 控制器號碼一個:每個控制最後停在哪裡,0 到 1。OAVp5.cc[1] 是調變輪,OAVp5.cc[64] 是延音踏板。 p5/oav-p5.js 第 258 行
OAVp5.on(name, fn)
用名字監聽一個 MIDI 事件,不必定義全域函式,並回傳一個停止監聽的函式。
事件函式(notePressed() 、knobMoved() 、noteOn() …)是用名字定義一次的。OAVp5.on('notePressed', fn) 是另一種方式:想要幾個監聽者都行,草圖在跑的時候可以加也可以拿掉,也不占用任何全域名稱。函式庫、類別或 Hydra 草圖就是這樣監聽的,要聽一陣子再停下來也用它。
對打擊墊的規則來說,監聽者就跟定義過的函式一樣:只要有東西在聽 'padPressed',打擊墊就不再呼叫 notePressed()。fn 裡面出錯會顯示在 console 和卡片上,其他的監聽者照樣執行。麥克風的事件要用 OAVp5.mic.on() 。
參數 名稱 型別 說明 nameString事件名稱:'notePressed'、'noteReleased'、'knobMoved'、'faderMoved'、'padPressed'、'padReleased'、'noteOn'、'noteOff'、'controlChange'、'pitchBend' 或 'midiMessage'。'noteon' 和 'noteoff' 大小寫隨便。其他的名稱會丟出一個錯誤,並列出可用的名稱。 fnFunction呼叫它的時候帶的參數跟同名函式會拿到的一樣(notePressed(note, velocity)、knobMoved(n, value) …)。
回傳 Function 一個把 fn 取消的函式:const off = OAVp5.on(…); off();。
範例
let heard = 0;
function setup() {
createCanvas(400, 300);
OAVp5.on('noteOn' , (note, velocity) => { heard++; });
}
function mousePressed() { OAVp5.play(60 + floor(random(12)), 0.8); } // no keys? click
function draw() {
background(20);
fill(255);
text('notes heard: ' + heard, 20, 30);
}
用監聽者而不是全域函式來數音符。點一下畫布就彈一個。接上控制器,或按右下角的 🎹 用螢幕上的控制器。
let heard = 0;
let off;
function setup() {
createCanvas(400, 300);
off = OAVp5.on('noteOn' , () => {
heard++;
if (heard === 5) off(); // stop listening after five notes
});
}
function mousePressed() { OAVp5.play(60 + floor(random(12)), 0.8); }
function draw() {
background(20);
fill(255);
text('notes heard: ' + heard + (heard >= 5 ? ' (stopped listening)' : '' ), 20, 30);
}
數字數到五就停了,就算你繼續彈也一樣:監聽者把自己取消掉了。
p5/oav-p5.js 第 262 行
OAVp5.panel
把 🎹 面板當成物件:用 open()、close()、toggle() 從程式裡顯示和收起它。
🎹 按鈕後面的面板裝著螢幕上的控制器,和 🎤、🔊、🥁、⏺、📷、📱、🩺 的按鈕。OAVp5.panel.open() 、close()、toggle() 做的是按 🎹 做的事,只是從你的程式裡:按一個鍵就打開它,或是工作坊一開始就打開(?oav_panel=open 在載入時做同樣的事)。
注意 設了 ?oav_panel=off 就沒有 🎹,這幾個也什麼都不做。 範例
function setup() {
createCanvas(400, 300);
}
function keyPressed() {
if (key === 'p' ) OAVp5.panel.toggle();
}
function draw() {
background(20);
fill(255);
text('press p to show or hide the 🎹 panel' , 20, 30);
}
按 p:🎹 面板打開,再按一次就關起來。
相關 OAVp5.panel.open() 打開 🎹 面板(open())、收起它(close()),或是切換(toggle())。 panel 🎹 面板一開始是收起來、打開,還是根本沒有:closed、open 或 off。 compact 把 🎹 面板顯示成角落的一個小方塊,高度是視窗的三分之一,而不是整條橫幅:on 或 off。 OAVp5.phone() 顯示 📱 卡片:一個 QR code,讓手機打開遙控頁面,手機上的 MIDI 分頁就能彈這個草圖。回傳房間名稱。 p5/oav-p5.js 第 275 行
OAVp5.panel.open()#
函式 oav-p5 OAVp5.panel.open()同一條也包含: OAVp5.panel.close OAVp5.panel.toggle
打開 🎹 面板(open())、收起它(close()),或是切換(toggle())。
就跟按 🎹 按鈕一樣。面板第一次打開的時候,oav-p5 才載入螢幕控制器(連線慢的話要幾秒,載入的時候面板會這麼說),而且 hardware 是 click 的話,就在這時去要 Web MIDI。已經打開的時候再呼叫 OAVp5.panel.open() 不會有新的動作。
回傳 Promise<Element | null> 一個 promise。open()(以及 toggle() 打開時)在面板裡的螢幕控制器載好之後 resolve 成面板的元素,面板本身一開始就會顯示,不用等。close()(以及 toggle() 關起來時)resolve 成 null,面板選項是 off 的時候全部都是 null。
注意 這三個放在一起說明:OAVp5.panel.close 和 OAVp5.panel.toggle 也都不帶參數。midi.panel.open() 是同一個函式。 範例
function setup() {
createCanvas(400, 300);
}
function keyPressed() {
if (key === 'o' ) OAVp5.panel.open();
if (key === 'c' ) OAVp5.panel.close();
}
function draw() {
background(20);
fill(255);
text('o: open the 🎹 panel, c: close it' , 20, 30);
}
按 o 打開 🎹 面板,按 c 收起來。
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
OAVp5.panel.open().then((panel) => console.log('the panel is' , panel ? 'open and loaded' : 'not available' ));
}
function draw() {
background(20);
fill(255);
text('click the canvas to open the panel' , 20, 30);
}
點一下畫布:面板馬上打開,裡面的控制器載好的時候,console 會說一聲。
相關 OAVp5.panel 把 🎹 面板當成物件:用 open()、close()、toggle() 從程式裡顯示和收起它。 panel 🎹 面板一開始是收起來、打開,還是根本沒有:closed、open 或 off。 OAVp5.phone() 顯示 📱 卡片:一個 QR code,讓手機打開遙控頁面,手機上的 MIDI 分頁就能彈這個草圖。回傳房間名稱。 OAVp5.check() 執行 🩺 體檢:版本、頁面、瀏覽器、麥克風、MIDI、聲音、鼓機、錄影、接線和提示,做成一張可以截圖的卡片,也回傳成物件。 p5/oav-p5.js 第 275 行
OAVp5.phone()
顯示 📱 卡片:一個 QR code,讓手機打開遙控頁面,手機上的 MIDI 分頁就能彈這個草圖。回傳房間名稱。
跟面板裡的 📱 按鈕一樣。卡片上有 QR code、房間,和下面的連結。在手機上打開連結,那支手機就有一個螢幕控制器,它的 MIDI 分頁透過中繼來彈你的草圖,當作多一個輸入(OAVp5.last.source 是 'phone')。再呼叫一次就把卡片收起來。第一次呼叫會連到 relay ,並從 jsDelivr 載入一個很小的 QR code 函式庫。
呼叫之前
要有網路,連到中繼(沒另外設的話是 wss://remote.cheyuwu.com/relay),顯示 QR code 的圖還要連到 jsDelivr。沒有那個函式庫的話,卡片就把連結用文字顯示。
回傳 String 手機要加入的房間名稱:room 選項、link 的房間,或是為這個瀏覽器隨機取、並存在裡面的名字(p5-…)。
範例
js 複製
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
const room = OAVp5.phone(); // the QR code card opens
console.log('phones join room' , room);
}
function notePressed(note) {
background(random(255), random(255), random(255)); // a phone key changes the colour
}
function draw() {
fill(255);
text('click the canvas, scan the QR code, open the MIDI tab' , 20, 30);
}
static 點一下畫布,用手機掃 QR code:手機 MIDI 分頁上的鍵會改變這個草圖的背景顏色。
待確認
這一頁沒有試過用手機透過中繼彈草圖:無頭瀏覽器的檢查連不到中繼,所以範例沒有跑。行為依程式碼和專案的 README 所述。
相關 room 手機要加入哪個房間才能彈這個頁面:預設是每個瀏覽器自己亂數取的一個。 relay 手機和 link 房間用來傳話的中繼的位址:一個 WebSocket 網址。 remote 📱 的 QR code 在手機上打開的那個遙控頁面的位址。 OAVp5.panel.open() 打開 🎹 面板(open())、收起它(close()),或是切換(toggle())。 p5/oav-p5.js 第 276 行
OAVp5.play(note, [velocity])
像控制器那樣彈一個鍵:midi.play(60, 0.8) 按下中央 C,midi.play(60, 0) 放開。
它送出的 MIDI 位元組跟鍵盤送出的一樣,midi.last.source 會是 'screen',所以所有東西都會有反應:notePressed() 、noteIsDown() 、note ,還有開著 startSound() 時的聲音。midi 和 OAVp5 是同一個物件,所以 OAVp5.play 和 midi.play 是同一個函式。
參數 名稱 型別 說明 noteNumber | String哪個鍵:MIDI 號碼或音名('C#4')。 velocityNumber多用力,0..1。0 表示放開。不給就是最大力道。 可省略 預設 1
範例
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(32);
}
function mousePressed() { midi.play('C4' , 0.8); }
function mouseReleased() { midi.play('C4' , 0); }
function draw() {
background(noteIsDown('C4' ) ? 90 : 20);
fill(255);
text('hold the mouse: C4 is ' + (noteIsDown('C4' ) ? 'down' : 'up' ), width / 2, height / 2);
}
用滑鼠彈中央 C:草圖聽到的跟按鍵一模一樣。
p5/oav-p5.js 第 269 行
OAVp5.wiring([dev])
列出現在哪一顆旋鈕、推桿或墊子在推動哪一個 OpenProcessing OPC 變數:[{ control: 'knob1', name: 'radius' }, …]。
搭配 OP Configurator 時,OPC.slider('radius', 10) 宣告一個變數,oav-p5 會依序把控制器接到 OPC 的變數:旋鈕 1 接第一個滑桿,旋鈕 2 接第二個,推桿接在旋鈕後面,打擊墊 1 接第一個開關或按鈕。這份清單就是誰推動什麼,面板在控制器下面印的、console 說一次的也是同一份。旋鈕推動錯東西的時候就看它。
參數 名稱 型別 說明 devString用誰的編號:'screen'(面板上顯示的那台控制器)或設定檔 id,像 'akai-lpd8'。它會影響推桿,因為推桿的編號接在旋鈕後面。 可省略 預設 'screen'
回傳 Array<Object> 每個接上線的變數一筆 { control, name }:control 是 'knob1'、'fader2'、'pad1' …,name 是 OPC 的變數。草圖自己有讀的控制還會多一個 sketch: true,對應的滑桿就不再跟著它。頁面上沒有 OPC,或 opc 是 off 的時候是空陣列。
範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
const wired = OAVp5.wiring();
text(wired.length ? wired.map((w) => w.control + ' -> ' + w.name).join('\n' ) : 'nothing wired (no OPC here)' , 20, 30);
}
一行印一組控制和它推動的 OPC 變數。在 OpenProcessing 以外沒有 OPC,所以會說沒有接上任何東西。
待確認
沒有用 OpenProcessing 真正的 OPC 函式庫試過:是拿一個有同樣 options 結構({ type: 'slider', name, min, max })的替身物件檢查的。在 OpenProcessing 以外,結果是空陣列。
相關 opc oav-p5 要不要把控制器接到 OP Configurator(OpenProcessing 的滑桿和按鈕)的變數上:on 或 off。 knob1 控制器上旋鈕的位置,從 0 到 1:knob1 是第一顆旋鈕,knob2 是第二顆,一直到 knob8。 knob() 回傳第 n 顆旋鈕的位置,0 到 1:跟 knob1 到 knob8 一樣,但 n 可以是任何數字。 pad1 打擊墊被按得多用力,從 0 到 1:pad1 是第一個墊子,一直到 pad16。放開就是 0。 OAVp5.check() 執行 🩺 體檢:版本、頁面、瀏覽器、麥克風、MIDI、聲音、鼓機、錄影、接線和提示,做成一張可以截圖的卡片,也回傳成物件。 p5/oav-p5.js 第 278 行
麥克風與音訊分析#
多大聲(micLevel)、低中高音、拍子;底層是 @openav/audio。 26
oav-p5
bass同一條也包含: mid treble
麥克風聽到的聲音裡,低音(bass)、中音(mid)、高音(treble)各佔多少,各自從 0 到 1。
用法跟 micLevel 一樣,每個頻段一個數字:fill(200 + bass * 160, 70, 100) 讓顏色跟著低音走。三個名稱共用這一條,因為它們的行為完全相同,只有頻率範圍不同。
跟 micLevel 不一樣,它們不是用分貝量的:每一個是該頻段所有頻率的平均強度,乘上 micSensitivity() 之後最多算到 1。如果某個頻段在你的房間裡很微弱,數字會一直貼近 0。這時調高靈敏度,比自己乘一個很大的數字好。
型別 Number
範圍 0..1bass 是 20–250 Hz 的能量,mid 是 250–2000 Hz,treble 是 2000–8000 Hz。麥克風開著時每個畫面更新一次,其他時候是 0。
要先做什麼才有值
第一次讀其中任何一個,就會跟 micLevel 一樣去要麥克風。在你允許之前都是 0。
注意 如果草圖本來就有自己的 bass、mid 或 treble(變數、函式、函式庫都算),oav-p5 就不動那個名稱,也不會為它去開麥克風。之後自己指定值的話,那個名稱就變回一般變數。 範例
function setup() {
createCanvas(400, 300);
colorMode(HSB, 360, 100, 100);
}
function draw() {
background(230, 40, 10);
fill(200 + bass * 160, 70, 100); // low sounds push the colour toward magenta
circle(width / 2, height / 2, 80 + bass * 220);
}
一個隨低音變大、變色的圓:敲桌子或放一段有大鼓的音樂。 會要求麥克風
function setup() {
createCanvas(400, 300);
noStroke();
}
function draw() {
background(20);
const bands = [bass, mid, treble];
for (let i = 0; i < 3; i++) {
fill(255, 80 + i * 70, 120);
rect(60 + i * 110, height - 20, 80, -20 - bands[i] * 240);
}
}
三根長條,依序是低、中、高音。吹口哨看右邊那根,哼低音看左邊那根。 會要求麥克風
待確認
說話和音樂的典型數值要看麥克風和房間。在量過之前,這裡不給典型數字。
相關 micLevel 麥克風聽到的聲音有多大聲,從 0(安靜的房間)到 1(大喊)。 beat 麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。 micSensitivity() 調整麥克風的靈敏度:micSensitivity(2) 是兩倍靈敏,micSensitivity() 會告訴你現在是多少。 kickDetected() 你來定義這幾個函式。麥克風(或鼓機)聽到大鼓、小鼓或鈸的時候,oav-p5 會呼叫 kickDetected(level)、snareDetected(level)、hatDetected(level)。 p5/oav-p5.js 第 1017 行
beat
麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。
它的用法像按著滑鼠鍵時的 mouseIsPressed,只是「按下」只持續一個畫面:if (beat) flash = 1; 就能對音樂做出反應。一個拍子是音量比房間一直以來的聲音突然跳高,兩個拍子之間至少隔 100 毫秒。兩個畫面之間發生的兩個拍子只算一次。
鼓機在播的時候,每一下大鼓和小鼓也會讓 beat 在那個畫面是 true,所以為麥克風寫的草圖不用改就能換成鼓機。
型別 Boolean拍子之後的第一個 draw() 是 true,到下一個拍子之前都是 false。同一個畫面裡讀兩次,答案一樣。
範例
let flash = 0;
function setup() {
createCanvas(400, 300);
}
function draw() {
if (beat) flash = 1; // true for exactly one frame per beat
background(20 + flash * 200);
flash *= 0.9; // fade back down
}
每個拍子畫面閃一下再慢慢暗下去。拍手或敲桌子試試。 會要求麥克風
let count = 0;
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(96);
}
function draw() {
if (beat) count++;
background(20);
fill(255);
text(count, width / 2, height / 2);
}
每個拍子加一的計數器,同一個拍子不會算兩次。 會要求麥克風
相關 beatDetected() 你來定義這個函式。麥克風(或鼓機)每聽到一個拍子,oav-p5 就呼叫它一次。 micLevel 麥克風聽到的聲音有多大聲,從 0(安靜的房間)到 1(大喊)。 kickDetected() 你來定義這幾個函式。麥克風(或鼓機)聽到大鼓、小鼓或鈸的時候,oav-p5 會呼叫 kickDetected(level)、snareDetected(level)、hatDetected(level)。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 p5/oav-p5.js 第 1020 行
function beatDetected(level) {}
你來定義這個函式。麥克風(或鼓機)每聽到一個拍子,oav-p5 就呼叫它一次。
用宣告 keyPressed() 的方式宣告它:
js
function beatDetected(level) {
// 閃一下、生出東西、跳到下一個場景
}每個拍子呼叫一次,在拍子落下的當下,不是每個畫面一次。如果你比較想每個畫面自己問,改用 beat 。
要先做什麼才有值
草圖只要定義了 beatDetected() 而沒有讀任何麥克風的值,第一次點擊或按鍵時就會啟動麥克風。鼓機開著的時候,鼓機的大鼓和小鼓也會呼叫同一個函式,而且不會為它去開麥克風。OAVp5.mic.on('beat', fn) 聽到的是同樣的拍子,不用定義全域函式,也會回傳一個停止監聽的函式,但只有監聽並不會啟動麥克風:還要讀 micLevel 或呼叫 startMic() 。
參數 名稱 型別 說明 levelNumber那一刻有多大聲,0..1(就是 micLevel 的值)。
注意 你的函式如果出錯,草圖會繼續跑:錯誤會出現在 console,也會在 🎹 旁邊的卡片上顯示檔案和行號。 範例
let flash = 0;
function setup() {
createCanvas(400, 300);
}
function beatDetected(level) {
flash = 0.4 + level * 0.6; // louder beat, brighter flash
}
function draw() {
background(20 + flash * 200);
flash *= 0.9;
}
每個拍子閃一下,拍子越大聲閃得越亮。 會要求麥克風
相關 beat 麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。 kickDetected() 你來定義這幾個函式。麥克風(或鼓機)聽到大鼓、小鼓或鈸的時候,oav-p5 會呼叫 kickDetected(level)、snareDetected(level)、hatDetected(level)。 micLevel 麥克風聽到的聲音有多大聲,從 0(安靜的房間)到 1(大喊)。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 p5/oav-p5.js 第 865 行
function kickDetected(level) {}同一條也包含: snareDetected hatDetected
你來定義這幾個函式。麥克風(或鼓機)聽到大鼓、小鼓或鈸的時候,oav-p5 會呼叫 kickDetected(level)、snareDetected(level)、hatDetected(level)。
麥克風把聽到的聲音分成三個像鼓的頻段,哪個頻段突然跳高,就呼叫對應的函式:大鼓是 20–120 Hz,小鼓是 150–800 Hz,鈸是 6–14 kHz。裡面沒有機器學習,只是某個頻段出現瞬間的衝擊,所以低沉的人聲也可能觸發大鼓。
鼓機在播的時候,它的每一下也會呼叫同樣的函式:大鼓呼叫 kick,小鼓、拍手或 rim 呼叫 snare,閉合、開放或腳踏的鈸呼叫 hat。所以為麥克風寫的草圖,不用改就能搭配 startDrums() 。
鼓機在打的時候,只有鼓機的每一下會呼叫這幾個函式(oav-p5 0.8.1 起)。這時候如果麥克風也開著,它聽到的多半是喇叭放出來的鼓聲,同一下會被叫兩次,大鼓也可能被聽成小鼓。micLevel 、bass 、mid、treble 照樣跟著麥克風。鼓機停下來,麥克風的打擊就會回來。
要先做什麼才有值
鼓機啟動之後,只定義其中一個函式並不會去開麥克風。要讀 micLevel (或呼叫 startMic() )才會。沒有鼓機的時候,草圖只要定義了其中任何一個、又沒有讀任何值,第一次點擊或按鍵時會啟動麥克風。
參數 名稱 型別 說明 levelNumber這一下有多強,0..1(鼓機的話就是力道)。
範例
let kick = 0, snare = 0;
function setup() {
createCanvas(400, 300);
noStroke();
}
function kickDetected(level) { kick = level; }
function snareDetected(level) { snare = level; }
function draw() {
background(20);
fill(255, 90, 31);
circle(120, 150, 40 + kick * 160);
fill(120, 190, 255);
circle(280, 150, 40 + snare * 160);
kick *= 0.9;
snare *= 0.9;
}
兩個圓,橘色對應大鼓、藍色對應小鼓,各自在自己的聲音出現時脹大再縮回去。 會要求麥克風
相關 beatDetected() 你來定義這個函式。麥克風(或鼓機)每聽到一個拍子,oav-p5 就呼叫它一次。 beat 麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。 bass 麥克風聽到的聲音裡,低音(bass)、中音(mid)、高音(treble)各佔多少,各自從 0 到 1。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 p5/oav-p5.js 第 865 行
kickIsDetected同一條也包含: snareIsDetected hatIsDetected
聽到一下大鼓(小鼓、鈸)之後的那一格是 true,鼓機或麥克風都算。就像打擊版的 mouseIsPressed。
跟呼叫 kickDetected() 、snareDetected()、hatDetected() 的是同一批打擊,只是改成在 draw() 裡讀的值,不用寫函式。交給 lag() 就變成一個數字:打到時跳到 1,然後慢慢退回去。lag(kickIsDetected, 0, 0.3) 在大鼓那一下是 1,大約 0.3 秒後回到 0 附近,可以直接拿來控制透明度或大小。lag() 把 true 當成 1。
鼓機在打的時候只算鼓機的。讀這幾個名字本身不會去要麥克風。沒有鼓機的時候,它就跟定義 kickDetected() 一樣,算是想要麥克風的打擊,所以第一次點畫面會開麥克風。
型別 Boolean每一下只在一個 p5 畫面裡是 true,那一格讀幾次都一樣。其他時候是 false。oav-p5 0.8.2 起。
範例
function setup() {
createCanvas(400, 300);
startDrums('bd*4, ~ sd ~ sd' , 110);
noStroke();
}
function draw() {
background(20);
fill(255, 90, 31, 255 * lag(kickIsDetected, 0, 0.3)); // a kick lights it, then it fades
circle(130, 200, 140);
fill(120, 190, 255, 255 * lag(snareIsDetected, 0, 0.3));
circle(270, 200, 140);
}
橘色的圓每一下大鼓亮起來再淡掉,藍色的圓跟著小鼓。
相關 kickDetected() 你來定義這幾個函式。麥克風(或鼓機)聽到大鼓、小鼓或鈸的時候,oav-p5 會呼叫 kickDetected(level)、snareDetected(level)、hatDetected(level)。 beat 麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。 lastKickTs 上一下大鼓(小鼓、鈸、拍子)是什麼時候來的,用 millis() 的時鐘:millis() - lastKickTs 就是幾毫秒前。 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 p5/oav-p5.js 第 1044 行
lastKickFrameCount#
變數 oav-p5 lastKickFrameCount同一條也包含: lastSnareFrameCount lastHatFrameCount lastBeatFrameCount
看到上一下大鼓(小鼓、鈸、拍子)的那一格的 frameCount:frameCount - lastKickFrameCount 就是幾格以前。
lastKickTs 的「數格子」版本。在 kickIsDetected 是 true 的那一格,frameCount - lastKickFrameCount 是 0,接下來是 1、2、3……,所以可以讓一個形狀在每一下之後固定留幾格。想要不管幀率都留固定秒數,就用 Ts 那一組。
型別 Number畫面的編號,跟 frameCount 同一套數法。第一下之前是 -Infinity。oav-p5 0.8.3 起。
範例
function setup() {
createCanvas(400, 300);
startDrums('bd*4, ~ sd ~ sd' , 110);
noStroke();
}
function draw() {
background(20);
const k = frameCount - lastKickFrameCount; // frames since the kick
if (k < 10) { // the ring stays for 10 frames after each kick
fill(255, 90, 31);
circle(200, 200, 60 + k * 16);
}
}
每一下大鼓之後,圓會出現 10 格,每一格變大一點。
相關 lastKickTs 上一下大鼓(小鼓、鈸、拍子)是什麼時候來的,用 millis() 的時鐘:millis() - lastKickTs 就是幾毫秒前。 kickIsDetected 聽到一下大鼓(小鼓、鈸)之後的那一格是 true,鼓機或麥克風都算。就像打擊版的 mouseIsPressed。 lastNoteFrameCount 看到上一個鍵按下的那一格的 frameCount:frameCount - lastNoteFrameCount 就是幾格以前。 p5/oav-p5.js 第 333 行
lastKickTs同一條也包含: lastSnareTs lastHatTs lastBeatTs
上一下大鼓(小鼓、鈸、拍子)是什麼時候來的,用 millis() 的時鐘:millis() - lastKickTs 就是幾毫秒前。
每一下會呼叫 kickDetected() 、snareDetected()、hatDetected() 或 beatDetected() 的打擊都會更新它:鼓機的,或沒有鼓機時麥克風的。要算「多久以前」就用它,例如 exp(-(millis() - lastKickTs) / 200) 在大鼓那一下是 1,然後以 0.2 秒的時間常數淡掉。
型別 Number毫秒,跟 millis() 同一個時鐘。第一下之前是 -Infinity,所以那時 millis() - lastKickTs 是 Infinity。oav-p5 0.8.2 起。
範例
function setup() {
createCanvas(400, 300);
startDrums('bd*4, hh*8' , 100);
noStroke();
}
function draw() {
background(20);
const ago = millis() - lastKickTs; // ms since the last kick
fill(255, 90, 31);
circle(200, 200, 40 + 160 * exp(-ago / 200));
fill(255);
text(isFinite(ago) ? round(ago) + ' ms since the kick' : 'no kick yet' , 20, 290);
}
每一下大鼓圓就鼓起來再縮回去,下面那行字寫離上一下大鼓幾毫秒。
相關 kickIsDetected 聽到一下大鼓(小鼓、鈸)之後的那一格是 true,鼓機或麥克風都算。就像打擊版的 mouseIsPressed。 kickDetected() 你來定義這幾個函式。麥克風(或鼓機)聽到大鼓、小鼓或鈸的時候,oav-p5 會呼叫 kickDetected(level)、snareDetected(level)、hatDetected(level)。 lastNoteTs 上一個鍵是什麼時候按下的,用 millis() 的時鐘:millis() - lastNoteTs 就是幾毫秒前。 p5/oav-p5.js 第 330 行
micIsOn
麥克風現在有沒有在運作。
可以用它在聲音進來之前顯示提示:if (!micIsOn) text("請允許麥克風", 20, 30);。讀它不會啟動麥克風(跟 micLevel 不一樣),所以在哪裡檢查都安全。false 的原因看 OAVp5.mic.state(off、asking、on、denied 或 unavailable),失敗的時候還有 OAVp5.mic.error。
型別 Boolean瀏覽器答應了、聲音進來之後是 true。麥克風關著、還在詢問、被擋下或不存在的時候是 false。
範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(micIsOn ? 20 : 90);
fill(255);
textAlign(CENTER, CENTER);
text(micIsOn ? 'listening' : 'allow the microphone' , width / 2, height / 2);
}
麥克風開起來之前是灰色加提示,開了之後變暗。(這裡沒有讀 micLevel,所以不會自己去要麥克風:按 🎹 面板裡的 🎤。)
p5/oav-p5.js 第 326 行
micLevel
麥克風聽到的聲音有多大聲,從 0(安靜的房間)到 1(大喊)。
像讀 mouseX 一樣在 draw() 裡直接讀:circle(width / 2, height / 2, 50 + micLevel * 300),有人說話圓就變大。它是以分貝量的音量,跟耳朵聽起來的感覺一致,而且是從房間本身量起:量表的底部跟著房間的底噪走(一兩秒內安頓下來,每秒最多往上爬 1.5 dB,所以人聲不會變成新的安靜),−10 dBFS(對著筆電大喊)就是 1。
實際上,安靜的房間是 0,小聲說話約 0.4 到 0.6,大喊接近 1。麥克風很小聲或離得很遠的話,用 micSensitivity() 把它調大。
它跟著瀏覽器的動畫畫面更新,不是跟著 draw(),所以用 frameRate(10) 跑的草圖讀到的還是最新的值。分頁被藏起來時數字會停住(瀏覽器在那時會暫停動畫畫面)。
型別 Number
範圍 0..1麥克風開著的時候,每個畫面更新一次。麥克風關著、還在詢問、被擋下或根本沒有的時候,一直是 0。
要先做什麼才有值
不用呼叫任何東西。草圖第一次讀 micLevel(或 bass 、mid、treble、beat )的時候,oav-p5 就會向瀏覽器要麥克風,在你按下允許之前所有的值都是 0。網頁要在 https 或 localhost 底下。如果瀏覽器在你點一下之前不讓聲音出來,第一次點擊或按鍵就會把它啟動。?oav_mic=click 表示只有 🎤 按鈕和 startMic() 能開。?oav_mic=off 表示什麼都開不了。
注意 🎹 面板裡的 🎤 按鈕可以手動開關麥克風,並顯示音量。瀏覽器擋下麥克風的時候,🎹 旁邊會跳出一張卡片說明原因和怎麼處理:點網址列左邊的鎖頭,把麥克風設成允許,再點一下畫布。 在 openprocessing.org 上,草圖的框允許麥克風和 MIDI(2026-10-19 實際檢查過:它的 allow 清單有 microphone 和 midi),而且在那裡第一次讀 micLevel 就會去要麥克風。從 OpenProcessing 複製出來的嵌入碼沒有這個設定:要在那個 <iframe> 加上 allow="microphone"。 它背後的原始數字是 OAVp5.mic.rms (0..1)、OAVp5.mic.db 和 OAVp5.mic.floor(房間本身的底噪,單位 dBFS)。 範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
circle(width / 2, height / 2, 50 + micLevel * 300);
}
瀏覽器詢問的時候按允許,然後說話:圓會跟著你的聲音變大。 會要求麥克風
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const level = lag(micLevel, 0.03, 0.4); // opens fast, closes slowly
fill(255, 120 + level * 135, 60);
circle(width / 2, height / 2, 50 + level * 300);
}
同一個圓,加上 lag():有人開口就立刻張開,之後慢慢收回去,像音量表。 會要求麥克風
function setup() {
createCanvas(400, 300);
micSensitivity(3); // a quiet or far-away microphone
}
function draw() {
background(20);
fill(255);
if (!micIsOn) text('Allow the microphone, then make a sound' , 20, 30);
rect(20, height - 40, micLevel * (width - 40), 20);
}
一條隨音量變長的橫條。micIsOn 告訴你瀏覽器是不是已經答應了。 會要求麥克風
待確認
貼進 openprocessing.org 的草圖會跑、會載入 oav-p5、也會去要麥克風,但那裡的聲音本身沒有試:檢查用的瀏覽器擋掉了麥克風,所以在那個網站上「按允許、然後聽到聲音」這一步還沒確認。
相關 bass 麥克風聽到的聲音裡,低音(bass)、中音(mid)、高音(treble)各佔多少,各自從 0 到 1。 beat 麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。 micIsOn 麥克風現在有沒有在運作。 micSensitivity() 調整麥克風的靈敏度:micSensitivity(2) 是兩倍靈敏,micSensitivity() 會告訴你現在是多少。 startMic() 開啟麥克風,並回傳一個 promise,告訴你有沒有成功。 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 p5/oav-p5.js 第 1016 行
micSensitivity()#
函式 oav-p5 micSensitivity([v])
調整麥克風的靈敏度:micSensitivity(2) 是兩倍靈敏,micSensitivity() 會告訴你現在是多少。
這個數字乘在麥克風聽到的東西上。對 micLevel 來說,2 等於加 6 dB。對 bass 、mid、treble 是乘以 2(最多還是 1)。麥克風很小聲或人離得很遠的時候用它:在 setup() 寫 micSensitivity(3) 大約加 9.5 dB。
參數 名稱 型別 說明 vNumber新的靈敏度,要大於 0(會被限制在 0.05 到 20 之間)。不傳,或傳不大於 0 的值,就什麼都不改。 可省略
回傳 Number 現在生效的靈敏度(沒改過就是 1)。
注意 OAVp5.mic.gain 是同一個旋鈕,用的是 oav-p5 0.3.0 的舊名字。範例
function setup() {
createCanvas(400, 300);
micSensitivity(3); // three times as sensitive
}
function draw() {
background(20);
circle(width / 2, height / 2, 50 + micLevel * 300);
text('sensitivity ' + micSensitivity(), 20, 30); // no argument: just reads it
}
同樣的音量圓,用在需要加強的麥克風上。數字顯示現在的靈敏度。 會要求麥克風
相關 micLevel 麥克風聽到的聲音有多大聲,從 0(安靜的房間)到 1(大喊)。 bass 麥克風聽到的聲音裡,低音(bass)、中音(mid)、高音(treble)各佔多少,各自從 0 到 1。 startMic() 開啟麥克風,並回傳一個 promise,告訴你有沒有成功。 p5/oav-p5.js 第 1047 行
startMic()
開啟麥克風,並回傳一個 promise,告訴你有沒有成功。
通常用不到:只要讀 micLevel 之類的名稱,麥克風就會自己啟動。想自己決定時機(一個按鈕、片頭結束的時候)或想知道結果時才呼叫:startMic().then((ok) => { … })。已經開著、或瀏覽器還在詢問的時候再呼叫,會回傳同一個結果。
呼叫之前
瀏覽器要先有一次點擊才讓聲音出來:如果在 setup() 裡太早,就放在 mousePressed() 或按鈕的處理函式裡呼叫。不管載入選項 mic 是什麼都能用,只有 ?oav_mic=off 例外,那時什麼都開不了麥克風。
回傳 Promise<Boolean> 麥克風開起來之後 resolve 成 true。被擋下、不存在、或載入選項 mic 是 off 的時候 resolve 成 false。
注意 呼叫過 stopMic() 之後,只有 startMic()(或 🎤)能再把它打開。讀 micLevel 不會。 範例
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
}
function mousePressed() {
startMic().then((ok) => console.log(ok ? 'microphone on' : 'no microphone' ));
}
function draw() {
background(20);
fill(255);
text(micIsOn ? 'level ' + nf(micLevel, 1, 2) : 'click to start the microphone' , width / 2, height / 2);
}
點下去之前什麼都不會發生。點了之後瀏覽器才詢問,數字就開始動。 會要求麥克風
p5/oav-p5.js 第 1045 行
stopMic()
關掉麥克風,所有麥克風的值都歸零。
它會停掉麥克風的音軌並關掉它的音訊環境,所以瀏覽器上「正在錄音」的指示會消失。micLevel 、bass 、mid、treble 等等都回到 0,micIsOn 變成 false。
回傳 Promise<Boolean> resolve 成 false,也就是麥克風的新狀態。
注意 stopMic() 之後,讀 micLevel 不會再把麥克風打開:要用 startMic() 或 🎤 按鈕。範例
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
}
function keyPressed() {
if (key === 'm' ) { if (micIsOn) stopMic(); else startMic(); }
}
function draw() {
background(20);
fill(255);
circle(width / 2, height / 2, 50 + micLevel * 300);
text('press m to turn the microphone ' + (micIsOn ? 'off' : 'on' ), width / 2, height - 24);
}
按 m 開關麥克風。關掉的時候圓縮到最小。 會要求麥克風
p5/oav-p5.js 第 1048 行
OAVp5.mic
平常那些名稱底下的麥克風:它的狀態、跟 micLevel 和 bass 一樣的數字(讀它們不會把麥克風打開)、原始的音量,還有 start、stop 和事件。
所有麥克風的名稱(micLevel 、bass 、micIsOn 、startMic() …)讀的都是這裡。差別是讀 OAVp5.mic.level 永遠不會去要麥克風,而一般的 micLevel 第一次讀就會。所以 OAVp5.mic 是給「只想看、不想吵醒任何東西」的程式用的:狀態列、函式庫、設了 ?oav_mic=click 的草圖。
成員有:state 、error 、sensitivity (也叫 gain)、level 、bass (還有 mid、treble)、rms 、db 、floor 、analyzer 、start() 、stop() 和 on() 。
範例
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
if (OAVp5.mic.state === 'on' ) OAVp5.mic.stop(); else OAVp5.mic.start();
}
function draw() {
background(20);
fill(255);
text('microphone: ' + OAVp5.mic.state + ' (click the canvas to switch it)' , 20, 30); // reading this asks for nothing
circle(width / 2, height / 2, 50 + OAVp5.mic.level * 300);
}
一個跟著麥克風走的圓,要點一下畫布才會開始收音:讀 OAVp5.mic 不會自己把麥克風打開。 會要求麥克風
相關 micLevel 麥克風聽到的聲音有多大聲,從 0(安靜的房間)到 1(大喊)。 bass 麥克風聽到的聲音裡,低音(bass)、中音(mid)、高音(treble)各佔多少,各自從 0 到 1。 micIsOn 麥克風現在有沒有在運作。 startMic() 開啟麥克風,並回傳一個 promise,告訴你有沒有成功。 stopMic() 關掉麥克風,所有麥克風的值都歸零。 mic 誰可以把麥克風打開:auto(第一次讀 micLevel 這些)、click(只有 🎤 或 startMic())或 off(誰都不行)。 p5/oav-p5.js 第 882 行
OAVp5.mic.analyzer#
變數 oav-p5 OAVp5.mic.analyzer
麥克風開著的時候,負責聽麥克風的那個 @openav/audio 的 AudioAnalyzer,其他時候是 null。
0 到 1 的數字不夠用的時候,直接進到聲音本身的路:用瀏覽器自己的 OAVp5.mic.analyzer.analyser.getByteFrequencyData(array) 讀麥克風的頻譜,或把你自己的 Web Audio 節點接上去。oav-p5 每個畫面會更新一次這個分析器,所以不要自己去呼叫它的 update()。
型別 AudioAnalyzer | null麥克風啟動時產生,停止時丟掉。它有自己的 AudioContext(OAVp5.mic.analyzer.ctx),也有它讀取的 Web Audio AnalyserNode(OAVp5.mic.analyzer.analyser)。
注意 這是進階用法。先確認它不是 null,而且 stopMic() 再 startMic() 之後要重新讀:每次都是新的物件。 範例
let bins;
function setup() {
createCanvas(400, 300);
noStroke();
startMic();
}
function draw() {
background(20);
const a = OAVp5.mic.analyzer;
if (!a) return ; // the microphone is not on yet
bins = bins || new Uint8Array(a.analyser.frequencyBinCount);
a.analyser.getByteFrequencyData(bins); // the spectrum, 0..255 per frequency bin
fill(255, 211, 107);
for (let i = 0; i < 64; i++) {
const h = bins[i] / 255 * (height - 20);
rect(20 + i * 5.8, height - 10 - h, 4, h);
}
}
麥克風頻譜最低的 64 個頻格畫成長條,直接從分析器讀。吹口哨,看長條動起來。 會要求麥克風
相關 OAVp5.mic.start() 開啟麥克風,並回傳一個 promise,告訴你有沒有成功:跟 startMic() 一樣。 OAVp5.mic.state 麥克風現在的情形:'off'、'asking'、'on'、'denied' 或 'unavailable'。 bass 麥克風聽到的聲音裡,低音(bass)、中音(mid)、高音(treble)各佔多少,各自從 0 到 1。 micLevel 麥克風聽到的聲音有多大聲,從 0(安靜的房間)到 1(大喊)。 p5/oav-p5.js 第 871 行
OAVp5.mic.bass同一條也包含: OAVp5.mic.mid OAVp5.mic.treble
跟 bass、mid、treble 一樣的 0 到 1 低音、中音、高音能量,讀它們不會把麥克風打開。
一般的 bass 、mid、treble 第一次被讀的時候會去要麥克風,而且草圖本來就有同名變數的話就由草圖保留。OAVp5.mic.bass、.mid、.treble 就是數字本身:一直都在,也不會啟動任何東西。一般名稱被占用的時候也要讀它們:在 strudel.cc 上 bass 是 Strudel 的,卡片會告訴你改讀這個。
型別 Number
範圍 0..1bass 是 20–250 Hz,mid 是 250–2000 Hz,treble 是 2000–8000 Hz,乘上靈敏度、最多算到 1。麥克風開著時每個畫面更新一次,其他時候是 0。
注意 三個名稱共用這一條,因為它們的行為完全相同,只有頻率範圍不同。 範例
function setup() {
createCanvas(400, 300);
noStroke();
startMic();
}
function draw() {
background(20);
const bands = [OAVp5.mic.bass, OAVp5.mic.mid, OAVp5.mic.treble];
for (let i = 0; i < 3; i++) {
fill(255, 80 + i * 70, 120);
rect(60 + i * 110, height - 20, 80, -20 - bands[i] * 240);
}
}
低、中、高音三根長條,用原始的成員寫。吹口哨看右邊那根,哼低音看左邊那根。 會要求麥克風
相關 bass 麥克風聽到的聲音裡,低音(bass)、中音(mid)、高音(treble)各佔多少,各自從 0 到 1。 OAVp5.mic.level 跟 micLevel 一樣的 0 到 1 音量,讀它不會把麥克風打開。 micSensitivity() 調整麥克風的靈敏度:micSensitivity(2) 是兩倍靈敏,micSensitivity() 會告訴你現在是多少。 startMic() 開啟麥克風,並回傳一個 promise,告訴你有沒有成功。 p5/oav-p5.js 第 871 行
OAVp5.mic.db
麥克風的音量,單位是相對滿刻度的分貝(dBFS):0 是最大聲,−100 是無聲或麥克風關著。
分貝就是耳朵聽到的樣子:每增加 6 dB 就是振幅兩倍,所以用 dB 的數字,變化的方式就跟聽起來的大小聲一致。micLevel 把它對應到 0 到 1,範圍從房間底噪(再加 6 dB)到 −10 dBFS。想用自己的刻度的話,直接用 db,例如 map(OAVp5.mic.db, -60, -10, 0, 1)。
型別 Number
範圍 -100..020 * log10(rms),由 OAVp5.mic.rms 算出來。低於約 −100 dB 和麥克風關著的時候是 −100。每個畫面更新一次。
範例
function setup() {
createCanvas(400, 300);
startMic();
}
function draw() {
background(20);
fill(255);
text(nf(OAVp5.mic.db, 1, 1) + ' dBFS' , 20, 30);
const own = constrain(map(OAVp5.mic.db, -60, -10, 0, 1), 0, 1); // an own scale: -60 dB to -10 dB
circle(width / 2, height / 2, 50 + own * 300);
}
用 dBFS 表示的音量,加上一個用自己的刻度(−60 到 −10 dB)的圓。允許麥克風,然後說話。 會要求麥克風
p5/oav-p5.js 第 872 行
OAVp5.mic.error#
變數 oav-p5 OAVp5.mic.error
麥克風為什麼沒開起來,用瀏覽器的錯誤名稱表示,像 'NotAllowedError',沒有錯誤的時候是 null。
給想比卡片說得更多的草圖用:if (OAVp5.mic.error === 'NotAllowedError') …。🎹 旁邊的卡片已經用使用者的語言,針對每一種告訴他要按什麼了,這是同一件事給你的程式用。
型別 String | null一開始是 null,麥克風開起來之後又回到 null。失敗之後是瀏覽器的錯誤名稱(被拒絕是 'NotAllowedError' 或 'SecurityError',沒有麥克風是 'NotFoundError',被別的程式占住是 'NotReadableError'),或是在不能詢問的頁面上的 'no getUserMedia'。
範例
function setup() {
createCanvas(400, 300);
startMic();
}
function draw() {
background(20);
fill(255);
text('state: ' + OAVp5.mic.state, 20, 30);
text('error: ' + OAVp5.mic.error, 20, 60); // null while all is well
}
印出狀態和錯誤。拒絕麥克風就會看到 "NotAllowedError"。 會要求麥克風
p5/oav-p5.js 第 871 行
OAVp5.mic.floor#
變數 oav-p5 OAVp5.mic.floor
oav-p5 量到的房間本身的底噪,單位是 dBFS:micLevel 把它(再加 6 dB)當成安靜的那個音量。
嗡嗡響的教室不該被讀成很大聲,所以 micLevel 是從房間本身量起的:這個底噪跟著最近最安靜的讀數走。房間變安靜時,一秒內就會降下去,變吵時每秒最多往上爬 1.5 dB,所以人聲不會變成新的安靜。比底噪高 6 dB 的音量,在 micLevel 上讀成 0。
型別 Number
範圍 -70..-45一開始是 −60,永遠限制在 −70 到 −45 之間。麥克風開著時每個畫面更新一次。
注意 停掉麥克風不會重設它:下次開始會從上一次的底噪接著走。 範例
function setup() {
createCanvas(400, 300);
startMic();
}
function draw() {
background(20);
fill(255);
text('level ' + nf(OAVp5.mic.db, 1, 1) + ' dBFS' , 20, 30);
text('room noise ' + nf(OAVp5.mic.floor, 1, 1) + ' dBFS' , 20, 60);
text('micLevel ' + nf(OAVp5.mic.level, 1, 2), 20, 90);
}
顯示音量、房間的底噪,和算出來的 micLevel。安靜個幾秒,看底噪穩定下來。 會要求麥克風
p5/oav-p5.js 第 872 行
OAVp5.mic.level#
變數 oav-p5 OAVp5.mic.level
跟 micLevel 一樣的 0 到 1 音量,讀它不會把麥克風打開。
micLevel 是一個 getter,回傳這個數字,而且第一次被讀的時候會去要麥克風。OAVp5.mic.level 就是數字本身,所以在哪裡讀都安全:使用者還沒決定之前、設了 ?oav_mic=click,或是在函式庫裡。用 startMic() 或 🎤 啟動麥克風,它才會動。它怎麼量(分貝、以房間為 0、−10 dBFS 為 1)寫在 micLevel 底下。
型別 Number
範圍 0..1麥克風開著的時候,每個畫面更新一次。關著、詢問中、被擋下或不存在的時候是 0。
範例
function setup() {
createCanvas(400, 300);
startMic();
}
function draw() {
background(20);
circle(width / 2, height / 2, 50 + OAVp5.mic.level * 300);
}
音量圓,這次用原始的數字,不用 micLevel。允許麥克風,然後說話。 會要求麥克風
p5/oav-p5.js 第 871 行
OAVp5.mic.on(name, fn)
監聽麥克風的事件('beat'、'kick'、'snare' 或 'hat'),不必定義全域函式,並回傳一個停止監聽的函式。
beatDetected() 、kickDetected() 、snareDetected()、hatDetected() 的監聽者版本:想要幾個都行,草圖在跑的時候可以加也可以拿掉,也不占用任何全域名稱。fn 裡面出錯會顯示在 console 和卡片上。
呼叫之前
監聽並不會把麥克風打開:要呼叫 startMic() 、按 🎤,或是也讀一下 micLevel ,不然什麼事件都不會發生。(定義全域的 beatDetected() 就不一樣:oav-p5 看到那個函式,會在第一次點擊時啟動麥克風。)這些事件只來自麥克風,不來自鼓機。
回傳 Function 一個把 fn 取消的函式:const off = OAVp5.mic.on(…); off();。
注意 鼓機在播的時候,全域的 beatDetected()、kickDetected() 這些也會為鼓機的每一下被呼叫。用 OAVp5.mic.on() 加的監聽者只聽得到麥克風。 範例
let flash = 0;
function setup() {
createCanvas(400, 300);
startMic();
OAVp5.mic.on('beat' , (level) => { flash = 0.4 + level * 0.6; }); // louder beat, brighter flash
}
function draw() {
background(20 + flash * 200);
flash *= 0.9;
}
麥克風每聽到一個拍子就閃一下,拍子越大聲閃得越亮。拍手或敲桌子。 會要求麥克風
相關 beatDetected() 你來定義這個函式。麥克風(或鼓機)每聽到一個拍子,oav-p5 就呼叫它一次。 kickDetected() 你來定義這幾個函式。麥克風(或鼓機)聽到大鼓、小鼓或鈸的時候,oav-p5 會呼叫 kickDetected(level)、snareDetected(level)、hatDetected(level)。 OAVp5.on() 用名字監聽一個 MIDI 事件,不必定義全域函式,並回傳一個停止監聽的函式。 beat 麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。 startMic() 開啟麥克風,並回傳一個 promise,告訴你有沒有成功。 p5/oav-p5.js 第 875 行
OAVp5.mic.rms
麥克風原始的音量:平滑過的 RMS,0 到 1,是 oav-p5 把它換成 micLevel 的分貝刻度之前的值。
最新一個畫面裡所有取樣的均方根,稍微平滑過。它是線性的,就像波形畫出來的樣子,所以適合快速檢查,或是用在你自己的刻度上。micLevel 就是用它算出來的:OAVp5.mic.db 是同一個數字換成 dBFS,OAVp5.mic.floor 是房間本身的底噪,單位也是 dBFS。
型別 Number
範圍 0..1麥克風開著的時候,每個畫面更新一次,關著是 0。對著筆電說話是個很小的數字,程式碼裡的註解說大約在 0.01 到 0.05,所以 micLevel 才改用分貝來算。
範例
function setup() {
createCanvas(400, 300);
startMic();
}
function draw() {
background(20);
fill(255);
text('rms ' + nf(OAVp5.mic.rms, 1, 4), 20, 30);
rect(20, 60, min(OAVp5.mic.rms * 10, 1) * (width - 40), 20); // magnified 10 times: a voice is small
}
原始的 RMS,用數字和長條表示,放大十倍,因為說話的數值很小。允許麥克風,然後說話。 會要求麥克風
待確認
說話和音樂的典型數值要看麥克風和房間。人聲的 0.01 到 0.05 是程式碼註解裡的數字,這裡沒有量過。
p5/oav-p5.js 第 872 行
OAVp5.mic.sensitivity#
變數 oav-p5 OAVp5.mic.sensitivity同一條也包含: OAVp5.mic.gain
麥克風的靈敏度:平常是 1,2 是兩倍靈敏。用 micSensitivity() 來設定,OAVp5.mic.gain 是舊名字。
這個數字乘在麥克風聽到的東西上:對 micLevel 來說 2 等於加 6 dB,對 bass 、mid、treble 是乘以 2。讀它來顯示目前的設定,要設定的話用 micSensitivity() ,它會檢查數字。
型別 Number
範圍 0.05..20一開始是 1。micSensitivity(v) 會把你給的值限制在 0.05 到 20 之間。
注意 OAVp5.mic.gain 是同一個值,用的是 oav-p5 0.3.0 的名字。直接指定給任何一個都行(OAVp5.mic.gain = 2),但不會被限制在 0.05 到 20 之間,而且 gain 遇到不大於 0 的數字會退回 1。範例
function setup() {
createCanvas(400, 300);
micSensitivity(3); // a quiet or far-away microphone
}
function draw() {
background(20);
fill(255);
text('sensitivity ' + OAVp5.mic.sensitivity, 20, 30); // 3
circle(width / 2, height / 2, 50 + micLevel * 300);
}
micSensitivity(3) 設定這個數字,OAVp5.mic.sensitivity 把它讀回來。 會要求麥克風
相關 micSensitivity() 調整麥克風的靈敏度:micSensitivity(2) 是兩倍靈敏,micSensitivity() 會告訴你現在是多少。 micLevel 麥克風聽到的聲音有多大聲,從 0(安靜的房間)到 1(大喊)。 bass 麥克風聽到的聲音裡,低音(bass)、中音(mid)、高音(treble)各佔多少,各自從 0 到 1。 p5/oav-p5.js 第 869 行
OAVp5.mic.start()#
函式 oav-p5 OAVp5.mic.start()
開啟麥克風,並回傳一個 promise,告訴你有沒有成功:跟 startMic() 一樣。
startMic() 就是這個函式:要先發生什麼(一次點擊、https 或 localhost)請看那一條。OAVp5.mic.start() 也會解除 stopMic() 留下的「你停掉的」記號,這樣之後再讀 micLevel 就又能把麥克風打開。
回傳 Promise<Boolean> 麥克風開起來之後 resolve 成 true,被擋下、不存在、或 mic 選項是 off 的時候 resolve 成 false。
範例
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
OAVp5.mic.start().then((ok) => console.log(ok ? 'microphone on' : 'no microphone' ));
}
function draw() {
background(20);
fill(255);
text(OAVp5.mic.state === 'on' ? 'listening' : 'click the canvas to start the microphone' , 20, 30);
}
點下去之前什麼都不會發生:點了之後瀏覽器才詢問,麥克風開起來時文字就會改變。 會要求麥克風
相關 startMic() 開啟麥克風,並回傳一個 promise,告訴你有沒有成功。 OAVp5.mic.stop() 關掉麥克風,所有麥克風的值都歸零:跟 stopMic() 一樣。 OAVp5.mic.state 麥克風現在的情形:'off'、'asking'、'on'、'denied' 或 'unavailable'。 mic 誰可以把麥克風打開:auto(第一次讀 micLevel 這些)、click(只有 🎤 或 startMic())或 off(誰都不行)。 p5/oav-p5.js 第 873 行
OAVp5.mic.state#
變數 oav-p5 OAVp5.mic.state
麥克風現在的情形:'off'、'asking'、'on'、'denied' 或 'unavailable'。
'off':還沒啟動,或已經停了。'asking':瀏覽器在等使用者回答。'on':聲音進來了,micIsOn 是 true。'denied':權限被拒絕(被使用者、被電腦的設定,或被沒有 allow="microphone" 的 iframe)。'unavailable':沒有麥克風、瀏覽器打不開,或網頁不在 https 或 localhost 底下。不是 'on' 的時候,OAVp5.mic.error 有瀏覽器給的原因,🎹 旁邊也會有卡片說明要按什麼。
型別 String
範圍 'off' | 'asking' | 'on' | 'denied' | 'unavailable'你開始或停止麥克風、瀏覽器回答的時候會變。在有東西啟動它之前是 'off'。讀它不會啟動它。
範例
function setup() {
createCanvas(400, 300);
startMic();
}
function draw() {
background(OAVp5.mic.state === 'on' ? 20 : color(90, 30, 30));
fill(255);
text('microphone: ' + OAVp5.mic.state, 20, 30);
if (OAVp5.mic.state === 'denied' ) text('click the lock left of the address and allow it' , 20, 60);
}
麥克風開起來之前背景是紅的。狀態先是 "asking",你允許之後變成 "on",拒絕的話變成 "denied"。 會要求麥克風
相關 micIsOn 麥克風現在有沒有在運作。 OAVp5.mic.error 麥克風為什麼沒開起來,用瀏覽器的錯誤名稱表示,像 'NotAllowedError',沒有錯誤的時候是 null。 startMic() 開啟麥克風,並回傳一個 promise,告訴你有沒有成功。 mic 誰可以把麥克風打開:auto(第一次讀 micLevel 這些)、click(只有 🎤 或 startMic())或 off(誰都不行)。 OAVp5.check() 執行 🩺 體檢:版本、頁面、瀏覽器、麥克風、MIDI、聲音、鼓機、錄影、接線和提示,做成一張可以截圖的卡片,也回傳成物件。 p5/oav-p5.js 第 868 行
OAVp5.mic.stop()#
函式 oav-p5 OAVp5.mic.stop()
關掉麥克風,所有麥克風的值都歸零:跟 stopMic() 一樣。
stopMic() 就是這個函式。它停掉麥克風的音軌並關掉它的音訊環境,所以瀏覽器上「正在錄音」的指示會消失,OAVp5.mic.level 、bass、mid、treble、rms 都讀到 0,db 讀到 −100。之後只有 OAVp5.mic.start() 或 🎤 能再把麥克風打開:讀 micLevel 不行。
回傳 Promise<Boolean> resolve 成 false,也就是麥克風的新狀態。
範例
function setup() {
createCanvas(400, 300);
startMic();
}
function mousePressed() {
OAVp5.mic.stop(); // the browser's recording indicator goes away
}
function draw() {
background(20);
fill(255);
text('microphone: ' + OAVp5.mic.state + ' (click the canvas to stop it)' , 20, 30);
circle(width / 2, height / 2, 50 + OAVp5.mic.level * 300);
}
點一下畫布:麥克風停止,狀態變成 "off",圓縮到最小。 會要求麥克風
p5/oav-p5.js 第 873 行
@openav/audio
AudioAnalyzer#
類別 @openav/audio new AudioAnalyzer({ signals = null , fftSize = 2048, smooth = 0.7 } = {})
聽麥克風、audio 元素或任何 Web Audio 節點,把它變成音量、三個頻段、起音,以及大鼓、小鼓、鈸的打點。
AudioAnalyzer 包著一個 Web Audio 的 AnalyserNode。你給它一個聲音來源(enableMic()、enableElement(el),或把自己的節點接到 input()),每個畫面呼叫一次 update(),它就回傳一個只有數字的物件,如果你給了 signals 匯流排,也會同時發布成 audio/* 訊號。這就是 p5 草圖的麥克風底下的分析:micLevel 是它的 rms 換成分貝,bass 、mid、treble 是它的 low、mid、high,beat 和 kickDetected() 來自它的 onset 和 kick。
來源開起來之前,update() 回傳 null。開起來之後,它回傳 { rms, peak, low, mid, high, centroid, onset, kick, snare, hat }。rms 是平滑後的音量,peak 是這一刻最大的取樣,兩個都是滿刻度的 0..1(瀏覽器有浮點取樣的話就用浮點,所以安靜的房間也量得到,不會被四捨五入到 8 位元的一格)。人聲的 rms 很小,要乘上倍數,或用範圍視窗對應。low、mid、high 是頻譜在 20–250 Hz、250–2000 Hz、2000–8000 Hz 的平均強度,0..1,取自分析器的位元組頻譜,所以不是分貝,微弱的頻段會貼近 0。centroid 是頻譜的重心,從 0 Hz 到取樣率的一半,0..1(亮度)。onset 只在音量跳過它自己適應的底線的那一次 update() 是 true,兩個起音之間至少隔 100 毫秒。
kick、snare、hat 是 { hit, level, env },做法是在頻譜的某一段找突然的上升,沒有用機器學習:大鼓 20–120 Hz、小鼓 150–800 Hz、鈸 6000–14000 Hz。hit 在找到一下的那次 update() 是 true(互相至少隔 90、90、60 毫秒),level 是那一段現在的強度,env 是 0..1:打到的時候跳到 min(1, level × 2),之後每次 update() 縮小 12%,每秒 60 個畫面的話大約 120 毫秒。因為它是每次 update() 衰減,所以你呼叫 update() 的頻率越低,它掉得越快。
有 signals 匯流排的話,會發布這些:audio/rms、audio/peak、audio/band/low、audio/band/mid、audio/band/high、audio/centroid、audio/kick/env、audio/snare/env、audio/hat/env(連續值,0..1),以及脈衝 audio/onset { rms }、audio/kick { level }、audio/snare { level }、audio/hat { level }。脈衝只在找到東西的時候才觸發。
呼叫之前
瀏覽器要使用者做過一個動作之後才讓聲音開始。enableMic() 會去要麥克風,而分析器的 AudioContext 在頁面被點一下或按過鍵之前都是暫停的:update() 每個畫面都會呼叫 resume(),所以第一次點擊之後數字會自己開始動。在那之前全部都是 0。麥克風要求網頁在 https 或 localhost 底下。
參數 名稱 型別 說明 signalsSignals要發布訊號的 Signals 匯流排。有給的話,建構分析器時就會定義 audio/* 訊號,每次 update() 都會更新它們。沒給的話,update() 只回傳數字。 可省略 預設 null fftSizeNumber分析器 FFT 的大小,32 到 32768 之間的 2 的次方。頻譜的格數是它的一半。 可省略 預設 2048 smoothNumber每次更新要留下多少上一次的 rms,0..1。越高越平滑也越慢:rms = rms × smooth + 現在的值 × (1 − smooth)。 可省略 預設 0.7
成員 名稱 說明 enableMic()method 向瀏覽器要麥克風(回音消除、噪音抑制、自動增益都關掉)並接起來。回傳一個 promise,成功時 resolve 成 true。權限被拒或沒有麥克風的時候會 reject,錯誤是瀏覽器給的(NotAllowedError、NotFoundError…)。 enableElement(el)method 分析一個 <audio> 或 <video> 元素:enableElement(el) 回傳 true。元素照常從喇叭播出來。Web Audio 對同一個元素只收一次,所以對同一個元素第二次呼叫 enableElement(),瀏覽器會丟出錯誤。 input()method 回傳 AnalyserNode(必要時先建好 context),讓你接自己的聲音:node.connect(audio.input())。audio.ctx 裡的任何節點都行。Tone.js 的話接它的原生節點。這樣接不會把聲音送到喇叭。 update()method 每個畫面呼叫一次。讀分析器、算出數字,有匯流排的話就發布訊號,並回傳 { rms, peak, low, mid, high, centroid, onset, kick, snare, hat } 這個物件,還沒開任何來源的話回傳 null。它也會把暫停的 context 叫醒。
注意 它自己建一個 AudioContext,所以 p5.sound 或 Tone.js 可以在旁邊照常跑。要分析在別的 context 裡的聲音,就把 audio.ctx 的節點接到 input(),或用 enableElement()。 enableMic() 會關掉回音消除、噪音抑制和自動增益,因為它們會改變你想量的音量。筆電如果一邊播放一邊聽,喇叭的聲音會漏進麥克風。範例
js 複製
import { AudioAnalyzer } from '@openav/audio' ;
const audio = new AudioAnalyzer();
await audio.enableMic(); // the browser asks for the microphone
const g = document.getElementById('c' ).getContext('2d' );
(function frame() {
const f = audio.update(); // { rms, peak, low, mid, high, ... }
g.fillStyle = '#111' ; g.fillRect(0, 0, 400, 300);
g.fillStyle = '#ffd166' ;
g.fillRect(20, 130, Math.min(1, f.rms * 6) * 360, 40); // a voice is a small rms: scale it
requestAnimationFrame(frame);
})();
module 允許麥克風、在頁面上點一下,然後說話:一條橫條跟著音量變長。 會要求麥克風
js 複製
import { AudioAnalyzer } from '@openav/audio' ;
const audio = new AudioAnalyzer(); // no signals bus: update() just returns the numbers
const input = audio.input(); // makes the AudioContext (audio.ctx) and the AnalyserNode
const osc = new OscillatorNode(audio.ctx, { frequency: 100 });
osc.connect(input); // any node of audio.ctx can feed the analyser
osc.start();
let frames = 0;
(function frame() {
const f = audio.update(); // also resumes the context once the page has had a click
if (++frames < 40) return requestAnimationFrame(frame);
console.log(audio.ctx.state, f.low > f.high); // running true: a 100 Hz tone sits in the low band
console.log(f.rms > 0.1, f.centroid < 0.1); // true true
})();
module 不需要麥克風:一個 100 Hz 的音從 input() 送進分析器。40 個畫面之後,log 會告訴你低頻段比高頻段大。
js 複製
import { AudioAnalyzer } from '@openav/audio' ;
import { Signals } from '@openav/core' ;
const signals = new Signals();
const audio = new AudioAnalyzer({ signals }); // defines the audio/* signals at once
console.log(signals.list().length); // 13
signals.on('audio/onset' , ({ rms }) => console.log('onset' , rms.toFixed(2)));
signals.on('audio/kick' , ({ level }) => console.log('kick' , level.toFixed(2)));
console.log(signals.get ('audio/rms' )); // 0, nothing heard yet
console.log(audio.update()); // null until a source is enabled
module 有匯流排的話,分析器一開始就定義好十三個 audio/* 訊號。用 signals.on 聽脈衝。來源開起來、找到打點的時候,處理函式就會被呼叫。
待確認
這個專案的各處對說話的 rms 典型值說法不一:手冊(docs/inputs.md)說說話常在 0.05 到 0.2 附近,oav-p5 裡的註解說是 0.01 到 0.05。它要看麥克風和房間,這裡沒有實際量過,所以不給典型數字。
相關 micLevel 麥克風聽到的聲音有多大聲,從 0(安靜的房間)到 1(大喊)。 bass 麥克風聽到的聲音裡,低音(bass)、中音(mid)、高音(treble)各佔多少,各自從 0 到 1。 beat 麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。 kickDetected() 你來定義這幾個函式。麥克風(或鼓機)聽到大鼓、小鼓或鈸的時候,oav-p5 會呼叫 kickDetected(level)、snareDetected(level)、hatDetected(level)。 micSensitivity() 調整麥克風的靈敏度:micSensitivity(2) 是兩倍靈敏,micSensitivity() 會告訴你現在是多少。 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 import { AudioAnalyzer } from '@openav/audio'; packages/audio/index.js 第 24 行
聲音與樂器#
彈的每個鍵都聽得到:startSound()、@openav/sound。 33
oav-p5
soundIsOn
現在彈的音有沒有被聽到:startSound() 完成之後是 true,之前和之後都是 false。
像 mouseIsPressed 一樣在 draw() 裡讀:if (!soundIsOn) text('click to hear', 20, 30);。它只有在聲音的狀態改變的時候才會變,不是每個畫面都更新。讀它不會啟動任何東西(跟 micLevel 不一樣),所以在哪裡檢查都安全。
false 不代表沒呼叫過 startSound()。要分辨「載入中」、「等點擊」和「失敗」,請讀 OAVp5.sound.state ,失敗的原因在 OAVp5.sound.error 。
型別 Boolean只有 OAVp5.sound.state 是 'on' 的時候才是 true。一開始、等待點擊、載入中、呼叫 stopSound() 之後、以及出錯的時候都是 false。
要先做什麼才有值
要先呼叫 startSound() (或按 🎹 面板的 🔊、或網址加 ?oav_sound=piano),而且過了瀏覽器要求的那次點擊,它才會變成 true。
注意 如果草圖本來就宣告了自己的 soundIsOn 變數,就用你的:oav-p5 不動這個名稱,並用一張卡片提醒你改讀 OAVp5.sound.state === 'on'。 範例
function setup() {
createCanvas(400, 300);
startSound('organ' );
}
function draw() {
background(20);
fill(soundIsOn ? color(120, 220, 140) : color(220, 160, 60));
circle(width / 2, height / 2, 80);
fill(255);
textAlign(CENTER);
text(soundIsOn ? 'sound is on' : 'click the canvas to turn sound on' , width / 2, height - 40);
}
點擊之前是琥珀色的圓,點了之後變綠色:那一刻就是 soundIsOn 變成 true 的時候。
p5/oav-p5.js 第 316 行
startSound([id])同一條也包含: OAVp5.sound.start
讓你彈的鍵都聽得到,用你指定的樂器發聲,並回傳一個 promise,告訴你聲音有沒有開起來。
在 setup() 裡呼叫一次:startSound('organ')。從這時候起,你彈的每個音都聽得到:MIDI 鍵盤、螢幕上的 🎹、手機、midi.play(60, 0.8) 都算。你自己寫的 notePressed() 一樣會收到音符,而且是先發聲、再通知草圖,所以 callback 寫得慢也不會拖慢聲音。MIDI 第 10 軌(General MIDI 的鼓軌)的打擊墊打的是鼓組,不是樂器,延音踏板則會讓音延續。
樂器代號有 piano、epiano、organ、strings、choir、harp、marimba、vibraphone、xylophone、glockenspiel、music-box,以及合成器音色 synth/pad、synth/saw-lead、synth/square-lead、synth/pluck、synth/supersaw、synth/bass、synth/acid、synth/brass、synth/bell、synth/sub、synth/chip。聲音在播的時候再呼叫 startSound(id),就是換樂器。給了不存在的代號,主控台會連同真正的代號清單一起列出來:已經在播的樂器不變,第一次呼叫就給錯的話,會是溫暖的 synth/pad。
在第一次呼叫之前(或按 🎹 面板裡的 🔊 之前)什麼都不會載入:@openav/sound 從 oav-p5.js 旁邊載入,Tone.js 15 從 jsDelivr 載入(載入選項 tone 可以改成別的地方)。只有 piano 播的是錄好的取樣,第一次大約 2 MB,載好之前由電鋼琴先頂著(如果是從別的樂器切過來,就由原本那個繼續響),取樣到齊的時候 OAVp5.sound.instrument 會變成 piano。其他樂器都是 Tone.js 即時合成的,沒有檔案要載。
呼叫之前
瀏覽器要頁面上先有一次點擊或按鍵才讓頁面出聲,MIDI 鍵盤上的鍵不算。在 setup() 裡呼叫 startSound() 沒有問題:OAVp5.sound.state 會是 'waiting',🎹 旁邊有一張卡片說「聲音準備好了,還沒響」,第一次點擊或按鍵就會把聲音打開。soundIsOn 會告訴你什麼時候開了。
參數 名稱 型別 說明 idString樂器的代號,例如 'organ' 或 'synth/pluck'(完整清單在說明裡)。不傳就沿用之前選的樂器,第一次呼叫時就是鋼琴。不是非空字串的值,視同沒傳。 可省略 預設 'piano'
回傳 Promise<Boolean> 聲音開起來之後 resolve 成 true。瀏覽器要先有一次點擊的話,就是在頁面上第一次點擊或按鍵之後。載入選項 sound 是 off、stopSound() 先被呼叫、或 Tone.js 載入失敗的時候,resolve 成 false。
注意 完全不寫程式也行:在網址後面加 ?oav_sound=piano(任何樂器代號都可以,在 OpenProcessing 上是 ?sketch_oav_sound=piano),任何草圖從第一次點擊起就聽得到,?oav_sound=off 則讓它保持安靜,這時 startSound() 什麼都不做,並 resolve 成 false。🎹 面板裡的 🔊 可以開關聲音,並列出所有樂器讓你挑。 聲音和鼓機是分開的:startDrums() 有自己的鼓組,不用 startSound(),第一次點擊之後就聽得到。 範例
let playing = 60;
function setup() {
createCanvas(400, 300);
startSound('synth/pluck' ); // every key you play is heard
}
function mousePressed() {
playing = 48 + floor(mouseX / 25); // low on the left, high on the right
midi.play(playing, 0.8);
}
function mouseReleased() {
midi.play(playing, 0);
}
function draw() {
background(soundIsOn ? 30 : 90);
fill(255);
textAlign(CENTER, CENTER);
text(soundIsOn ? 'click to play a note' : 'click once to turn sound on' , width / 2, height / 2);
}
第一次點擊把聲音打開,背景變暗,之後每點一下就彈一個音,越右邊音越高。
const KEYS = { a: 60, s: 62, d: 64, f: 65, g: 67, h: 69, j: 71, k: 72 };
let dots = [];
function setup() {
createCanvas(400, 300);
noStroke();
startSound('epiano' );
}
function keyPressed() {
if (KEYS[key]) midi.play(KEYS[key], 0.8);
}
function keyReleased() {
if (KEYS[key]) midi.play(KEYS[key], 0);
}
function notePressed(n, v) {
dots.push({ x: map(n, 60, 72, 30, width - 30), y: height, v });
}
function draw() {
background(20);
for (const d of dots) {
fill(255, 160, 80);
circle(d.x, d.y, 20 + d.v * 30);
d.y -= 2;
}
dots = dots.filter((d) => d.y > -40);
fill(255);
text(soundIsOn ? 'play with a s d f g h j k' : 'press a key once to turn sound on' , 20, 24);
}
按 a、s、d、f、g、h、j、k:每個鍵都會發聲,並從對應的位置飄起一個圓點。
const IDS = ['synth/pluck' , 'organ' , 'marimba' , 'piano' ];
function setup() {
createCanvas(400, 300);
startSound(IDS[0]);
}
function keyPressed() {
const i = Number(key) - 1;
if (IDS[i]) startSound(IDS[i]); // switches while it plays
}
function draw() {
background(20);
fill(255);
const s = OAVp5.sound.status; // the instrument being loaded
text('keys 1-4 pick an instrument' , 20, 30);
text('playing: ' + OAVp5.sound.instrument, 20, 60);
text('loading: ' + s.id + ' (' + s.state + ', ' + nf(s.progress * 100, 1, 0) + '%)' , 20, 90);
}
按 4 換鋼琴:「loading」會顯示取樣載入的進度,「playing」仍是你現在聽到的樂器,等準備好才換成 piano。
p5/oav-p5.js 第 1413 行
stopSound()同一條也包含: OAVp5.sound.stop
關掉聲音,讓正在響的音全部放開,樂器還留在記憶體裡,所以再呼叫 startSound() 馬上就回來。
它會放開所有正在響的音,把 soundIsOn 設成 false,OAVp5.sound.state 設成 'off'。之後彈的音聽不到(草圖的 notePressed() 照樣會被呼叫)。Tone.js 和樂器都還載著,所以之後再呼叫 startSound() 會馬上開,不用重新下載,也不用再等一次點擊。
聲音還在載入、或還在等第一次點擊的時候呼叫也可以:還沒結束的 startSound() 會 resolve 成 false。
回傳 Promise<Boolean> 一個已經 resolve 成 false 的 promise,false 就是聲音的新狀態。
範例
function setup() {
createCanvas(400, 300);
startSound('synth/pluck' );
textAlign(CENTER, CENTER);
}
function keyPressed() {
if (key === 's' ) {
if (soundIsOn) stopSound(); else startSound();
}
}
function draw() {
background(soundIsOn ? 40 : 100);
fill(255);
text('press s: sound is ' + (soundIsOn ? 'on' : 'off' ), width / 2, height / 2);
}
按 s 把聲音關掉再打開,聲音開著的時候背景是暗的。
p5/oav-p5.js 第 1414 行
OAVp5.sound
聲音相關名稱背後的物件:狀態、樂器、錯誤、載入進度,以及載入進來的 @openav/sound 各部分。
OAVp5.sound(也可以用 midi.sound 取得)是 startSound() 、stopSound() 和 soundIsOn 的底層。讀 state 知道聲音在哪個階段,instrument 是你聽到的樂器,status 是載入進度,error 是失敗的原因。start(id) 和 stop() 跟那兩個全域函式一模一樣。shell 、engine、module 把載入進來的 @openav/sound 各部分交給你,給少數真的需要的情況。
注意 一般草圖用全域名稱就夠了。這個物件是給工具、主控台和除錯用的:聽不到聲音的時候,先看 OAVp5.sound.state。 範例
function setup() {
createCanvas(400, 300);
startSound('harp' );
}
function draw() {
background(20);
fill(255);
const s = OAVp5.sound;
text('state: ' + s.state, 20, 30);
text('instrument: ' + s.instrument, 20, 60);
text('error: ' + s.error, 20, 90);
}
聲音物件的三個欄位:狀態會從 "loading" 或 "waiting",在第一次點擊之後變成 "on"。
p5/oav-p5.js 第 1325 行
OAVp5.sound.error#
變數 oav-p5 OAVp5.sound.error
載入聲音的時候,最近一次出錯的訊息,沒出過錯就是 null。
這是 @openav/sound 或 Tone.js 載入失敗的時候,瀏覽器回報的錯誤。🎹 旁邊的卡片已經告訴現場的人該怎麼辦(換一個網路,或把 tone 選項指到另一份 Tone.js)。讀這個值是為了記錄下來,或顯示你自己的訊息。要知道聲音現在是不是在出錯,看 OAVp5.sound.state ,不要看這個。
型別 String | null狀態變成 'error' 的時候設定。它會一直留到頁面重新載入:之後重試成功也不會清掉。
範例
function setup() {
createCanvas(400, 300);
startSound('strings' );
textAlign(CENTER, CENTER);
}
function draw() {
background(OAVp5.sound.error ? color(120, 30, 30) : 20);
fill(255);
text(OAVp5.sound.error ? OAVp5.sound.error : 'no error' , width / 2, height / 2);
}
聲音載入正常的時候一直是 "no error",如果連不到 Tone.js,背景會變紅並印出訊息。
相關 OAVp5.sound.state 聲音現在在哪個階段:'off'、'waiting'(等點擊)、'loading'、'on' 或 'error'。 startSound() 讓你彈的鍵都聽得到,用你指定的樂器發聲,並回傳一個 promise,告訴你聲音有沒有開起來。 soundIsOn 現在彈的音有沒有被聽到:startSound() 完成之後是 true,之前和之後都是 false。 p5/oav-p5.js 第 1318 行
OAVp5.sound.instrument#
變數 oav-p5 OAVp5.sound.instrument
你現在聽到的樂器代號,聲音還沒載入的時候是 null。
第一次呼叫 startSound('piano') 的時候,鋼琴的取樣(約 2 MB)要先下載,這段時間由電鋼琴頂著,所以第一個鍵不會沒聲音:鋼琴準備好之前 instrument 是 'epiano',之後才是 'piano'。如果是從別的樂器切到鋼琴,取樣到齊之前原本那個樂器繼續響,instrument 也還是它的名字。載到哪裡、載的是哪個樂器,看 OAVp5.sound.status 。
型別 String | null樂器換了才會變,取樣型的樂器則是等取樣到齊的時候才換。
範例
function setup() {
createCanvas(400, 300);
startSound('piano' );
textAlign(CENTER, CENTER);
}
function draw() {
background(20);
fill(255);
text(OAVp5.sound.instrument || 'nothing loaded yet' , width / 2, height / 2);
}
顯示你聽到的樂器:鋼琴取樣下載的時候是 epiano,之後變成 piano。
p5/oav-p5.js 第 1318 行
OAVp5.sound.state#
變數 oav-p5 OAVp5.sound.state
聲音現在在哪個階段:'off'、'waiting'(等點擊)、'loading'、'on' 或 'error'。
'off' 是起點,也是 stopSound() 之後的狀態。呼叫 startSound() 之後,@openav/sound 和 Tone.js 還在下載的時候是 'loading',東西都到齊但瀏覽器還沒等到那次點擊的時候是 'waiting',聽得到音符的時候是 'on'。'error' 表示聲音的部分載入失敗(網路、學校的防火牆或廣告攔截器)。訊息在 OAVp5.sound.error ,再呼叫一次 startSound() 會重試。
soundIsOn 就是把它縮成 state === 'on' 的版本。
型別 String
範圍 off | waiting | loading | on | error一開始是 'off',聲音換到另一個階段的時候就跟著變。
範例
function setup() {
createCanvas(400, 300);
startSound('marimba' );
textAlign(CENTER, CENTER);
textSize(28);
}
function draw() {
background(20);
const hints = { off: 'off' , loading: 'loading…' , waiting: 'click to hear' , on: 'listening' , error: 'failed' };
fill(255);
text(hints[OAVp5.sound.state], width / 2, height / 2);
}
每個階段一句話,聲音準備好、但還沒點擊的那段時間,畫面會顯示 "click to hear"。
p5/oav-p5.js 第 1317 行
OAVp5.sound.status#
物件 oav-p5 OAVp5.sound.status
樂器載入得怎麼樣了:{ id, state, progress, error },其中 progress 從 0 到 1。
它講的是樂器,不是整個聲音:整個聲音的狀態是 OAVp5.sound.state 。只有 piano 有檔案要載,所以其他樂器的 progress 幾乎馬上就是 1。取樣型樂器載入的時候,OAVp5.sound.instrument 仍是你現在聽到的那個,status.id 才是正在路上的那個(第一次呼叫的時候,一開始可能先是頂替用的電鋼琴,之後才是鋼琴)。
型別 Object
範圍 { id, state: idle | loading | ready | error, progress: 0..1, error }每次讀都是一個新的物件。id 是載入器現在處理的樂器,state 在還沒載任何東西時是 'idle',之後是 'loading'、'ready' 或 'error',progress 是檔案載入的比例。
範例
function setup() {
createCanvas(400, 300);
startSound('piano' );
noStroke();
}
function draw() {
background(20);
const s = OAVp5.sound.status;
fill(60);
rect(40, 140, 320, 16);
fill(120, 220, 140);
rect(40, 140, 320 * s.progress, 16);
fill(255);
text(s.id + ': ' + s.state, 40, 120);
}
鋼琴取樣的進度條,幾秒內會填滿,標籤變成 "ready"。
p5/oav-p5.js 第 1322 行
@openav/sound
BUILTIN_INSTRUMENTS#
常數 @openav/sound BUILTIN_INSTRUMENTS = array, 22 items
套件內建的 22 個樂器的定義:平台鋼琴、敲擊琴、弦樂、豎琴、風琴、合唱和 11 個合成器預設。
text
id 類別 名稱
piano keys 平台鋼琴(Salamander),30 個取樣,CC-BY 3.0
epiano keys 電鋼琴(兩組 FM 加顫音)
xylophone mallets 木琴(高一個八度)
marimba mallets 馬林巴
vibraphone mallets 顫音琴(馬達顫音,放開鍵會止音)
glockenspiel mallets 鐵琴(高兩個八度)
music-box mallets 音樂盒(高兩個八度)
strings strings 弦樂合奏
harp plucked 豎琴(Karplus-Strong)
organ organ 音栓風琴(旋轉喇叭顫音)
choir voice 合唱襯底(啊)
synth/pad synth 溫暖襯底(預設)
synth/saw-lead synth 鋸齒波主奏
synth/square-lead synth 方波主奏
synth/pluck synth 撥奏
synth/supersaw synth 超級鋸齒波
synth/bass synth 類比貝斯
synth/acid synth 酸性 303
synth/brass synth 合成銅管
synth/bell synth FM 鐘聲
synth/sub synth 超低音
synth/chip synth 晶片音(8-bit)載入套件就會登記全部,除非你先登記了同樣 id 的樂器。每一個都是套件的 instruments/ 資料夾裡的一個小檔案。鋼琴只有在被選到的時候才載入取樣,取樣抓不到就退回電鋼琴。風琴、弦樂、合唱、電鋼琴和顫音琴有自己的參數。
型別 Object[]一個有 22 個樂器定義的陣列,順序跟下面的表一樣。
範例
js 複製
import { BUILTIN_INSTRUMENTS, getInstrument } from '@openav/sound' ;
console.log(BUILTIN_INSTRUMENTS.length); // 22
console.log(BUILTIN_INSTRUMENTS.slice(0, 3).map((d) => d.id).join(' ' )); // piano epiano xylophone
console.log(getInstrument('piano' ) === BUILTIN_INSTRUMENTS[0]); // true: they are the registered ones
console.log(getInstrument('organ' ).params.map((p) => p.key).join(' ' )); // drawbars rotor
module 有幾個內建樂器、前三個,以及登記表裡放的就是這些物件。風琴列出它自己的兩個參數。
import { BUILTIN_INSTRUMENTS } from '@openav/sound'; packages/sound/index.js 第 61 行
CATEGORIES#
常數 @openav/sound CATEGORIES = array, 9 items
樂器的類別,照選單的順序:keys、mallets、strings、plucked、organ、voice、synth、drums、other。
樂器定義的 category 決定它在選單裡的組別。不在這份清單裡的類別也可以,它排在這些後面,標籤就是它的 id。中文名稱是 鍵盤、敲擊琴、弦樂、撥弦、風琴、人聲、合成器、鼓、其他。
型別 Object[]九個項目 { id, name: { en, zh } },例如 { id: 'mallets', name: { en: 'Mallets', zh: '敲擊琴' } }。
範例
js 複製
import { CATEGORIES } from '@openav/sound' ;
console.log(CATEGORIES.map((c) => c.id).join(' ' )); // keys mallets strings plucked organ voice synth drums other
console.log(CATEGORIES[1].name.en, CATEGORIES[1].name.zh);
module 依序印出九個 id,以及第二個的英文和中文名稱。
import { CATEGORIES } from '@openav/sound'; 也能從 @openav/sound/registry packages/sound/registry.js 第 32 行
DEFAULT_INSTRUMENT#
常數 @openav/sound DEFAULT_INSTRUMENT = "synth/pad"
沒指定樂器時用的那個:'synth/pad',由三個鋸齒波組成的溫暖襯底,是這個框架最早的聲音。
toneEngine() 一開始用它,你指定了沒登記的樂器時也退回它。它的聲音從第一版以來沒有變過。
型別 String字串 'synth/pad'。
範例
js 複製
import { DEFAULT_INSTRUMENT, getInstrument, instrumentName, toneEngine } from '@openav/sound' ;
console.log(DEFAULT_INSTRUMENT, '-' , instrumentName(getInstrument(DEFAULT_INSTRUMENT))); // synth/pad - Warm pad
console.log(toneEngine().instrument === DEFAULT_INSTRUMENT); // true
module 印出預設樂器和它的名字,以及沒給任何選項的引擎一開始用的就是它。
相關 toneEngine() 發聲引擎,用 Tone.js 播放登記好的樂器,聲音依序經過濾波器、殘響和限幅器。 BUILTIN_INSTRUMENTS 套件內建的 22 個樂器的定義:平台鋼琴、敲擊琴、弦樂、豎琴、風琴、合唱和 11 個合成器預設。 SYNTH_PRESETS 11 個合成器預設,是單純的資料:pad、saw lead、square lead、pluck、supersaw、bass、acid、brass、bell、sub、chip。 import { DEFAULT_INSTRUMENT } from '@openav/sound'; packages/sound/index.js 第 59 行
getInstrument()#
函式 @openav/sound getInstrument(id)
用 id 找出一個登記過的樂器定義,找不到就是 null。
在把 id 交給 toneEngine() 或 setInstrument() 之前,用它確認 id 存在,或讀一個樂器的 credit、defaults、params。
參數 名稱 型別 說明 idStringid,例如 'piano' 或 'synth/pluck'。
回傳 Object | null 你登記的那個定義({ id, name, category, create, … }),或 null。
範例
js 複製
import { getInstrument, instrumentName } from '@openav/sound' ;
const piano = getInstrument('piano' );
console.log(instrumentName(piano), '/' , piano.category); // Grand piano (Salamander) / keys
console.log(piano.credit.text); // Salamander Grand Piano · Alexander Holm · CC-BY 3.0
console.log(getInstrument('theremin' )); // null
module 讀內建鋼琴的名字、類別和出處說明,並顯示沒登記過的 id 會得到 null。
import { getInstrument } from '@openav/sound'; 也能從 @openav/sound/registry packages/sound/registry.js 第 102 行
instrumentGroups()#
函式 @openav/sound instrumentGroups(lang = 'en' , list = listInstruments())
把樂器依類別分組,照選單的順序:[{ id, label, items }],沒有樂器的組會省略。
這就是選單背後的資料,所以你可以自己做選單、清單或一排按鈕。不在 CATEGORIES 裡的類別也可以,它排在已知的類別後面,label 就是它的 id。
參數 名稱 型別 說明 langString組別標籤的語言。 可省略 預設 'en' listObject[]要分組的樂器。沒給的話,就是所有登記過的。 可省略 預設 listInstruments()
回傳 Object[] [{ id, label, items: [定義…] }]:各組依 CATEGORIES 的順序,接著是你自己的類別,依名稱排序。
範例
js 複製
import { instrumentGroups } from '@openav/sound' ;
for (const g of instrumentGroups('en' )) console.log(g.label + ' (' + g.items.length + ')' );
// Keys (2), Mallets (5), Strings (1), Plucked (1), Organ (1), Voice (1), Synth (11)
console.log(instrumentGroups('zh' )[1].label); // the Chinese label of the mallets group
module 內建樂器組成的七個組,各有幾個樂器(還沒有鼓,所以那一組省略),以及一個中文的組別標籤。
import { instrumentGroups } from '@openav/sound'; 也能從 @openav/sound/registry packages/sound/registry.js 第 118 行
instrumentName()#
函式 @openav/sound instrumentName(def, lang = 'en' )
給一個樂器在某種語言下的顯示名稱,沒有就退回英文。
樂器的 name 可以是字串,也可以是 { en, zh }。這個函式把差別藏起來,所以你自己的選單只要呼叫 instrumentName(def, lang)。
參數 名稱 型別 說明 defObject樂器的定義。null 或 undefined 會得到 ''。 langString'en'、'zh'…。比較長的標記,像 'zh-TW',會用它前兩個字母去找。 可省略 預設 'en'
回傳 String 該語言的名稱,沒有就是英文的。name 只是一個字串的定義就回傳那個字串。
範例
js 複製
import { getInstrument, instrumentName } from '@openav/sound' ;
const bar = getInstrument('glockenspiel' );
console.log(instrumentName(bar), instrumentName(bar, 'zh' )); // Glockenspiel, then its Chinese name
console.log(instrumentName(bar, 'fr' )); // Glockenspiel, English as the fallback
console.log(JSON.stringify(instrumentName(null ))); // ""
module 英文的名字、中文的名字、沒有登記的語言(退回英文),以及什麼都沒有的情況。
import { instrumentName } from '@openav/sound'; 也能從 @openav/sound/registry packages/sound/registry.js 第 111 行
listInstruments()#
函式 @openav/sound listInstruments({ category = null } = {})
列出所有登記過的樂器,照登記的順序,或只列某一個類別的。
22 個內建樂器在 @openav/sound 載入的時候就登記了,所以剛打開的頁面列出的就是這些。要一份適合做選單的清單,各組已排好序,用 instrumentGroups() 。
參數 名稱 型別 說明 categoryString在選項 { category } 裡:只列這個類別的樂器('mallets')。 可省略 預設 null
範例
js 複製
import { listInstruments } from '@openav/sound' ;
console.log(listInstruments().length); // 22
console.log(listInstruments({ category: 'mallets' }).map((d) => d.id).join(' ' ));
// xylophone marimba vibraphone glockenspiel music-box
console.log(listInstruments({ category: 'keys' }).map((d) => d.id).join(' ' )); // piano epiano
module 一共有幾個樂器,以及哪些是敲擊琴和鍵盤。
import { listInstruments } from '@openav/sound'; 也能從 @openav/sound/registry packages/sound/registry.js 第 105 行
modalInstrument()#
函式 @openav/sound modalInstrument({ id, name, category = 'mallets' , credit = null , params, defaults = { cutoff: 8000 }, partials, strike = null , damp = 0, pitchDecay = 0.6, transpose = 0, gain = 0, attack = 0.0015, tremolo = null , maxVoices = 40, set = null , })
用資料做出敲擊的音條或音叉類樂器:一串會響然後衰減的泛音,加上一小段代表槌子的雜訊。
木琴的音條、鐵琴的音條和音樂盒的音叉是同樣的物理,只是數字不同:敲一下激發出幾個振動模式,每個模式以自己的音高倍數響,用自己的速度消失。所以這裡的敲擊類樂器是資料,不是程式。內建的木琴就這麼短:
js
modalInstrument({
id: 'xylophone' , name: { en: 'Xylophone' , zh: '木琴' }, transpose: 12,
partials: [{ ratio: 1, gain: 1, decay: 0.9 }, { ratio: 3, gain: 0.32, decay: 0.32 }, { ratio: 6.1, gain: 0.1, decay: 0.1 }],
strike: { ratio: 5, q: 1.1, gain: 0.35, decay: 0.012 },
pitchDecay: 0.75,
});打得越用力越亮:高的泛音會隨力度長得比較快,真的槌子就是這樣。每個聲部都是每敲一下就建一組的原生 Web Audio 節點,用完交給垃圾回收。
參數 名稱 型別 說明 idString樂器的 id(見 registerInstrument() )。 nameString | Object名稱:字串,或 { en, zh }。 categoryString選單的組別。 可省略 預設 'mallets' creditString | Object出處說明,字串或 { text, url }。 可省略 預設 null paramsObject[]它自己的參數:[{ key, label, min, max, def }]。由 set 函式處理。 可省略 defaultsObject共用參數的起始值。預設把濾波器打開,因為敲擊的音條聲音是亮的。 可省略 預設 { cutoff: 8000 } partialsObject[]音條的各個振動模式:[{ ratio, gain, decay }]。ratio 是頻率相對於音符的倍數(1 是音符本身,3 是高一個十二度),gain 是相對於其他泛音的音量,decay 是在中央 C 衰減到幾乎無聲要幾秒。高音比低音消失得快。高於取樣率 45% 的泛音會被略過。必須給:沒給的話會丟出錯誤。 strikeObject槌子,幾毫秒的帶通雜訊:{ ratio, q, gain, decay },ratio 是它的音高相對於音符的倍數,q 是寬度,gain 是音量,decay 是長度,單位是秒。 可省略 預設 null dampNumber放開鍵之後幾秒內把音條止音。0 是讓音條自由響完(木琴、鐵琴)。顫音琴有止音器。 可省略 預設 0 pitchDecayNumber高音比低音消失得快多少。衰減時間會乘上 (261.63 / 頻率) ^ pitchDecay,限制在 0.2 到 3 之間。0 讓每個音一樣長。 可省略 預設 0.6 transposeNumber把演奏的音移幾個半音(木琴高一個八度:12)。 可省略 預設 0 gainNumber樂器的音量,單位是 dB。 可省略 預設 0 attackNumber泛音達到全音量要幾秒。 可省略 預設 0.0015 tremoloObject所有聲部一起的音量起伏,也就是顫音琴的馬達:{ frequency, depth, spread }。 可省略 預設 null maxVoicesNumber最多能同時響幾個音。超過的時候,最舊的會在 30 毫秒內淡出。 可省略 預設 40 setFunctionset({ key, value, tremolo, Tone }),樂器自己的參數改變時呼叫,好讓它改顫音或別的東西。 可省略 預設 null
範例
js 複製
import { modalInstrument, registerInstrument, validateInstrument, getInstrument, unregisterInstrument } from '@openav/sound' ;
const bar = modalInstrument({
id: 'me/steel-bar' , name: { en: 'Steel bar' , zh: '鋼片' },
partials: [{ ratio: 1, gain: 1, decay: 2.5 }, { ratio: 2.76, gain: 0.4, decay: 1.2 }],
strike: { ratio: 6, q: 1, gain: 0.3, decay: 0.01 },
});
console.log(bar.id, bar.category, bar.tail, validateInstrument(bar)); // me/steel-bar mallets 3.95 []
registerInstrument(bar); // now it is in every picker, under Mallets
console.log(getInstrument('me/steel-bar' ) === bar); // true
unregisterInstrument('me/steel-bar' );
module 兩個泛音加一個槌子,就做出一個鋼片樂器,驗證通過也能登記。要等發聲引擎真的播它的時候,它才會被建出來、被聽見。
import { modalInstrument } from '@openav/sound'; packages/sound/instruments/modal.js 第 20 行
mountSoundPicker()#
函式 @openav/sound mountSoundPicker(container, sound, { lang = (globalThis.document?.documentElement?.lang || 'en' ).toLowerCase().startsWith('zh' ) ? 'zh' : 'en' , className = 'oav-sound-pick' , label = null , enableOnPick = true , } = {})
在頁面上放一個樂器選單:每個類別一組,取樣樂器有載入進度條,也會顯示樂器要求的出處說明。
這是原生的 <select>,每個類別(CATEGORIES )一個 <optgroup>,所以用鍵盤、在手機上都能用,不用做任何樣式就能用。它列出所有已登記的樂器,而且在有樂器被登記或移除時自動更新(onInstrumentsChange() )。選了一個就呼叫 sound.setInstrument(id, { remember: true })。取樣樂器載入的時候顯示進度條和百分比。載入失敗會說出來,並說明改播哪個樂器。有出處說明的樂器,選單底下會出現一行:Salamander 鋼琴是 CC-BY,它的出處說明一定會顯示。
參數 名稱 型別 說明 containerHTMLElement要放選單的元素。 soundSound要控制的 Sound 。選單會跟著它的樂器和載入狀態走。 lang'en' | 'zh'標籤和樂器名稱的語言。預設是頁面的 <html lang> 以 zh 開頭就用 'zh',否則用 'en'。 可省略 預設 from <html lang> classNameString選單的 class。各個部分會在它後面加上 -row、-label、-status、-meter、-credit,讓你自己寫樣式。選單本身不帶 CSS。 可省略 預設 'oav-sound-pick' labelString選單前面的標籤。沒給的話,依語言是 "instrument" 或「樂器」。 可省略 預設 null enableOnPickBoolean選了樂器之後,如果聲音還沒開,就順便呼叫 sound.enable()。選擇本身就是一次點擊,正是瀏覽器要的那個動作。 可省略 預設 true
回傳 Object { el, select, refresh(), dispose() }:它做出來的元素、裡面的 <select>、重建清單的函式,和把它拆掉的函式。
範例
js 複製
import { mountSoundPicker, registerInstrument, unregisterInstrument, Sound } from '@openav/sound' ;
import { Signals } from '@openav/core' ;
const engine = { // a stand-in engine with instruments, so nothing is loaded
instrument: 'synth/pad' , params: [], enable() {}, noteOn() {}, noteOff() {}, set () {},
setInstrument: async (id) => { engine.instrument = id; return true ; }, subscribe: () => () => {},
};
const sound = new Sound({ signals: new Signals(), engine, remember: false });
const picker = mountSoundPicker(document.getElementById('app' ), sound, { lang: 'en' });
console.log(picker.select.options.length, picker.select.value); // 22 synth/pad
registerInstrument({ id: 'me/kalimba' , name: 'Kalimba' , category: 'mallets' , create: () => ({ noteOn() {}, noteOff() {}, dispose() {} }) });
console.log(picker.select.options.length); // 23: the menu refreshed itself
unregisterInstrument('me/kalimba' );
module 頁面上出現一個選單,22 個內建樂器分組列在裡面。再登記一個,它馬上就加進選單。
import { mountSoundPicker } from '@openav/sound'; 也能從 @openav/sound/picker packages/sound/picker.js 第 18 行
onInstrumentsChange()#
函式 @openav/sound onInstrumentsChange(cb)
每當有樂器被登記或移除,就呼叫你的函式,讓選單可以更新。回傳一個停止監聽的函式。
mountSoundPicker() 就是這樣在聽,所以選單蓋好之後才登記的樂器也會出現。用 { replace: true } 取代也會通報 register。對不存在的 id 呼叫 unregisterInstrument() 不會通報。
參數 名稱 型別 說明 cbFunctioncb({ type, id }),type 是 'register' 或 'unregister'。裡面出錯只會被記在 console,不會影響其他監聽者。
範例
js 複製
import { onInstrumentsChange, registerInstrument, unregisterInstrument } from '@openav/sound' ;
const seen = [];
const stop = onInstrumentsChange((e) => seen.push(e.type + ' ' + e.id));
const def = { id: 'me/x' , name: 'X' , category: 'other' , create: () => ({ noteOn() {}, noteOff() {}, dispose() {} }) };
registerInstrument(def);
unregisterInstrument('me/x' );
stop();
registerInstrument(def); unregisterInstrument('me/x' ); // nobody is listening now
console.log(seen.join(' | ' )); // register me/x | unregister me/x
module 聽的時候記下兩次變化,然後停止監聽,顯示接下來的兩次不會被通報。
import { onInstrumentsChange } from '@openav/sound'; 也能從 @openav/sound/registry packages/sound/registry.js 第 129 行
registerInstrument()#
函式 @openav/sound registerInstrument(def, { replace = false } = {})
把一個聲音加到頁面上每個選單:一個 id、一個名字、一個類別,和一個建立樂器的 create() 函式。
能選的聲音就是一份資料加一個工廠函式。登記它就夠了:在開始之前匯入那個檔案,頁面上每個 mountSoundPicker() 就會把它列在它的類別底下,不用改套件。登記表放在 globalThis 上,所以同一個頁面上有兩份這個模組也共用同一份清單。內建的樂器只會填空位:先登記你自己的 'piano',就會保留你的。定義如下:
text
id 小寫字母、數字和 "-",最多一個 "/":'kalimba'、'me/kalimba'
name 一個字串,或 { en, zh }
category keys · mallets · strings · plucked · organ · voice · synth · drums · other,或你自己的
create create(Tone, ctx) → { noteOn(note, vel01, time), noteOff(note, time), set?(key, v),
releaseAll?(time), ready?: Promise, dispose() }
params 你自己的旋鈕 [{ key, label, min, max, def }],播放時顯示成 sound/<key>
defaults 共用參數的起始值:{ cutoff, space, volume }
credit 一個字串,或 { text, url },顯示在選單裡
gain 音量,單位 dB transpose 半音數 tail 換樂器之後舊的可以再響幾秒
fallback 如果 `ready` 失敗,要改播的樂器 idcreate(Tone, ctx) 在啟用的時候、以及每次切換到這個樂器的時候執行,那時 Tone.js 已經載入。ctx 是 { output, rawOutput, rawContext, baseUrl, options, onProgress }:把 Tone 的節點接到 output(原生 Web Audio 節點接到 rawOutput),baseUrl 是取樣資料夾的位置,options 是 toneEngine({ instruments: { <id>: … } }) 傳進來的東西,onProgress(0..1) 用來顯示載入進度。如果 ready 是 promise,選單會顯示進度,而且在它 resolve 之前舊的聲音繼續播。從資料做敲擊聲的樂器可以用 modalInstrument() ,是有設定的 Tone 合成器就用 registerSynthPreset() 。
參數 名稱 型別 說明 defObject樂器的定義,見下面的表。 replaceBoolean在選項 { replace } 裡:允許取代同樣 id 的樂器。沒有它的話,重複的 id 會丟出錯誤。 可省略 預設 false
範例
js 複製
import { registerInstrument, getInstrument, listInstruments, unregisterInstrument } from '@openav/sound' ;
const def = {
id: 'me/kalimba' , name: { en: 'Kalimba' , zh: '拇指琴' }, category: 'mallets' ,
create: () => ({ noteOn() {}, noteOff() {}, dispose() {} }), // a silent stand-in for the example
};
registerInstrument(def);
console.log(getInstrument('me/kalimba' ) === def); // true
console.log(listInstruments({ category: 'mallets' }).map((d) => d.id).join(' ' ));
// xylophone marimba vibraphone glockenspiel music-box me/kalimba
try { registerInstrument(def); } catch (e) { console.log(e.message); }
// [sound] instrument "me/kalimba" is already registered; pass { replace: true } to swap it on purpose
registerInstrument({ ...def, name: 'Kalimba 2' }, { replace: true }); // on purpose
unregisterInstrument('me/kalimba' );
module 登記一個樂器、在它的類別裡找到它、重複登記同一個 id 會被拒絕,再用 replace 刻意換掉。
js 複製
import { registerInstrument } from '@openav/sound' ;
registerInstrument({
id: 'kalimba' ,
name: { en: 'Kalimba' , zh: '拇指琴' },
category: 'mallets' ,
params: [{ key: 'decay' , label: 'Decay (s)' , min: 0.2, max: 4, def: 1.2 }], // becomes sound/decay
create(Tone, { output }) {
const synth = new Tone.PolySynth(Tone.FMSynth, {
harmonicity: 5.1, modulationIndex: 2,
envelope: { attack: 0.001, decay: 1.2, sustain: 0, release: 1 },
}).connect(output);
const hz = (note) => 440 * 2 ** ((note - 69) / 12);
return {
noteOn: (note, vel, time) => synth.triggerAttack(hz(note), time, vel),
noteOff: (note, time) => synth.triggerRelease(hz(note), time),
set : (key, value) => key === 'decay' && synth.set ({ envelope: { decay: value } }),
dispose: () => synth.dispose(),
};
},
});
static 一個完整的樂器放在一個檔案裡:一個有自己旋鈕的 FM 合成器。在聲音開始之前匯入它,每個選單的「敲擊琴」底下就會出現 Kalimba。
import { registerInstrument } from '@openav/sound'; 也能從 @openav/sound/registry packages/sound/registry.js 第 86 行
registerSynthPreset()#
函式 @openav/sound registerSynthPreset(preset, opts)
把一個合成器預設加到每個選單,名稱是 synth/ 加上它的 id:把聲部、包絡和效果寫成資料就好。
一個預設只有這些:一個 voice、用 Tone.js 的詞彙寫的設定,和選用的效果鏈。id 是小寫字母、數字和 -(會變成 synth/<id>),name 是字串或 { en, zh }。voice 是 'synth'(振盪器加包絡)、'mono'(多一個有自己包絡的共振濾波器)、'fm' 或 'am'。Tone 的設定有 oscillator、envelope、filter、filterEnvelope、harmonicity、modulationIndex、modulation、modulationEnvelope、detune、portamento。其他的:octave(整數個八度,貝斯用 -1)、gain(dB,用來跟別的樂器配音量)、polyphony、defaults(起始的 cutoff、space、volume)、params、category、credit,跟 registerInstrument() 一樣。
effects 是一串 { type, …設定 },聲音依序通過它們。種類有 chorus、delay、pingpong、distortion、vibrato、tremolo、phaser、autofilter、filter、eq、compressor、chebyshev、widener。其他的鍵是那個效果自己的 Tone 設定,包括 wet。不合格的預設會丟出 TypeError 並說明原因。
參數 名稱 型別 說明 presetObject預設:{ id, name, voice, … },見下面。 optsObjectregisterInstrument() 的選項:{ replace: true } 會換掉現有的。 可省略
範例
js 複製
import { registerSynthPreset, getInstrument, unregisterInstrument } from '@openav/sound' ;
const def = registerSynthPreset({
id: 'glass' , name: { en: 'Glass' , zh: '玻璃' },
voice: 'fm' , harmonicity: 3.01, modulationIndex: 14,
envelope: { attack: 0.002, decay: 1.4, sustain: 0, release: 1.2 },
effects: [{ type: 'chorus' , frequency: 1.5, depth: 0.4, wet: 0.3 }],
});
console.log(def.id, getInstrument('synth/glass' ) === def); // synth/glass true: it is in every picker now
unregisterInstrument('synth/glass' );
module 把一個像 FM 鐘的聲音當作單純的資料登記。它會以「Glass」出現在選單的 Synth 組裡。
import { registerSynthPreset } from '@openav/sound'; packages/sound/instruments/synth.js 第 180 行
SALAMANDER_NOTES#
常數 @openav/sound SALAMANDER_NOTES = array, 30 items
Salamander 鋼琴有取樣的 30 個音:從 A0(MIDI 21)到 C8(108),每隔一個小三度一個。
取樣器把中間的音升降最多一個半音半,在鋼琴上聽不出來。只有一個力度層,所以大聲和小聲是音量曲線,不是不同的琴槌。
型別 Number[][21, 24, 27, …, 108]:30 個 MIDI 音符號碼,彼此相隔三個半音(每個八度的 A、C、D♯、F♯)。
範例
js 複製
import { SALAMANDER_NOTES } from '@openav/sound' ;
console.log(SALAMANDER_NOTES.length, SALAMANDER_NOTES[0], SALAMANDER_NOTES.at(-1)); // 30 21 108
console.log(SALAMANDER_NOTES.slice(0, 4).join(' ' )); // 21 24 27 30
module 數量、最低和最高的音,以及最前面四個。
相關 salamanderMap() 做出 Salamander 鋼琴給 Tone.Sampler 用的取樣對照表:{ 'A0': 'A0.mp3', 'D#1': 'Ds1.mp3', … }。 SAMPLES_URL 沒有人指定時,內建取樣組所在資料夾的位址:openaudiovisual.com/packages/sound/samples/。 import { SALAMANDER_NOTES } from '@openav/sound'; packages/sound/instruments/piano.js 第 20 行
salamanderMap()#
函式 @openav/sound salamanderMap(notes = SALAMANDER_NOTES)
做出 Salamander 鋼琴給 Tone.Sampler 用的取樣對照表:{ 'A0': 'A0.mp3', 'D#1': 'Ds1.mp3', … }。
鋼琴樂器用它從 baseUrl + 'salamander/' 抓檔案。想把同樣的檔案載入你自己的取樣器時可以用它。
參數 名稱 型別 說明 notesNumber[]要包含哪些 MIDI 音符。預設是 SALAMANDER_NOTES ,全部 30 個。 可省略 預設 SALAMANDER_NOTES
回傳 Object 一個物件,從 Tone 的音名對應到檔名。檔名把升記號寫成 "s":D#1 是 Ds1.mp3。
範例
js 複製
import { salamanderMap, SAMPLES_URL } from '@openav/sound' ;
const map = salamanderMap();
console.log(Object.keys(map).length, map['A0' ], map['D#1' ]); // 30 A0.mp3 Ds1.mp3
console.log(salamanderMap([60, 63])); // { C4: 'C4.mp3', 'D#4': 'Ds4.mp3' }
console.log(SAMPLES_URL + 'salamander/' + map['C4' ]); // the address of one file
module 完整的對照表有 30 個檔案。只有兩個音的對照表。以及一個取樣的完整位址。
import { salamanderMap } from '@openav/sound'; packages/sound/instruments/piano.js 第 23 行
SAMPLES_URL#
常數 @openav/sound SAMPLES_URL = "https://openaudiovisual.com/packages/sound/samples/"
沒有人指定時,內建取樣組所在資料夾的位址:openaudiovisual.com/packages/sound/samples/。
它是 toneEngine() 的 baseUrl 預設值。鋼琴從它底下的 salamander/ 資料夾讀 30 個 mp3 檔。伺服器有送 CORS 標頭,所以任何頁面都能載入。createShow() 會改指向頁面旁邊的那一份。要用自己的備份,就給 baseUrl,只想換鋼琴的話給 instruments: { piano: { baseUrl } }。
型別 String'https://openaudiovisual.com/packages/sound/samples/',以斜線結尾。
範例
js 複製
import { SAMPLES_URL, toneEngine } from '@openav/sound' ;
console.log(SAMPLES_URL); // https://openaudiovisual.com/packages/sound/samples/
const local = toneEngine({ instrument: 'piano' , baseUrl: '/my/samples/' }); // the piano then reads /my/samples/salamander/…
module 印出預設資料夾,並示範怎麼讓引擎指向你自己的。
相關 toneEngine() 發聲引擎,用 Tone.js 播放登記好的樂器,聲音依序經過濾波器、殘響和限幅器。 TONE_CDN 引擎啟用時載入 Tone.js 的位址:jsDelivr 上的 Tone 15.0.4。 salamanderMap() 做出 Salamander 鋼琴給 Tone.Sampler 用的取樣對照表:{ 'A0': 'A0.mp3', 'D#1': 'Ds1.mp3', … }。 import { SAMPLES_URL } from '@openav/sound'; packages/sound/index.js 第 57 行
new Sound({ signals, params = null , engine, instrument = null , remember = true , picker = true })
讓匯流排上每個 midi/note/on 和 midi/note/off 都透過發聲引擎聽得見,並把引擎的參數放到匯流排上,名稱是 sound/*。
Sound 是把引擎接進應用程式的外殼。await sound.enable() 之後,匯流排上每個 midi/note/on 和 midi/note/off 都會被播出來,不管是誰送的:MIDI 鍵盤、螢幕鋼琴(mountKeys() )、SimPlayer 、TakePlayer 或手機。在引擎的 skipChannels 裡的頻道的音符不會播(Tone 引擎略過頻道 10,那是鼓機的)。
引擎的參數會變成 sound/* 參數,所以旋鈕、時間軸或一隻手可以像操作畫面那樣操作濾波器:聲音是可以演奏的狀態,不是附帶的效果。每個畫面用解析好的參數值呼叫 sound.update(state),其中 sound/* 的就會被送進引擎。樂器是離散的選擇(setInstrument() 或選單),要連續演奏的東西則維持是參數。
呼叫之前
要在點擊裡呼叫 sound.enable():瀏覽器要使用者做過一個動作才讓聲音開始。用 toneEngine() 的話,第一次還會從網路(jsDelivr)載入 Tone.js。enable() 之前什麼都聽不到,匯流排也沒有被聽。
參數 名稱 型別 說明 signalsSignalsSignals 匯流排。Sound 聽 midi/note/on { note, vel, ch }、midi/note/off { note, ch } 和 midi/cc/64(延音踏板,0.5 以上算踩下),並在樂器改變時發出 sound/instrument { id, name } 脈衝。 paramsParams要把引擎的參數加進去的 Params,名稱是 sound/<key>(sound/cutoff、sound/space、sound/volume,和樂器自己的)。沒給的話,不註冊任何參數。 可省略 預設 null engineObject發聲引擎,例如 toneEngine() 或 drumEngine() 。約定是 { params, enable(), noteOn(note, vel01), noteOff(note), set(key, value), dispose() },選用的有 setInstrument、instrument、status、subscribe、sustain(down)、skipChannels。 instrumentString一開始用哪個樂器,給有樂器的引擎用('piano'、'synth/pluck')。不認識的 id 會在 console 警告,並被忽略。 可省略 預設 null rememberBoolean | String把選單最後選的樂器依頁面記在 localStorage,下一次它會贏過 instrument。給字串就是你自己的儲存鍵。預設的鍵是 openav:sound:<頁面路徑>。false 是關掉。用程式做的選擇不會被記住,除非你要求(setInstrument(id, { remember: true }))。 可省略 預設 true pickerBoolean控台要不要顯示樂器選單。它被存成 sound.picker,給建介面的人讀。Sound 自己不畫任何東西。 可省略 預設 true
成員 名稱 說明 enable()method 啟動引擎並開始聽匯流排。回傳 true 的 promise。要在點擊裡呼叫。再呼叫一次會回傳同一個 promise。失敗了可以再試。會對 onChange 的監聽者發出 { type: 'enabled', id }。 update(state)method 每個畫面一次:把 state 裡每個 sound/<key> 的值送進引擎的 set(key, value)。state 的其他鍵會被忽略。enable() 之前什麼都不做。 instrumentgetter Getter。目前樂器的 id('piano'),引擎沒有樂器(例如鼓組)的話是 null。 statusgetter Getter。{ id, state, progress, error }:state 是 'idle'、'loading'、'ready' 或 'error',取樣樂器載入的時候 progress 是 0..1。 choosablegetter Getter。引擎有沒有樂器可以選。 setInstrument(id, { remember = false } = {})method 現場換樂器:setInstrument(id, { remember = false })。舊樂器按著的音會被放開,新的樂器準備好之後接手(取樣樂器在檔案載入的時候,還是用舊的聲音)。回傳 promise:樂器開始發聲是 true,id 不認得、引擎沒有樂器,或這個選擇被更新的選擇取代則是 false。remember: true 會把這個選擇記給這個頁面。 onChange(cb)method 聽發生了什麼事:cb({ type, id, progress, error }),type 是 'instrument'、'loading'、'ready'、'error' 或 'enabled'。回傳一個停止聽的函式。 dispose()method 不再聽匯流排、移除監聽者,並釋放引擎。
範例
js 複製
import { Sound } from '@openav/sound' ;
import { Signals } from '@openav/core' ;
const signals = new Signals();
const heard = [];
const engine = { // the smallest engine: it only writes down what it is told
params: [{ key: 'cutoff' , label: 'Cutoff' , min: 100, max: 8000, def: 2500 }],
async enable() {},
noteOn: (note, vel) => heard.push('on ' + note + ' ' + vel),
noteOff: (note) => heard.push('off ' + note),
set : (key, value) => heard.push(key + '=' + value),
dispose() {},
};
const sound = new Sound({ signals, engine });
await sound.enable();
signals.pulse('midi/note/on' , { note: 60, vel: 0.8, ch: 1 });
signals.pulse('midi/note/off' , { note: 60, ch: 1 });
sound.update({ 'sound/cutoff' : 900, hue: 0.3 }); // only the sound/* keys go to the engine
console.log(heard.join(', ' )); // on 60 0.8, off 60, cutoff=900
console.log(sound.enabled, sound.choosable, sound.instrument); // true false null
module 一個匯流排加一個替身引擎:匯流排上的音符傳到引擎,state 裡只有 sound/cutoff 會傳到。這個引擎沒有樂器,所以 choosable 是 false。
js 複製
import { Signals } from '@openav/core' ;
import { mountKeys } from '@openav/keys' ;
import { Sound, toneEngine, mountSoundPicker } from '@openav/sound' ;
const signals = new Signals();
const sound = new Sound({ signals, engine: toneEngine({ instrument: 'piano' }) });
mountKeys(document.getElementById('app' ), { signals }); // an on-screen piano that publishes midi/note/on
mountSoundPicker(document.getElementById('app' ), sound); // choosing an instrument also turns the sound on
document.body.addEventListener('click' , () => sound.enable(), { once: true });
sound.onChange((e) => console.log(e.type, e.id));
static 在任何地方點一下開始發聲,然後點鋼琴的琴鍵:你會聽到 Salamander 平台鋼琴。彈的時候可以從選單換別的樂器。
待確認
這一頁沒有實際跑過 Tone.js 引擎:這裡沒有網路也沒有喇叭。寫在這裡的內容來自程式碼,和套件的測試(測試用一個替身代替 Tone.js),能跑的範例用的是替身引擎。
import { Sound } from '@openav/sound'; packages/sound/index.js 第 90 行
SOUND_PARAMS#
常數 @openav/sound SOUND_PARAMS = array, 3 items
每個樂器共用的三個參數:cutoff(濾波器)、space(殘響)和 volume(音量)。
cutoff 是低通濾波器的頻率,單位是 Hz,space 是混進去多少殘響(濕度,0..1),volume 是音量,單位是 dB。它們在輸出鏈上排在樂器之後,所以換樂器的時候,時間軸、旋鈕或一隻手可以繼續演奏它們。在演出裡它們是參數 sound/cutoff、sound/space、sound/volume。樂器可以用它的 defaults 建議不同的起始值,自動化和覆寫永遠贏過建議。樂器自己的 params 不能用這幾個鍵。
型別 Object[][{ key: 'cutoff', min: 100, max: 8000, def: 2500 }, { key: 'space', min: 0, max: 1, def: 0.3 }, { key: 'volume', min: -36, max: 0, def: -8 }],每個還有一個 label。
範例
js 複製
import { SOUND_PARAMS } from '@openav/sound' ;
console.log(SOUND_PARAMS.map((p) => p.key + ' ' + p.min + '..' + p.max + ' (' + p.def + ')' ).join(' | ' ));
// cutoff 100..8000 (2500) | space 0..1 (0.3) | volume -36..0 (-8)
module 印出每個共用參數的範圍和預設值。
相關 toneEngine() 發聲引擎,用 Tone.js 播放登記好的樂器,聲音依序經過濾波器、殘響和限幅器。 Sound 讓匯流排上每個 midi/note/on 和 midi/note/off 都透過發聲引擎聽得見,並把引擎的參數放到匯流排上,名稱是 sound/*。 registerInstrument() 把一個聲音加到頁面上每個選單:一個 id、一個名字、一個類別,和一個建立樂器的 create() 函式。 import { SOUND_PARAMS } from '@openav/sound'; 也能從 @openav/sound/registry packages/sound/registry.js 第 24 行
soundOptions()#
函式 @openav/sound soundOptions(value)
把 createShow() 的 modules.sound 選項(true、樂器 id 或物件)讀成一個整理好的物件。
createShow({ modules: { sound: … } }) 就是靠它理解選項,所以 sound: true、sound: 'piano' 和 sound: { instrument: 'organ', remember: false, picker: false } 都行。只有你要寫收同樣選項的東西時才需要它。
參數 名稱 型別 說明 valueBoolean | String | Objecttrue 是預設,樂器 id('piano'),或 { instrument, remember, picker, engine, …toneEngine 的選項 }。任何假值會得到 null。
回傳 Object | null { engine, instrument, remember, picker, engineOptions }(沒給的 engine 和 instrument 是 null,remember 和 picker 是 true),假值則是 null。engineOptions 放物件裡其餘的東西:baseUrl、cdn、instruments…。instrument 不是字串,或給了別種值,會丟出 TypeError。
範例
js 複製
import { soundOptions } from '@openav/sound' ;
console.log(soundOptions('piano' ).instrument); // piano
console.log(soundOptions({ instrument: 'organ' , remember: false , baseUrl: 'x/' }));
// { engine: null, instrument: 'organ', remember: false, picker: true, engineOptions: { baseUrl: 'x/' } }
console.log(soundOptions(false )); // null
try { soundOptions([]); } catch (e) { console.log(e.message); }
// modules.sound: true, an instrument id, or { instrument, remember, picker, engine, baseUrl, cdn, instruments }
module 三種接受的形式、關掉聲音的假值,以及什麼都不是的東西會得到的錯誤。
相關 Sound 讓匯流排上每個 midi/note/on 和 midi/note/off 都透過發聲引擎聽得見,並把引擎的參數放到匯流排上,名稱是 sound/*。 toneEngine() 發聲引擎,用 Tone.js 播放登記好的樂器,聲音依序經過濾波器、殘響和限幅器。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 import { soundOptions } from '@openav/sound'; packages/sound/index.js 第 74 行
SYNTH_PRESETS#
常數 @openav/sound SYNTH_PRESETS = array, 11 items
11 個合成器預設,是單純的資料:pad、saw lead、square lead、pluck、supersaw、bass、acid、brass、bell、sub、chip。
每個預設都會被 synthInstrument() 變成一個樂器 synth/<id>。它們是用 Tone.js 自己的詞彙寫的資料(oscillator、envelope、filter、filterEnvelope、harmonicity…),要為 registerSynthPreset() 寫自己的預設時,是個好的參考。pad 是框架最早的聲音:三個失諧的鋸齒波,起音快、釋放長。
型別 Object[]一個有 11 個預設的陣列 { id, name: { en, zh }, voice, … }:pad、saw-lead、square-lead、pluck、supersaw、bass、acid、brass、bell、sub、chip。
範例
js 複製
import { SYNTH_PRESETS } from '@openav/sound' ;
console.log(SYNTH_PRESETS.map((p) => p.id).join(' ' ));
// pad saw-lead square-lead pluck supersaw bass acid brass bell sub chip
const bass = SYNTH_PRESETS.find((p) => p.id === 'bass' );
console.log(bass.voice, bass.octave, bass.oscillator.type); // mono -1 fatsawtooth
module 列出預設的 id,並讀貝斯的幾個設定:有濾波器的單音合成器,降一個八度。
import { SYNTH_PRESETS } from '@openav/sound'; packages/sound/instruments/synth.js 第 30 行
synthInstrument()#
函式 @openav/sound synthInstrument(preset)
把一個合成器預設變成樂器定義,id 是 synth/ 加上預設的 id,但不登記它。
這個定義的 create() 會建一個 Tone.PolySynth,聲部是預設的 voice(synth、mono、fm 或 am),帶著預設的 Tone 設定、一串 effects 效果,以及它的 polyphony(沒給就是 16 個聲部)。想自己拿到這個定義(登記前先調整)時才用它。否則 registerSynthPreset() 兩步一起做完。
回傳 Object 一個樂器定義:id 是 'synth/' + preset.id,name 用預設的(沒有就用它的 id),category 用預設的或 'synth',transpose 是 octave × 12,gain、defaults、params、credit 來自預設,tail 是 min(8, release × 1.5 + 0.5) 秒,preset 是預設本身。
範例
js 複製
import { synthInstrument, validateInstrument } from '@openav/sound' ;
const def = synthInstrument({ id: 'glass' , name: 'Glass' , voice: 'fm' , octave: -1, gain: -3, envelope: { release: 2 } });
console.log(def.id, def.category, def.transpose, def.gain, def.tail); // synth/glass synth -12 -3 3.5
console.log(validateInstrument(def)); // []
try { synthInstrument({ id: 'Glass!' }); } catch (e) { console.log(e instanceof TypeError); } // true
module 一個預設變成合格的樂器定義:降一個八度是 12 個半音,尾巴長度跟著釋放時間走。壞的 id 會丟出錯誤。
import { synthInstrument } from '@openav/sound'; packages/sound/instruments/synth.js 第 141 行
TONE_CDN#
常數 @openav/sound TONE_CDN = "https://cdn.jsdelivr.net/npm/tone@15.0.4/+esm"
引擎啟用時載入 Tone.js 的位址:jsDelivr 上的 Tone 15.0.4。
toneEngine() 在 enable() 時匯入的模組網址。想自己放 Tone.js 的話,把你的網址當作 toneEngine() 的 cdn 選項。
型別 String字串 'https://cdn.jsdelivr.net/npm/tone@15.0.4/+esm'。
範例
js 複製
import { TONE_CDN, toneEngine } from '@openav/sound' ;
console.log(TONE_CDN); // https://cdn.jsdelivr.net/npm/tone@15.0.4/+esm
const own = toneEngine({ cdn: '/vendor/tone.mjs' }); // your own copy, used when enable() runs
module 印出預設位址,並示範自己的備份要寫在哪裡。
相關 toneEngine() 發聲引擎,用 Tone.js 播放登記好的樂器,聲音依序經過濾波器、殘響和限幅器。 SAMPLES_URL 沒有人指定時,內建取樣組所在資料夾的位址:openaudiovisual.com/packages/sound/samples/。 import { TONE_CDN } from '@openav/sound'; packages/sound/index.js 第 54 行
toneEngine()#
函式 @openav/sound toneEngine({ cdn = TONE_CDN, instrument = DEFAULT_INSTRUMENT, baseUrl = SAMPLES_URL, instruments: perInstrument = {}, drumChannel = 10, } = {})
發聲引擎,用 Tone.js 播放登記好的樂器,聲音依序經過濾波器、殘響和限幅器。
Tone.js 只是鄰居,不是相依套件:第一次 enable() 的時候才從網路載入,所以從不開聲音的應用程式什麼代價都不用付。enable() 之後,樂器的輸出會經過低通濾波器(cutoff,起始 2500 Hz)、殘響(space,濕度,起始 0.3)、音量(volume,單位 dB,起始 -8)和 -1 dB 的限幅器,所以很大聲的風琴也不會把房間削波。這三個就是每個樂器共用的 SOUND_PARAMS 。它們的起始值可以跟著樂器走(鋼琴想要濾波器全開),樂器也可以再加自己的參數。
setInstrument(id) 現場換樂器,回傳換成功與否的 promise。在 enable() 之前它只是記住這個選擇。舊樂器按著的音會被放開,它的尾巴讓它再響幾秒。取樣樂器(鋼琴)在檔案進來之前會繼續用舊的聲音。如果載入失敗,引擎會播這個樂器的 fallback(鋼琴退回電鋼琴),所以演出不會因為一次下載失敗就沒聲音。subscribe(cb) 會告訴你 instrument、loading、ready、error 事件。sustain(down) 是踏板:放開音的動作要等到踏板抬起來才做。releaseAll() 是緊急停止鈕。
通常你會把引擎交給 Sound ,由它去聽匯流排。單獨使用的話,noteOn(60, 0.8) 就是彈一個音。
呼叫之前
要在點擊裡 await engine.enable():它從網路載入 Tone.js,然後啟動音訊。在那之前 noteOn() 什麼都不做,setInstrument() 只是記住選擇。
參數 名稱 型別 說明 cdnString引擎啟用時從哪裡匯入 Tone.js。預設是 TONE_CDN 。是一個模組網址,所以你可以放自己的備份。 可省略 預設 TONE_CDN instrumentString一開始用的樂器。不認識的 id 會警告,改用 synth/pad。 可省略 預設 'synth/pad' baseUrlString放內建取樣組(salamander/…)的資料夾。預設是 SAMPLES_URL 。 可省略 預設 SAMPLES_URL instrumentsObject給單一樂器的選項,會當作 ctx.options 交給它的 create():{ piano: { baseUrl: '/my/salamander/' } }。 可省略 預設 {} drumChannelNumber | null這個 MIDI 頻道的音符屬於鼓的引擎(鼓機送的是 General MIDI 的頻道 10),這裡不播。null 是全部都播。 可省略 預設 10
回傳 Object 一個引擎:{ params, sharedParams, instrument, status, skipChannels, hasInstrument(id), subscribe(cb), setInstrument(id), enable(), noteOn(note, vel01, time), noteOff(note, time), sustain(down), releaseAll(), set(key, value), dispose() }。
範例
js 複製
import { toneEngine } from '@openav/sound' ;
const engine = toneEngine({ instrument: 'strings' }); // nothing is loaded yet
console.log(engine.instrument, engine.status.state); // strings idle
console.log(engine.params.map((p) => p.key + ' ' + p.def).join(', ' ));
// cutoff 6000, space 0.45, volume -8, attack 0.35, vibrato 0.35 (the shared three, then the strings' own)
console.log(engine.skipChannels, engine.hasInstrument('piano' )); // [10] true
console.log(toneEngine({ drumChannel: null }).skipChannels); // []
module 在引擎啟用之前看它:它的樂器、它會提供的參數(弦樂建議了不同的起始 cutoff 和 space,還多加了兩個自己的),以及頻道 10 是留給鼓的。
js 複製
import { toneEngine } from '@openav/sound' ;
const engine = toneEngine({ instrument: 'epiano' });
document.body.addEventListener('click' , async () => {
await engine.enable(); // loads Tone.js from the network, starts the audio
engine.set ('space' , 0.6); // more reverb
[60, 64, 67].forEach((note) => engine.noteOn(note, 0.8)); // a C major chord
setTimeout(() => engine.releaseAll(), 1500);
}, { once: true });
static 在頁面上點一下:電鋼琴彈一個 C 大調和弦,殘響比平常多,一秒半之後放開。
待確認
這一頁沒有實際跑過 Tone.js 引擎:這裡沒有網路也沒有喇叭。寫在這裡的內容來自程式碼,和套件的測試(測試用一個替身代替 Tone.js),能跑的範例用的是替身引擎。
相關 Sound 讓匯流排上每個 midi/note/on 和 midi/note/off 都透過發聲引擎聽得見,並把引擎的參數放到匯流排上,名稱是 sound/*。 registerInstrument() 把一個聲音加到頁面上每個選單:一個 id、一個名字、一個類別,和一個建立樂器的 create() 函式。 SOUND_PARAMS 每個樂器共用的三個參數:cutoff(濾波器)、space(殘響)和 volume(音量)。 TONE_CDN 引擎啟用時載入 Tone.js 的位址:jsDelivr 上的 Tone 15.0.4。 SAMPLES_URL 沒有人指定時,內建取樣組所在資料夾的位址:openaudiovisual.com/packages/sound/samples/。 drumEngine() 一組用 Web Audio 合成的鼓組,沒有取樣檔:送進 General MIDI 的鼓音符,就打出鼓聲。 import { toneEngine } from '@openav/sound'; packages/sound/index.js 第 226 行
unregisterInstrument()#
函式 @openav/sound unregisterInstrument(id)
移除一個登記過的聲音,給測試和熱重載用。回傳它原本在不在。
選單會自己更新。它不會停掉正在播這個樂器的引擎:要先換掉。
範例
js 複製
import { registerInstrument, unregisterInstrument, getInstrument } from '@openav/sound' ;
registerInstrument({ id: 'me/temp' , name: 'Temp' , category: 'other' , create: () => ({ noteOn() {}, noteOff() {}, dispose() {} }) });
console.log(unregisterInstrument('me/temp' ), getInstrument('me/temp' )); // true null
console.log(unregisterInstrument('me/temp' )); // false
module 移除一個樂器。第二次就沒有東西可以移除了。
import { unregisterInstrument } from '@openav/sound'; 也能從 @openav/sound/registry packages/sound/registry.js 第 96 行
validateInstrument()#
函式 @openav/sound validateInstrument(def)
檢查一個樂器定義,把有問題的地方都列成看得懂的句子。空的清單表示沒問題。
它檢查:id(小寫字母、數字和 -,最多一個 /)、name(字串,或至少有 en 的 { en, zh })、category(字串)、create(函式)、params(每個的 key 是一個單字,min < max 是數字,def 在兩者之間,而且 key 不是共用的那三個之一)、defaults(只能是 cutoff、space、volume,且各自在範圍內)、credit(字串,或 { text, url })、gain、transpose、tail(數字),以及 fallback(另一個樂器的 id)。registerInstrument() 會執行它,並用這些句子丟出錯誤。
回傳 String[] 句子,每個問題一句。沒問題就是 []。它不會丟出錯誤。
範例
js 複製
import { validateInstrument } from '@openav/sound' ;
const ok = { id: 'me/ok' , name: 'OK' , category: 'other' , create() {} };
console.log(validateInstrument(ok)); // []
console.log(validateInstrument({ ...ok, id: 'Piano' , params: [{ key: 'cutoff' , min: 0, max: 1, def: 0 }] }));
// [ 'id "Piano": lowercase letters, digits and "-", at most one "/" (e.g. "synth/pad")',
// 'params[0] (cutoff): "cutoff" is a shared sound param; set its starting value with defaults: { cutoff: … }' ]
console.log(validateInstrument(null )); // [ 'an instrument definition is an object' ]
module 好的定義得到空清單。id 有大寫字母、又有一個已經是共用參數的定義,得到兩句話。
import { validateInstrument } from '@openav/sound'; 也能從 @openav/sound/registry packages/sound/registry.js 第 49 行
validateSynthPreset()#
函式 @openav/sound validateSynthPreset(p)
檢查一個合成器預設,列出有問題的地方。空的清單表示沒問題。
它檢查 id(小寫字母、數字和 -)、voice(synth、mono、fm 或 am)、每個效果的 type 是不是套件認得的,以及 octave 是不是整數。Tone 的設定它不檢查,那是 Tone.js 的事。
回傳 String[] 句子,每個問題一句。沒問題就是 []。
範例
js 複製
import { validateSynthPreset } from '@openav/sound' ;
console.log(validateSynthPreset({ id: 'glass' , voice: 'fm' , effects: [{ type: 'chorus' }] })); // []
console.log(validateSynthPreset({ id: 'Glass' , voice: 'laser' , effects: [{ type: 'zz' }], octave: 1.5 }));
// [ 'id "Glass": lowercase letters, digits and "-" (it becomes "synth/<id>")',
// 'voice "laser": one of synth, mono, fm, am',
// 'effects[0].type "zz": one of chorus, delay, pingpong, distortion, vibrato, tremolo, phaser, autofilter, filter, eq, compressor, chebyshev, widener',
// 'octave: a whole number' ]
module 好的預設得到空清單。有四個錯誤的預設,每個錯誤得到一句話。
import { validateSynthPreset } from '@openav/sound'; packages/sound/instruments/synth.js 第 128 行
鼓機與節奏#
startDrums()、一行字寫節奏、drumPhase;@openav/drums 與 @openav/pattern。 40
oav-p5
beatPhase同一條也包含: OAVp5.drums.beatPhase
鼓機走到這一拍的哪裡,從 0 平滑地增加到快要 1,一小節會跑四次。
一拍是一個四分音符(四格)。1 - beatPhase 在正拍上最大,然後縮小到下一拍,所以 circle(width / 2, height / 2, 60 + (1 - beatPhase) * 80) 會跟著速度脈動,不用任何 callback。drumPhase 是同樣的東西,範圍是整個小節。
型別 Number
範圍 0..1每次讀都是當下的值。正拍上是 0,下一拍之前接近 1,然後回到 0。鼓機還沒播之前是 0,停下來的時候停在原地。
範例
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 120);
noStroke();
}
function draw() {
background(20);
fill(255, 120, 90);
circle(width / 2, height / 2, 60 + (1 - beatPhase) * 100); // biggest on the beat
}
一個圓,一小節脈動四次:每一拍最大,然後縮小到下一拍。
相關 drumPhase 鼓機走到這一小節的哪裡,從 0 平滑地增加到快要 1,是你耳朵聽到的位置。 drumStep 鼓機剛剛播到這一小節 16 格裡的第幾格,從 0 到 15。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 beat 麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。 p5/oav-p5.js 第 1447 行
drawRhythm([x], [y], [w], [h], [style], [o])同一條也包含: OAVp5.drums.draw
把節奏畫在畫布上,樣式可以是格子、圓或螺旋,有播放頭,打擊的時候會亮起來,所以錄影也錄得到。
在 draw() 裡、background() 之後呼叫:drawRhythm(20, 100, 360, 100)。每一列是節奏裡有用到的一種鼓,打擊在你聽到的那一刻亮起來(用的就是 drumPhase 讀的那個時鐘),兩格之間的打擊(例如三連音)會畫在它實際落下的位置。鼓機的卡片是頁面的一部分,錄影錄不到,但錄影錄的是畫布,所以要讓節奏進影片,就用這個。
不給方框的話,'grid' 畫在畫布下方的一條帶子,另外兩種畫在中間的正方形。它用 p5 自己的圖形,包在 push() 和 pop() 之間,所以不會弄亂你設定的樣式,2D 和 WEBGL、p5 1.x 和 2.x 都能用。在 WEBGL 裡原點是畫布中心:drawRhythm(-width / 2, -height / 2, width, height, 'circle') 會鋪滿整個畫布。
參數 名稱 型別 說明 xNumber要畫的方框的左邊。在 WEBGL 裡,畫布中心是 (0, 0)。x、y、w、h 只要有一個沒傳,就用預設的方框。 可省略 yNumber方框的上邊。 可省略 wNumber方框的寬。 可省略 hNumber方框的高。 可省略 styleString | Object'grid'(16 格加一條播放頭)、'circle'(每種鼓一圈 16 格,指針照 drumPhase 轉)或 'spiral'(最近幾小節往外繞,現在在最外面的尖端)。這裡如果給的是物件,就當成 o,style 寫在物件裡面。 可省略 預設 'grid' oObject選項:colors({ kick: '#f66', … },每種鼓一個 p5 看得懂的顏色)、color(全部同一個顏色)、off(空格子的顏色)、playhead(顏色,或 false 表示不畫)、bg(方框的底色)、bars(只用在螺旋,1 到 16,沒寫就是 4)。 可省略
回傳 Boolean 有畫東西就是 true,方框是空的(w 或 h 不大於 0),或沒有 p5 全域模式可以畫的時候是 false。
注意 OAVp5.drums.draw(x, y, w, h, style, o) 是同一個函式,寫成方法的樣子。範例
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 120);
}
function draw() {
background(14, 16, 26);
drawRhythm(20, 100, 360, 100); // the 16 steps with a playhead
}
節奏以格子的樣子畫在畫布上:一條白色的播放頭掃過去,每一下打擊發聲的時候那一格亮起來。
function setup() {
createCanvas(400, 300);
startDrums('bd(3,8), [~ sd]*2, hh*8, ~ ~ ~ cp' , 104);
}
function draw() {
background(14, 16, 26);
drawRhythm(10, 10, 180, 180, 'circle' );
drawRhythm(210, 10, 180, 180, 'spiral' , { bars: 4, colors: { kick: '#ff7b54' } });
drawRhythm(20, 220, 360, 60, 'grid' , { bg: 'rgba(255, 255, 255, 0.05)' });
}
同一個節奏的三種畫法:每種鼓一圈、有指針的圓,最近四小節繞成的螺旋,以及下方的格子。
function setup() {
createCanvas(400, 300, WEBGL);
startDrums('four on floor' , 120);
}
function draw() {
background(14, 16, 26);
drawRhythm(-width / 2, -height / 2, width, height, 'circle' ); // (0, 0) is the middle in WEBGL
}
在 WEBGL 畫布裡方框要從 (-width / 2, -height / 2) 開始,因為 (0, 0) 是畫布中心。
p5/oav-p5.js 第 1871 行
drumBpm同一條也包含: OAVp5.drums.bpm
鼓機的速度,單位是每分鐘幾拍。
一拍是一個四分音符,也就是四格,所以一格長 60 / drumBpm / 4 秒,一小節長 240 / drumBpm 秒:速度 120 的時候一小節是 2 秒。要改速度就呼叫 startDrums(bpm) :startDrums(drumBpm + 5) 就是加快 5。自己對 drumBpm 賦值不會有任何作用。
startDrums() 會把速度限制在 30 到 300 之間,格子上的滑桿則是從 60 到 180。
型別 Number
範圍 30..300在你設定別的之前是 112。它會跟著 startDrums() 和格子上的速度滑桿走。
注意 如果草圖本來就宣告了自己的 drumBpm 變數,就用你的:oav-p5 不動這個名稱,並用一張卡片提醒你改讀 OAVp5.drums.bpm。 範例
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 100);
textAlign(CENTER, CENTER);
textSize(32);
}
function keyPressed() {
if (keyCode === UP_ARROW) startDrums(drumBpm + 5);
if (keyCode === DOWN_ARROW) startDrums(drumBpm - 5);
}
function draw() {
background(20);
fill(255);
text(round(drumBpm) + ' bpm' , width / 2, height / 2);
}
按上、下方向鍵,每次把速度加減 5,數字會顯示出來。
相關 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 drumPhase 鼓機走到這一小節的哪裡,從 0 平滑地增加到快要 1,是你耳朵聽到的位置。 beatPhase 鼓機走到這一拍的哪裡,從 0 平滑地增加到快要 1,一小節會跑四次。 drumBar 鼓機現在在第幾小節,從啟動的時候起算,第一小節是 1。 p5/oav-p5.js 第 317 行
function drumHit(lane, velocity) {}
一個由你定義的事件函式:鼓機每打一下就會呼叫它,告訴你是哪一種鼓、打得多用力。
定義 function drumHit(lane, velocity) { … },鼓機每打一下、不論哪一種鼓,oav-p5 都會呼叫它,就像按一個鍵會呼叫 notePressed() 一樣。它是在你聽到那一下的那個畫面、draw() 之前被呼叫的。
同一下打擊也會呼叫麥克風用的那些函式,你可以挑想要的粒度:kickDetected() 是大鼓,snareDetected() 是小鼓、拍手和鼓邊,hatDetected() 是三種鈸,beatDetected() (同時 beat 在那個畫面是 true)是大鼓、小鼓、拍手和鼓邊,每一格最多一次。中鼓、碎音鈸、疊音鈸和牛鈴只會呼叫 drumHit()。同一格裡的順序是:cueFired() 、barStarted() 、stepStarted() ,最後才是各個打擊。
參數 名稱 型別 說明 laneString發聲的鼓:格子上是 'kick'、'snare'、'hat'、'clap',一行字寫的節奏還會有 'openhat'、'pedalhat'、'rim'、'lowtom'、'tom'、'hightom'、'crash'、'ride'、'cowbell'。 velocityNumber打得多用力,從 0.15 到 1。每一下會有一點點不同,因為鼓機故意打得時輕時重。
注意 分頁被藏起來的時候沒有人看得到畫面:時鐘照樣在數,但不會通知任何一下打擊,分頁回來時也不會把漏掉的補通知一次。drumHit() 裡丟出的錯誤會連同檔案和行號印出來,下一下打擊照樣會來。 範例
const COLORS = { kick: '#ff6b6b' , snare: '#ffd166' , hat: '#6fd3ff' , clap: '#c792ff' };
const lit = { kick: 0, snare: 0, hat: 0, clap: 0 };
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 120);
noStroke();
}
function drumHit(lane, velocity) {
lit[lane] = velocity;
}
function draw() {
background(20);
Object.keys(lit).forEach((lane, i) => {
fill(COLORS[lane]);
circle(60 + i * 93, height / 2, 30 + lit[lane] * 70);
lit[lane] *= 0.85;
});
}
四個圓,一種鼓一個:每打一下就鼓起來再縮回去,打得越用力鼓得越大。
let last = '' ;
function setup() {
createCanvas(400, 300);
startDrums('bd*4, cr ~ ~ ~, lt(3,8), hh*8' , 100);
textAlign(CENTER, CENTER);
textSize(32);
}
function drumHit(lane, velocity) {
last = lane;
}
function draw() {
background(20);
fill(255);
text(last, width / 2, height / 2);
}
顯示最近一下是哪種鼓:一行字寫的節奏會有碎音鈸和落地鼓,這些只有 drumHit() 會通知。
相關 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 kickDetected() 你來定義這幾個函式。麥克風(或鼓機)聽到大鼓、小鼓或鈸的時候,oav-p5 會呼叫 kickDetected(level)、snareDetected(level)、hatDetected(level)。 beatDetected() 你來定義這個函式。麥克風(或鼓機)每聽到一個拍子,oav-p5 就呼叫它一次。 stepStarted() 一個由你定義的事件函式:鼓機 16 格裡的每一格都會呼叫它,帶著這一格和小節。 barStarted() 一個由你定義的事件函式:鼓機每一小節的第一格就會呼叫它,帶著小節的號碼。 OAVp5.drums.on() 在任何地方監聽鼓機:OAVp5.drums.on('hit', fn) 會像 drumHit 一樣呼叫 fn,並回傳一個可以取消監聽的函式。 notePressed() 你來定義這個函式。每當有一個鍵(或打擊墊)按下,oav-p5 就呼叫它。 p5/oav-p5.js 第 1498 行
drumPhase同一條也包含: OAVp5.drums.phase
鼓機走到這一小節的哪裡,從 0 平滑地增加到快要 1,是你耳朵聽到的位置。
任何要跟著音樂連續移動的東西都用它:rotate(drumPhase * TWO_PI) 一小節轉一圈。drumStep 講的是同一件事,只是分成 16 次跳動,drumPhase 是平滑的版本。它的值是聽的人聽到的位置,也就是鼓機的位置再扣掉喇叭的延遲,所以用它驅動的畫面會落在你聽到的拍子上。
一小節有四拍,beatPhase 是同樣的概念,單位是一拍。
型別 Number
範圍 0..1每次讀都是當下的值,不會慢一個畫面。小節開頭是 0,結尾接近 1,然後回到 0。鼓機還沒播之前是 0,停下來的時候停在原地。
注意 如果草圖本來就宣告了自己的 drumPhase,就用你的,oav-p5 不會去寫這個名稱。OAVp5.drums.phase 是同一個數字,用一個不會跟別人衝突的名字。 範例
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 120);
strokeWeight(4);
}
function draw() {
background(20);
stroke(255, 211, 107);
translate(width / 2, height / 2);
rotate(drumPhase * TWO_PI - HALF_PI);
line(0, 0, 110, 0); // a hand that turns once per bar
}
一根時鐘的指針,每一小節剛好轉一圈,轉得很平滑。
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 110);
noStroke();
}
function draw() {
background(20);
const a = drumStep / 16 * TWO_PI - HALF_PI; // hops from step to step
const b = drumPhase * TWO_PI - HALF_PI; // moves all the time
fill(255, 120, 90);
circle(200 + cos(a) * 100, 150 + sin(a) * 100, 20);
fill(120, 200, 255);
circle(200 + cos(b) * 70, 150 + sin(b) * 70, 20);
}
兩個圓上各有一個點:橘色的一格一格跳,藍色的連續不斷地跑。
相關 beatPhase 鼓機走到這一拍的哪裡,從 0 平滑地增加到快要 1,一小節會跑四次。 drumStep 鼓機剛剛播到這一小節 16 格裡的第幾格,從 0 到 15。 drumBar 鼓機現在在第幾小節,從啟動的時候起算,第一小節是 1。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 drawRhythm() 把節奏畫在畫布上,樣式可以是格子、圓或螺旋,有播放頭,打擊的時候會亮起來,所以錄影也錄得到。 p5/oav-p5.js 第 728 行
drumsAreOn
鼓機現在有沒有在播。
用它來分辨「鼓機沒在跑」和「鼓機剛好在第 0 格」:這兩種情況下 drumStep 都是 0。在讀 drumPhase 之前用 if (drumsAreOn) { … } 把關也很合適。它只有在鼓機開始或停止的時候才會變,讀它不會啟動任何東西。
false 的原因看 OAVp5.drums.state:模組還在下載是 'loading' ,格子在但沒在播是 'stopped',載入失敗是 'error'。
型別 Boolean鼓機在播的時候是 true,一開始、呼叫 stopDrums() 之後、出錯的時候、以及格子上自己的勾選框取消勾選的時候是 false。
要先做什麼才有值
要先呼叫 startDrums() (或按 🎹 面板的 🥁),等鼓機載入完才會變成 true,通常不到一秒。
注意 如果草圖本來就宣告了自己的 drumsAreOn 變數,就用你的:oav-p5 不動這個名稱,並用一張卡片提醒你改讀 OAVp5.drums.state === 'on'。 範例
function setup() {
createCanvas(400, 300);
startDrums('half time' , 100);
textAlign(CENTER, CENTER);
}
function draw() {
background(20);
fill(drumsAreOn ? color(120, 220, 140) : color(220, 160, 60));
circle(width / 2, height / 2, 80);
fill(255);
text(drumsAreOn ? 'drums are on' : 'drums are not running' , width / 2, height - 40);
}
鼓機載入的那一瞬間是琥珀色的圓,然後變成綠色。
p5/oav-p5.js 第 316 行
rhythmChanged()#
事件 oav-p5 function rhythmChanged(src, how) {}
一個由你定義的事件函式:節奏每次改變,就會帶著新的那一行節奏被呼叫。
格子上方的那一行字和格子本身會互相改寫,所以節奏可以在你的程式碼之外改變:點格子、在卡片上打字、按 ↶。rhythmChanged(src, how) 把這件事告訴草圖,讓它可以把那一行字畫在畫布上(錄影就錄得到),或是存起來。startDrums() 之後,節奏模組載入完的那一刻也會呼叫一次,這時 how 是 'code'。
範例
let current = '' ;
let how = '' ;
function setup() {
createCanvas(400, 300);
startDrums('bd*4, [~ sd]*2, hh*8' , 110);
textAlign(CENTER, CENTER);
}
function rhythmChanged(src, mode) {
current = src;
how = mode;
}
function mousePressed() {
OAVp5.drums.edit('bd(3,8), [~ sd]*2, hh*16' ); // as if typed on the card
}
function draw() {
background(20);
fill(255);
text(current, width / 2, height / 2 - 10);
text('changed by: ' + how, width / 2, height / 2 + 20);
text('click the canvas, or click a cell of the grid' , width / 2, height - 30);
}
顯示正在播的那一行節奏和是誰改的:一開始是 "code",點畫布之後是 "type",點格子上的一格就是 "cell"。
p5/oav-p5.js 第 1637 行
startDrums([pattern], [bpm])同一條也包含: OAVp5.drums.start
啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。
在 setup() 裡呼叫一次:startDrums('breakbeat', 120)。頁面上方會出現 16 格 × 4 軌(大鼓、小鼓、鈸、拍手)的鼓機,旁邊有速度和目前的小節,用合成的鼓組發聲(沒有取樣檔)。這個鼓機是頁面的一部分,不在畫布上,所以錄影錄不到它,要把節奏畫進畫布請用 drawRhythm() 。點格子可以改節奏,🎹 面板裡的 🥁 不用寫程式就能開關鼓機。
鼓機同時也是時鐘和事件的來源。每一下打擊都會呼叫 drumHit() 和麥克風用的那幾個函式(kickDetected() 、snareDetected() 、hatDetected() 、beatDetected() ),所以為麥克風寫的草圖不用改就能換成鼓機。drumStep 、drumBar 、drumPhase 、beatPhase 告訴你鼓機走到哪,cue() 讓你事先安排哪一小節發生什麼。
機器播的時候再呼叫,是換節奏或換速度,不會重新開始:startDrums(90) 只改速度,startDrums('latin') 只換節奏。不是預設節奏名字的文字,都當成一行節奏來讀。一個字就是一種鼓(bd 大鼓、sd 小鼓、hh 鈸、oh 開鈸、cp 拍手、rim 鼓邊、lt mt ht 三種中鼓、cr 碎音鈸、rd 疊音鈸、cb 牛鈴),~ 是休息,用空白隔開的字平分一小節,[ ] 是一組,*4 是重複四次,, 是疊上另一行,< > 是每小節輪流一個,bd(3,8) 是把 3 下打擊平均分在 8 格裡。寫錯的話會跳出一張卡片,指出是哪個字、怎麼改,這時先播 'four on floor'。
從 strudel.cc 複製來的程式也可以:startDrums('s("bd*4, [~ sd]*2").fast(2)')。s("…") 和 sound("…") 加上 .fast(n)、.slow(n) 會被讀進來,沒給速度的話 setcpm(110/4) 會設定速度,.bank(…) 會略過(oav-p5 用自己的鼓組),鏈裡其他的呼叫會跳一張卡片說目前還不支援,其餘照播。
呼叫之前
呼叫之前不用準備什麼。計數、drumHit() 和其他 callback 馬上就開始,但瀏覽器要頁面上先有一次點擊或按鍵才讓頁面出聲,所以在那之前鼓是沒聲音的,過一會兒上方會出現卡片提醒你。
參數 名稱 型別 說明 patternString | Object | Number要播什麼。預設節奏的名字('four on floor'、'breakbeat'、'half time'、'latin'、'sparse')。一行字寫的節奏,例如 'bd*4, [~ sd]*2, hh*8',或從 strudel.cc 複製來的。你自己畫的格子 { kick: 'x...x...x...x...', snare, hat, clap }(x 或 1 是打擊,沒寫的軌道是靜音,{} 是只走時間、不出聲的時鐘)。只給一個數字,那個數字就是速度。不傳就維持現在播的(一開始是 'four on floor')。 可省略 bpmNumber速度,單位是每分鐘幾拍(一拍是四格)。會被限制在 30 到 300 之間。不傳就維持現在的速度:一開始是 112,或是複製來的 Strudel 程式用 setcpm(…) 設的那個。 可省略
回傳 Promise<Boolean> 鼓機開始播之後 resolve 成 true(或者後面又有一次 startDrums() 接手的時候)。stopDrums() 先被呼叫、沒有頁面可以放鼓機、或 @openav/drums 載入失敗的時候,resolve 成 false。
注意 鼓的聲音不需要 startSound() :鼓機有自己的鼓組。MIDI 第 10 軌的打擊墊,在 startSound() 開著的時候打的就是同一組鼓。 卡片上的 – 可以把格子收起來,× 會停掉鼓機。分頁在背景的時候,鼓機照樣數、照樣播。載入選項 drums (lookahead 或 frame)和 drumlatency 可以調整時鐘。 範例
let swell = 0;
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 120); // the grid appears at the top of the page
}
function kickDetected(level) {
swell = 1;
}
function draw() {
background(20);
swell *= 0.9;
circle(width / 2, height / 2, 60 + swell * 120);
}
頁面上方出現鼓機。在畫布上點一下就聽得到鼓聲,每一下大鼓圓都會跳一下。
const LINES = ['bd*4, [~ sd]*2, hh*8' , 'bd(3,8), [~ sd]*2, hh*16' , 'bd ~ ~ bd, ~ sd ~ ~, [hh hh]*4' ];
let n = 0;
function setup() {
createCanvas(400, 300);
startDrums(LINES[0], 110);
textAlign(CENTER, CENTER);
}
function mousePressed() {
n = (n + 1) % LINES.length;
startDrums(LINES[n]); // changes the rhythm while it plays
}
function draw() {
background(20);
fill(255);
text('click to change the rhythm' , width / 2, height / 2 - 20);
text(OAVp5.drums.src, width / 2, height / 2 + 20);
}
在畫布上點一下就換下一個節奏:說明文字下面那一行就是現在播的節奏,上方的鼓機格子也跟著變。
function setup() {
createCanvas(400, 300);
startDrums({}, 90); // no lane has a hit: nothing to hear, the clock still counts
noStroke();
}
function draw() {
background(20);
for (let i = 0; i < 16; i++) {
fill(i === drumStep ? 255 : 90);
circle(30 + i * 22.5, height / 2, i === drumStep ? 18 : 8);
}
}
一個沒有聲音的時鐘:十六個點,現在這一格的點比較大,以每分鐘 90 拍的速度走,完全沒有聲音。
相關 stopDrums() 停掉鼓機,並把它的卡片收起來。 drumsAreOn 鼓機現在有沒有在播。 drumHit() 一個由你定義的事件函式:鼓機每打一下就會呼叫它,告訴你是哪一種鼓、打得多用力。 drumStep 鼓機剛剛播到這一小節 16 格裡的第幾格,從 0 到 15。 drumPhase 鼓機走到這一小節的哪裡,從 0 平滑地增加到快要 1,是你耳朵聽到的位置。 drawRhythm() 把節奏畫在畫布上,樣式可以是格子、圓或螺旋,有播放頭,打擊的時候會亮起來,所以錄影也錄得到。 cue() 替某一小節安排事情:到了第 bar 小節,就呼叫 cueFired(name, bar),cueName 變成 name,給了 every,之後每隔那麼多小節再來一次。 rhythmChanged() 一個由你定義的事件函式:節奏每次改變,就會帶著新的那一行節奏被呼叫。 beat 麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。 p5/oav-p5.js 第 1731 行
stopDrums()同一條也包含: OAVp5.drums.stop
停掉鼓機,並把它的卡片收起來。
鼓機停止播放,格子消失,drumsAreOn 變成 false。各個數字停在最後的值:drumStep 、drumBar 和 cueName 留在原來的地方,drumPhase 和 beatPhase 也停住不動。之後再呼叫 startDrums() 會從第 1 小節重新開始。
機器還在載入的時候呼叫也可以:還沒結束的 startDrums() 會 resolve 成 false。卡片上的 × 做的是同一件事。
回傳 Promise<Boolean> 一個已經 resolve 成 false 的 promise,false 就是鼓機的新狀態。
範例
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 120);
textAlign(CENTER, CENTER);
}
function mousePressed() {
stopDrums();
}
function draw() {
background(drumsAreOn ? 40 : 100);
fill(255);
text(drumsAreOn ? 'click to stop the drums' : 'stopped at step ' + drumStep, width / 2, height / 2);
}
在畫布上點一下:鼓機消失,背景變亮,文字顯示它停在第幾格。
待確認
在同一個頁面呼叫 stopDrums() 之後再呼叫一次 startDrums(),鼓機會再度播放、計數,但實際試的時候(Chromium,oav-p5 0.8.0),drumsAreOn 和 OAVp5.drums.state 仍然是 false 和 'off'。這是不是預期的行為,還沒有確認。
p5/oav-p5.js 第 1732 行
OAVp5.drums
鼓機相關名稱背後的物件:機器的狀態、各種計數、那一行節奏、它的歷史,以及載入進來的鼓機。
OAVp5.drums(也可以用 midi.drums 取得)是 startDrums() 、drumStep 這些名稱的底層。它裡面有數字(state 、step 、bar 、count 、bpm 、phase 、beatPhase)、節奏(pattern 、src 、copyText 、strudelUrl 、history 、edit() 、undo() )、cue(cue() 、cues 、lastCue )、監聽的方法(on() )、鼓機此刻聽到的樣子(now ),以及給好奇的人用的把手(machine 、clock )。
start、stop、draw 跟 startDrums() 、stopDrums() 、drawRhythm() 是同一回事。一般草圖用全域名稱就夠了。這個物件是給工具、函式庫(例如 Hydra)、主控台和除錯用的。
範例
function setup() {
createCanvas(400, 300);
startDrums('latin' , 110);
textSize(14);
}
function draw() {
background(20);
fill(255);
const d = OAVp5.drums;
text('state: ' + d.state, 20, 30);
text('pattern: ' + d.pattern, 20, 60);
text('bar ' + d.bar + ', step ' + d.step + ', count ' + d.count, 20, 90);
text('bpm: ' + d.bpm, 20, 120);
}
機器在播的時候,從鼓機物件讀出來的四行資料。
相關 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 OAVp5.drums.state 鼓機現在在哪個階段:'off'、'loading'、'on'、'stopped' 或 'error'。 OAVp5.drums.on() 在任何地方監聽鼓機:OAVp5.drums.on('hit', fn) 會像 drumHit 一樣呼叫 fn,並回傳一個可以取消監聽的函式。 OAVp5.sound 聲音相關名稱背後的物件:狀態、樂器、錯誤、載入進度,以及載入進來的 @openav/sound 各部分。 OAVp5.record 錄影相關名稱背後的物件:狀態、時間、最後一支檔案、這個瀏覽器能寫的格式,以及存圖的函式。 p5/oav-p5.js 第 1471 行
OAVp5.drums.copyText#
變數 oav-p5 OAVp5.drums.copyText
卡片上「複製」按鈕放進剪貼簿的文字:把現在播的節奏寫成可以直接貼上的 startDrums("…", bpm)。
點格子、打字,找到喜歡的節奏之後,把這一行貼進 setup() 就能留住它。速度是現在的速度,四捨五入到小數點後兩位。
型別 String例如 startDrums("bd*4, [~ sd]*2, hh*8", 110)。每次讀都是當下的值,鼓機載入之前是 ''。
範例
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 120);
textSize(12);
}
function draw() {
background(20);
fill(255);
text(OAVp5.drums.copyText, 20, 20, width - 40, 200);
}
可以貼回 setup() 的那一行,在格子上點一點,它也會跟著變。
p5/oav-p5.js 第 1467 行
OAVp5.drums.edit()#
函式 oav-p5 OAVp5.drums.edit(text)
把一行節奏當成在卡片上打的字來播,並回傳有沒有成功,不成功的話告訴你哪裡有問題。
跟 startDrums(text) 不同:那一行有錯的時候,startDrums 會改播 'four on floor',而 edit() 會讓原本那一行繼續播,並告訴你新的為什麼失敗,所以即時編碼的草圖可以把訊息顯示出來。這一行會進到 OAVp5.drums.history ,rhythmChanged() 也會被呼叫,how 是 'type'。有錯的時候也會跳出卡片,指出是哪個字。
參數 名稱 型別 說明 textString一行節奏,例如 'bd*4, [~ sd]*2, hh*8',或是從 strudel.cc 複製來的程式。
回傳 Object 這行節奏播起來了就回傳 { ok: true, error: null, text }。有錯的話回傳 { ok: false, error, text }:error 裡有 code、index 和 length(錯的那個字在 text 的位置)、message 和 hint,這時上一行能播的節奏繼續播。鼓機還沒載入的時候回傳 { ok: false, error: null }。
範例
const TRIES = ['bd*4, hh*8' , 'bd(3,8), [~ sd]*2' , 'bd*4, xyz*8' ];
let n = 0;
let result = '' ;
function setup() {
createCanvas(400, 300);
startDrums('bd*4, [~ sd]*2, hh*8' , 110);
textAlign(CENTER, CENTER);
}
function mousePressed() {
const r = OAVp5.drums.edit(TRIES[n % TRIES.length]);
result = r.ok ? 'ok: ' + r.text : 'wrong: ' + r.error.message;
n++;
}
function draw() {
background(20);
fill(255);
text('click to try the next line' , width / 2, 40);
text(result, width / 2, height / 2 - 10);
text('playing: ' + OAVp5.drums.src, width / 2, height / 2 + 30);
}
連點三下:前兩行會播,第三行有錯,所以顯示錯誤訊息,並讓前一行繼續播。
p5/oav-p5.js 第 1465 行
OAVp5.drums.history#
變數 oav-p5 OAVp5.drums.history
之前播過的節奏,最舊的在最前面:每點一次格子、每改一次文字、每從選單挑一次,前一行就會存到這裡。
OAVp5.drums.undo() 會把這份清單最後一行拿出來播。用 startDrums() 做的改變不會記在這裡,只有在卡片上做的,或用 OAVp5.drums.edit() 做的才會。這個陣列是鼓機自己的,請當成唯讀。
型別 Array<String>一開始是空的,最多保留 50 行。現在在播的那一行不在裡面,那一行是 OAVp5.drums.src 。
範例
const LINES = ['bd*4, hh*8' , 'bd(3,8), hh*8' , 'bd ~ ~ bd, [~ sd]*2, hh*16' ];
let n = 0;
function setup() {
createCanvas(400, 300);
startDrums(LINES[0], 110);
textSize(14);
}
function mousePressed() {
n = (n + 1) % LINES.length;
OAVp5.drums.edit(LINES[n]);
}
function draw() {
background(20);
fill(255);
text('click to edit the line' , 20, 30);
text('history: ' + OAVp5.drums.history.length + ' earlier lines' , 20, 60);
text(OAVp5.drums.history.join('\n' ), 20, 90);
}
每點一下就改一次那一行,前一行會加進歷史清單。
p5/oav-p5.js 第 1461 行
OAVp5.drums.now#
物件 oav-p5 OAVp5.drums.now
鼓機此刻聽到的樣子,一次拿齊:小節、格、兩個相位,以及那一行字裡哪些字亮著。
卡片就是用它讓正在響的字亮起來。想一次拿到那一行字和亮著的字的時候讀它,例如把那一行字畫在畫布上,並標出正在響的字。鼓機載入之前,它給的是零和空的 text。
型別 Object
範圍 { bar, step, phase, beatPhase, tokens, mode, text }每次讀都是一個新的物件。phase 和 beatPhase 就是 drumPhase 和 beatPhase 。tokens 是 { start, end } 的清單,標出 text 裡現在正在響的那幾個字的位置。mode 在播一行節奏時是 'string',預設節奏或你自己畫的格子是 'grid'。text 是那一行字。
範例
function setup() {
createCanvas(400, 300);
startDrums('bd*4, [~ sd]*2, hh*8' , 80);
textFont('monospace' );
textSize(18);
noStroke();
}
function draw() {
background(20);
const n = OAVp5.drums.now;
fill(255, 211, 107);
for (const t of n.tokens) {
rect(20 + textWidth(n.text.slice(0, t.start)), 150, textWidth(n.text.slice(t.start, t.end)), 3); // underline the sounding words
}
fill(255);
text(n.text, 20, 140);
}
那一行節奏,下面有一條黃色底線,跟著正在響的字移動。
p5/oav-p5.js 第 1449 行
OAVp5.drums.on()#
函式 oav-p5 OAVp5.drums.on(name, fn)
在任何地方監聽鼓機:OAVp5.drums.on('hit', fn) 會像 drumHit 一樣呼叫 fn,並回傳一個可以取消監聽的函式。
你定義的函式(drumHit、barStarted ……)每種事件只能有一個。on() 是給不適用的情況:同一個事件要有好幾個監聽者、函式庫或 Hydra 草圖沒有自己的 drumHit()、或是想把監聽者再拿掉。概念跟 midi.on() 一樣。fn 裡丟出的錯誤會被印出來,其他監聽者照樣執行。
回傳 Function 一個函式,呼叫它就停止監聽。
範例
let bars = 0;
let off = null ;
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 160);
off = OAVp5.drums.on('bar' , (bar) => { bars++; }); // returns a function that stops it
textAlign(CENTER, CENTER);
}
function mousePressed() {
if (off) { off(); off = null ; } // stop counting
}
function draw() {
background(20);
fill(255);
text('bars counted: ' + bars, width / 2, height / 2);
text(off ? 'click to stop counting' : 'stopped' , width / 2, height / 2 + 30);
}
一個每小節加一的計數器,點一下就拿掉監聽者,數字就停住。
p5/oav-p5.js 第 1441 行
OAVp5.drums.pattern#
變數 oav-p5 OAVp5.drums.pattern
鼓機正在播的東西,用名字表示:預設節奏的名字、自己畫的格子是 'custom',一行節奏就是那一行字本身。
startDrums('breakbeat') 之後是 'breakbeat',startDrums({ kick: 'x...x...x...x...' }) 之後是 'custom',startDrums('bd*4, hh*8') 之後就是那一行字。在預設節奏的格子上點一下,它就變成 'custom'。想在每一種情況下都拿到那一行節奏,請讀 OAVp5.drums.src :預設節奏也有對應的那一行。
型別 String在你選別的之前是 'four on floor'。它跟著正在播的東西走,包括在格子上點的修改。
範例
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 110);
textAlign(CENTER, CENTER);
textSize(24);
}
function draw() {
background(20);
fill(255);
text(OAVp5.drums.pattern, width / 2, height / 2);
}
顯示 "breakbeat",在鼓機的格子上點一下,就變成 "custom"。
p5/oav-p5.js 第 1437 行
OAVp5.drums.src#
變數 oav-p5 OAVp5.drums.src
現在播的那一行節奏,例如 'bd*4, [~ sd]*2, hh*8'。鼓機載入之前是空字串。
把它畫在畫布上,錄影就錄得到(text(OAVp5.drums.src, 20, 30)),或是存起來,之後用 startDrums(src) 回到這個節奏。它改變的時候 rhythmChanged() 會通知你,OAVp5.drums.copyText 則是同一行字外面包上 startDrums("…")。
型別 String每次讀都是當下的值。預設節奏或你自己畫的格子,會顯示成它對應的那一行字,每點一次格子、每改一次文字,它就跟著變。
範例
function setup() {
createCanvas(400, 300);
startDrums('half time' , 100);
textAlign(CENTER, CENTER);
}
function draw() {
background(20);
fill(255);
text(OAVp5.drums.src || 'loading…' , width / 2, height / 2);
}
預設節奏 "half time" 對應的那一行字,在格子上點一點,這一行就會被改寫。
p5/oav-p5.js 第 1457 行
OAVp5.drums.state#
變數 oav-p5 OAVp5.drums.state
鼓機現在在哪個階段:'off'、'loading'、'on'、'stopped' 或 'error'。
'loading' 是呼叫 startDrums() 之後,模組還在下載的那段時間,'on' 是正在播,'stopped' 是格子還在但沒在播(它的勾選框取消勾選了),'off' 是起點,也是 stopDrums() 之後的狀態,'error' 表示 @openav/drums 載入失敗。drumsAreOn 就是 state === 'on'。
型別 String
範圍 off | loading | on | stopped | error一開始是 'off',鼓機載入、播放、停止或失敗的時候就跟著變。
範例
function setup() {
createCanvas(400, 300);
startDrums('sparse' , 90);
textAlign(CENTER, CENTER);
textSize(28);
}
function draw() {
background(20);
fill(255);
text(OAVp5.drums.state, width / 2, height / 2);
}
先短暫顯示 "loading",然後是 "on"。把鼓機上的勾選框取消勾選,就變成 "stopped"。
p5/oav-p5.js 第 1436 行
OAVp5.drums.strudelUrl#
變數 oav-p5 OAVp5.drums.strudelUrl
現在播的節奏做成的連結,打開就會在 strudel.cc 裡開啟同一個節奏,沒有的時候是空字串。
裡面是那一行節奏,寫成 s("…"),再加上 setcpm(bpm/4)(這裡的一小節就是 Strudel 的一個週期,四拍),跟卡片上「在 Strudel 打開」連結開啟的是同一個東西。用新分頁打開它:在點擊的處理函式裡寫 window.open(OAVp5.drums.strudelUrl)。
型別 String一個 https://strudel.cc/#… 的網址,每次讀都是當下的。鼓機載入之前是 ''。
範例
function setup() {
createCanvas(400, 300);
startDrums('bd*4, [~ sd]*2, hh*8' , 110);
textAlign(CENTER, CENTER);
}
function mousePressed() {
if (OAVp5.drums.strudelUrl) window.open(OAVp5.drums.strudelUrl, '_blank' );
}
function draw() {
background(20);
fill(255);
text('click to open this rhythm in strudel.cc' , width / 2, height / 2 - 15);
text(OAVp5.drums.strudelUrl.slice(0, 40) + '…' , width / 2, height / 2 + 15);
}
顯示連結的開頭,點一下就在新分頁的 strudel.cc 裡打開。
待確認
還沒試過:OpenProcessing 的草圖框是否允許 window.open 開啟這個連結。
p5/oav-p5.js 第 1459 行
OAVp5.drums.undo()#
函式 oav-p5 OAVp5.drums.undo()
回到上一行節奏,跟卡片上的 ↶ 一樣,如果有上一行就回傳 true。
上一行會再播一次,並從歷史清單裡拿掉。rhythmChanged() 會被呼叫,how 是 'undo'。在卡片上,點一下卡片之後按 ⌘Z 或 Ctrl+Z 也是同樣的事。
範例
function setup() {
createCanvas(400, 300);
startDrums('bd*4, hh*8' , 110);
textAlign(CENTER, CENTER);
}
function mousePressed() {
OAVp5.drums.edit('bd(3,8), hh*16' );
}
function keyPressed() {
if (key === 'z' ) OAVp5.drums.undo();
}
function draw() {
background(20);
fill(255);
text(OAVp5.drums.src, width / 2, height / 2 - 10);
text('click: change the line z: undo' , width / 2, height / 2 + 20);
}
點一下換一行節奏,按 z 回到前一行。
p5/oav-p5.js 第 1463 行
@openav/drums
drumEngine()#
函式 @openav/drums drumEngine({ samples = null } = {})
一組用 Web Audio 合成的鼓組,沒有取樣檔:送進 General MIDI 的鼓音符,就打出鼓聲。
它遵守 @openav/sound 的小小引擎約定,所以同一個外殼、同樣的訊號都能驅動它:enable() 建立 AudioContext,noteOn(note, vel01, time) 打一下鼓,set(key, value) 設定參數。鼓組有十三種鼓聲(見 GM ):大鼓、鼓邊、小鼓、拍手、落地鼓、鈸、踩鈸、中鼓、開鈸、高音中鼓、碎音鈸、疊音鈸、牛鈴。鼓組不認得的音符(任何旋律音符)不會發聲,並回傳 null。
noteOn(note, vel01, time) 回傳 { time, cancel() },在 enable() 之前,或遇到它不打的音符時回傳 null。time 是鼓組自己的時鐘(engine.context.currentTime)上的時間。沒給,或已經過了,這一下就馬上響。cancel() 能讓還沒開始的鼓靜音,DrumTransport 在速度改變時就是用它把排好的收回來。hit('crash', 0.9) 用名字(或音符號碼)打一種鼓,給不是 MIDI 墊子的程式用。它有一個參數 kitVolume,從 −30 到 0 dB(預設 −6)。
啟用之後,engine.context 是它的 AudioContext,engine.output 是接到喇叭的節點,錄音器可以從這裡接。在那之前兩個都是 null。noteOff() 什麼都不做(鼓聲自己響完),dispose() 會關掉 context。
參數 名稱 型別 說明 samplesObject用音檔取代合成:{ 36: 'kick.wav', 38: 'snare.wav' },MIDI 音符號碼對應網址。enable() 會去下載並解碼。載入失敗的檔案會被略過、不報錯,屬於鼓組的音符就改用合成的聲音打。 可省略 預設 null
回傳 Object 一個引擎:{ params, context, output, enable(), noteOn(note, vel01, time), hit(what, vel01, time), noteOff(), set(key, value), dispose() }。
範例
js 複製
import { drumEngine, GM } from '@openav/drums' ;
const kit = drumEngine();
console.log(kit.params.map((p) => p.key + ' ' + p.min + '..' + p.max + ' (' + p.def + ')' ).join());
// kitVolume -30..0 (-6)
console.log(kit.noteOn(GM.kick, 1), kit.context); // null null: not enabled yet
module 在 enable() 之前,鼓組有參數但沒有聲音也沒有 AudioContext:noteOn() 回傳 null。
js 複製
import { drumEngine } from '@openav/drums' ;
const kit = drumEngine();
document.body.addEventListener('click' , async () => {
await kit.enable(); // from a click: audio may start
kit.hit('kick' );
kit.hit('snare' , 0.9, kit.context.currentTime + 0.5); // half a second from now
kit.hit('crash' , 0.8, kit.context.currentTime + 1);
});
static 在頁面上點一下:先是大鼓,半秒後小鼓,一秒後碎音鈸。時間是鼓組自己的音訊時鐘上的時間。
相關 DrumTransport 包在 DrumScheduler 外面的執行環境:它用鼓組的音訊時鐘,把每一下鼓提前交給鼓組,並在聽到的時候才告訴頁面每一步和每一下。 mountDrums() 在頁面上放一台鼓機:步進格子、播放控制,和一行可以直接打字修改的節奏。它把每一下鼓發布成 drum/* 訊號。 GM 鼓組裡每種鼓對應的 General MIDI 鼓音符:GM.kick 是 36,GM.snare 是 38。 voiceForNote() 告訴你一個 General MIDI 鼓音符會打鼓組裡的哪一種鼓,不打的音符回傳 null。 Sound 讓匯流排上每個 midi/note/on 和 midi/note/off 都透過發聲引擎聽得見,並把引擎的參數放到匯流排上,名稱是 sound/*。 toneEngine() 發聲引擎,用 Tone.js 播放登記好的樂器,聲音依序經過濾波器、殘響和限幅器。 import { drumEngine } from '@openav/drums'; packages/drums/index.js 第 38 行
drumName()#
函式 @openav/drums drumName(voice, lang = 'en' )
給人讀的鼓的名字,英文或中文:drumName('lowtom', 'zh') 是 '落地鼓'。
程式裡的鼓的名字到處都保持英文('kick'、'snare'),這個函式是給畫面顯示用的:鼓機卡的列標籤就是用它,中文的瀏覽器裡是 大鼓 小鼓 鈸 拍手。
參數 名稱 型別 說明 voiceString哪種鼓:kick、snare、hat、clap、openhat、pedalhat、rim、lowtom、tom、hightom、crash、ride、cowbell。 lang'en' | 'zh''zh' 是中文。其他都是英文。 可省略 預設 'en'
回傳 String 名字。如果 voice 不是鼓組的鼓,就原樣回傳 voice。
範例
js 複製
import { drumName } from '@openav/drums' ;
console.log(drumName('kick' ), drumName('kick' , 'zh' )); // kick, then the Chinese name of the kick
console.log(drumName('openhat' , 'zh' ), drumName('lowtom' , 'zh' )); // Chinese names of the open hat and the low tom
console.log(drumName('banjo' , 'zh' )); // banjo, not a drum: returned as is
module 印出幾種鼓的英文和中文名字,以及不是鼓的名字會得到什麼。
相關 GM 鼓組裡每種鼓對應的 General MIDI 鼓音符:GM.kick 是 36,GM.snare 是 38。 mountDrums() 在頁面上放一台鼓機:步進格子、播放控制,和一行可以直接打字修改的節奏。它把每一下鼓發布成 drum/* 訊號。 WORDS 一行節奏可以用的鼓的字,以及每個字打哪一種鼓:bd 是 kick,sd 是 snare,hh 是 hat。 import { drumName } from '@openav/drums'; packages/drums/index.js 第 809 行
DrumScheduler#
類別 @openav/drums new DrumScheduler({ bpm = 112, swing = 0, humanize = 0.35, lookahead = 0.1, pattern = 'four on floor' , seed = 0, random = Math.random } = {})
純粹的鼓的邏輯,沒有計時器也沒有聲音:放進一張格子圖或一行節奏,就提前吐出有時間的事件,時鐘由你傳進來。
DrumScheduler 知道哪個小節要打什麼,也知道一個小節在什麼時候發生,但它沒有自己的時鐘:你給它的每個時間,都是你選的某一個時鐘上的秒數,所以它在測試裡用假時鐘跑、在頁面裡用音訊時鐘跑,結果一樣。DrumTransport 是把真的時鐘餵給它的執行環境。想把鼓的事件當資料用,例如給自己的發聲引擎或測試,就單獨用排程器。
流程是這樣:start(at) 讓第 1 小節的強拍落在時間 at。然後一再地呼叫 advance(now),它排好直到 now + lookahead 的全部,並依序回傳新的事件。due(t) 把時間到了的事件交出來(在那之前它們留在 queue 裡)。事件有兩種:每個十六分音符一個步 { kind: 'step', i, n, bar, pos, time },和一下鼓 { kind: 'hit', voice, vel, pos, n, bar, token, word, layer, dur, time }。pos 從開始算起以小節為單位(0 是第 1 小節的強拍,1.5 是第 2 小節的一半),time 是你的時鐘的秒數,i 是步 0..15,n 是從開始算起的步數。格子圖的話,步在它的鼓之前。token、word、layer、dur 來自一行節奏,格子圖的話是 null 或 undefined。
位置是從最近一個速度錨點用乘法換成秒的,不是把畫面時間加起來,所以一千個小節之後也沒有漂移(測試量到的誤差不到一微秒)。速度改變會多加一個錨點,所以過去保持它當時的速度。
參數 名稱 型別 說明 bpmNumber速度,每分鐘幾拍。不大於 0 的值會變成 112。 可省略 預設 112 swingNumber0 到 0.5:每個奇數的十六分音符晚那麼多個十六分音符。0 是直的。 可省略 預設 0 humanizeNumber0..1:晚幾毫秒、力道有一點大小變化,讓它有律動而不是機械的滴答。要精準、可重複的時間就用 0。 可省略 預設 0.35 lookaheadNumberadvance() 在 now 之後多排幾秒。 可省略 預設 0.1 patternString | ObjectPATTERNS 裡的預設名稱、你自己的格子圖,或編譯過的一行節奏(見 setPattern())。 可省略 預設 'four on floor' seedNumber當作 eventsInBar(bar, { seed }) 交給那一行節奏,讓每個 ? 每次都做同樣的決定。 可省略 預設 0 randomFunctionhumanize 的隨機從哪裡來:回傳 0 到 1 之間數字的函式。傳自己的就能得到可重複的結果。 可省略 預設 Math.random
成員 名稱 說明 bpmgetter Getter 和 setter。速度。設定它就是 setBpm(bpm)。 stepDur()method 現在的速度下,一個十六分音符多少秒:60 / bpm / 4。 setPattern(p, { text } = {})method 預設名稱、你自己的格子圖 { kick: 'x...x...', … }(沒寫的軌道是靜音),或編譯過的一行節奏:任何有 eventsInBar(bar, { seed }) 的東西,例如 compile(text) 。給一行節奏的話,{ text } 是它原本的那行字。不認識的名稱會打 'four on floor'。回傳新的 pattern。 toggleCell(lane, i)method 切換格子上的一格:toggleCell('snare', 4)。節奏會變成 'custom'。如果是一行節奏,會先換成它在第 1 小節打出來的格子圖。 gridOf(bar = 1)method 正在打的內容的第 bar 小節,放在四條軌道的 16 格上:{ grid, offGrid, dropped }。offGrid 是原本落在格子之間而被移到最近一格的鼓點數量,dropped 列出格子沒有軌道的鼓。 takeover(bar = 1)method 一行節奏換成它在 bar 小節打出來的格子圖,從此改由格子來打。回傳 gridOf(bar) 的結果。 barEvents(bar)method 第 bar 小節(1 是第一個)的鼓點,格式是 [{ voice, pos, … }],pos 是小節裡的 0..1,不管來源是哪一種。一行節奏出錯,或用了鼓組沒有的鼓,會設定 warning 並略過那一下。 posAt(t)method 時間 t 的時候,音樂在哪裡,單位是從開始算起的小節。第 1 小節的強拍是 0,在它之前是負數。 timeAt(p)method 位置 p 在什麼時間響,還沒算 swing 和 humanize。是 posAt() 的反函數。 bpmAt(t)method 時間 t 的速度。 phaseAt(t)method 時間 t 的 { pos, bar, beat }:bar 和 beat 是在小節和拍子裡走了多遠,0..1。第一個強拍之前是 0。 start(at)method 開始:第 1 小節的強拍在時間 at。清空佇列並回傳排程器本身。 stop()method 停止,並回傳已經排程但還沒被聽到的事件,讓你可以把它們從鼓組收回來。 setBpm(bpm, at)method 從時間 at 起換成新的速度。沒給的話,從已經排程的內容的結尾起算,所以不會有東西被收回。回傳被收回的事件(沒有就是 [])。不大於 0 的值不會有任何動作。 rewind(at)method 把排在時間 at 或之後的全部收回來。下一次 advance() 會依那時的節奏和速度重做。回傳被收回的事件。已經被 due() 交出去的事件絕不會被收回。 advance(now)method 排好直到 now + lookahead 的全部,並依序回傳新的事件(它們留在 queue 裡,直到 due() 交出去)。如果頁面凍結太久,到期的超過 64 個小節,它會直接跳過去,不會一次吐出好幾分鐘的鼓,並把跳過的記在 skipped。停止的時候什麼都不做,回傳 []。 due(t)method 時間在 t 或更早的事件,依序,並從佇列移除。
範例
js 複製
import { DrumScheduler } from '@openav/drums' ;
const s = new DrumScheduler({ bpm: 120, humanize: 0 });
s.start(0); // the downbeat of bar 1 is at time 0
s.advance(1.9); // schedule up to 1.9 + 0.1 seconds: one whole bar
const heard = s.due(0.5); // what is heard by 0.5 s
console.log(heard.filter((e) => e.kind === 'hit' ).map((e) => e.voice + '@' + e.time).join(' ' ));
// kick@0 hat@0 hat@0.25 kick@0.5 snare@0.5 hat@0.5
console.log(s.phaseAt(1).bar, s.posAt(2), s.timeAt(1)); // 0.5 1 2
module 沒有時鐘、沒有聲音:向排程器要 120 bpm 四四拍的一個小節,印出前半秒聽到的鼓,連同以秒為單位的時間。
js 複製
import { DrumScheduler } from '@openav/drums' ;
import { compile } from '@openav/pattern' ;
const s = new DrumScheduler({ bpm: 120, humanize: 0 });
s.setPattern(compile('bd [~ sd]*2' ), { text: 'bd [~ sd]*2' });
console.log(s.mode, s.pattern); // string bd [~ sd]*2
console.log(s.barEvents(1).map((e) => e.voice + '@' + e.pos).join(' ' )); // kick@0 snare@0.625 snare@0.875
const g = s.gridOf(1);
console.log(g.grid.snare.join('' ), g.offGrid); // 0000000000100010 0
s.start(10);
s.advance(10);
s.setBpm(60, 12); // half the tempo from time 12
console.log(s.posAt(12), s.posAt(14)); // 1 1.5
module 放進一行節奏:先把它的第一小節讀成鼓點,再讀成 16 格的格子圖,然後在時間 12 把速度減半,在那之前的不受影響。
相關 DrumTransport 包在 DrumScheduler 外面的執行環境:它用鼓組的音訊時鐘,把每一下鼓提前交給鼓組,並在聽到的時候才告訴頁面每一步和每一下。 DrumSequencer 一個 16 步、四條軌道的鼓節奏時鐘,每個畫面用 update(dt) 往前推,每一下鼓就呼叫你的函式。 compile() 把一行節奏文字,像 'bd*4, [~ sd]*2, hh*8',讀成任何一個小節要打哪些鼓。它不會丟出錯誤。 toGrid() 把一行節奏的某一個小節畫成格子圖,並告訴你格子放不下哪些東西。 PATTERNS 鼓機內建的五個預設格子圖:'four on floor'、breakbeat、'half time'、latin、sparse。 GM 鼓組裡每種鼓對應的 General MIDI 鼓音符:GM.kick 是 36,GM.snare 是 38。 import { DrumScheduler } from '@openav/drums'; packages/drums/index.js 第 296 行
DrumSequencer#
類別 @openav/drums new DrumSequencer({ onHit, onStep = null , bpm = 112, swing = 0.12, humanize = 0.35, pattern = 'four on floor' } = {})
一個 16 步、四條軌道的鼓節奏時鐘,每個畫面用 update(dt) 往前推,每一下鼓就呼叫你的函式。
這是最簡單的時鐘:沒有聲音、沒有計時器、沒有 DOM。做一個會做事的 onHit,呼叫 toggle(true),然後每個畫面用距離上一個畫面過了幾秒呼叫 update(dt)。它會走過一個小節的 16 步(一個十六分音符是 60 / bpm / 4 秒),每一下 kick、snare、hat、clap 四條軌道上的鼓就呼叫 onHit(lane, velocity)。鼓是在到時間的那個畫面才被告知,所以最多會晚一個畫面,分頁被藏起來的時候什麼都不會發生。要在畫面很重的時候也有穩的節拍,用 DrumTransport 。
格子圖是 seq.grid:{ kick: [0|1 × 16], snare, hat, clap }。seq.pattern 是預設的名稱,改過格子或給了自己的格子圖之後就是 'custom'。seq.playing、seq.bpm、seq.swing、seq.humanize 都是可以直接讀和設的普通屬性。
參數 名稱 型別 說明 onHitFunction每一下鼓呼叫 onHit(lane, velocity):lane 是 'kick'、'snare'、'hat'、'clap',velocity 是 0.15 到 1。一下鼓要做什麼由你決定:發聲、畫圖、發布訊號。 onStepFunction每個十六分音符步呼叫 onStep(i, n):i 是 0..15,n 是從開始算起的步數。它在那一步的鼓之前。時間軸靠它數小節。 可省略 預設 null bpmNumber速度,每分鐘幾拍。之後可以改:seq.bpm = 90。 可省略 預設 112 swingNumber0 到 0.5:每個奇數的十六分音符晚那麼多個十六分音符。0 是直的。 可省略 預設 0.12 humanizeNumber0..1:有些鼓晚幾毫秒、力道有一點大小。要精準、可重複的力道就用 0(拍點上 0.9,其他 0.6)。 可省略 預設 0.35 patternString | ObjectPATTERNS 裡的預設名稱,或你自己的格子圖。 可省略 預設 'four on floor'
成員 名稱 說明 setPattern(p)method 預設名稱('breakbeat'),或你自己的格子圖 { kick: 'x...x...x...x...', snare: …, hat: …, clap: … }。沒寫的軌道是靜音,pattern 變成 'custom'。不認識的名稱會打 'four on floor'。 toggleCell(lane, i)method 切換一格:toggleCell('kick', 4)。lane 必須是四條軌道之一。pattern 變成 'custom'。 toggle(on = !this.playing)method toggle(on) 開始或停止。不給參數就是反過來。開始的時候從第 0 步起,停止之前還沒送出的鼓不會在下一次開始之後跑出來。回傳新的狀態。stepDur()method 現在的速度下,一個十六分音符多少秒:60 / bpm / 4。 update(dt)method 把時鐘往前推 dt 秒,並對這段時間內的每一步和每一下呼叫 onStep 和 onHit。停止的時候什麼都不做。每個畫面呼叫一次。
範例
js 複製
import { DrumSequencer } from '@openav/drums' ;
const hits = [];
const seq = new DrumSequencer({
onHit: (lane, vel) => hits.push(lane + ' ' + vel.toFixed(1)),
bpm: 120, swing: 0, humanize: 0,
});
seq.toggle(true );
for (let i = 0; i < 32; i++) seq.update(1 / 60); // about half a second, in 60 fps frames
console.log(hits.join(', ' ));
// kick 0.9, hat 0.9, hat 0.6, kick 0.9, snare 0.9, hat 0.9
console.log(seq.pattern, seq.grid.kick.join('' )); // four on floor 1000100010001000
module 不用聲音也不用畫面,跑半秒的四四拍,印出鼓點:強拍上的大鼓和鈸、反拍的鈸,然後第二拍的大鼓、小鼓和鈸。
js 複製
import { DrumSequencer } from '@openav/drums' ;
const g = document.getElementById('c' ).getContext('2d' );
let lit = 0;
const seq = new DrumSequencer({
bpm: 110, pattern: { kick: 'x...x...x...x...' , snare: '....x.......x...' , hat: 'x.x.x.x.x.x.x.x.' },
onStep: (i) => { lit = i; }, // onHit would be the place to play a sound
});
seq.toggle(true );
let last = performance.now();
(function frame(now) {
seq.update((now - last) / 1000); last = now;
g.fillStyle = '#111' ; g.fillRect(0, 0, 400, 300);
for (let i = 0; i < 16; i++) { g.fillStyle = i === lit ? '#ffd166' : '#2a3348' ; g.fillRect(12 + i * 23, 120, 18, 60); }
requestAnimationFrame(frame);
})(last);
module 畫布上有十六個方塊,亮的那一個跟著節拍跑。這裡沒有聲音:onHit 就是你放發聲程式的地方。
相關 DrumTransport 包在 DrumScheduler 外面的執行環境:它用鼓組的音訊時鐘,把每一下鼓提前交給鼓組,並在聽到的時候才告訴頁面每一步和每一下。 DrumScheduler 純粹的鼓的邏輯,沒有計時器也沒有聲音:放進一張格子圖或一行節奏,就提前吐出有時間的事件,時鐘由你傳進來。 PATTERNS 鼓機內建的五個預設格子圖:'four on floor'、breakbeat、'half time'、latin、sparse。 laneSteps() 把寫成文字的軌道,像 'x...x...x...x...',或寫成陣列的軌道,變成 16 格 0 和 1。 mountDrums() 在頁面上放一台鼓機:步進格子、播放控制,和一行可以直接打字修改的節奏。它把每一下鼓發布成 drum/* 訊號。 GM 鼓組裡每種鼓對應的 General MIDI 鼓音符:GM.kick 是 36,GM.snare 是 38。 import { DrumSequencer } from '@openav/drums'; packages/drums/index.js 第 210 行
DrumTransport#
類別 @openav/drums new DrumTransport({ scheduler = new DrumScheduler(), engine = null , onStep = null , onHit = null , onLate = null , autoEnable = false , now = perfNow, hidden = pageHidden, heartbeat = 'auto' , tick = 0.025, latency = 'auto' , stale = 0.25, startDelay = 0.05 } = {})
包在 DrumScheduler 外面的執行環境:它用鼓組的音訊時鐘,把每一下鼓提前交給鼓組,並在聽到的時候才告訴頁面每一步和每一下。
在到時間的那個畫面才打的鼓,最多會晚一個畫面,分頁被藏起來的時候還會停。DrumTransport 兩個都解決。一個計時器(心跳,預設用 Web Worker,每 25 毫秒一次)在鼓組自己的音訊時鐘上往前看 0.1 秒,把每一下鼓連同確切的開始時間交給鼓組。頁面則是另外被通知,在每一步、每一下被聽到 的那個畫面:它的時間加上瀏覽器回報的輸出延遲。所以畫面很重或分頁被藏起來,節拍還是穩的,回呼也依然按順序到:一步在它的鼓之前,鼓依時間排序。
呼叫 start(),然後每個動畫畫面呼叫一次 frame()。心跳會替你呼叫 pump()。速度和節奏的改變(setBpm()、setPattern()、toggleCell())會把現在之後已經交出去的收回來重做,所以不會有鼓重複或漏掉。鼓組的 AudioContext 還沒跑(還沒點擊)的時候,時鐘用 now() 跑,回呼照常執行但沒有聲音。開始跑之後,從那之後的鼓才會響,之前的不補。
頁面被藏起來,或畫面迴圈追趕時已經晚了超過 stale 秒的時候,步會被安靜地告知:ev.quiet 是 true,時鐘照走、cue 照發,但 onHit 不會被呼叫,而且同時到的好幾個小節開頭,只有最後一個有 ev.announce。分頁回來的時候不會一下子湧進一大堆。如果某個 draw() 佔住執行緒的時間比預讀還長,預讀會增加到 0.3 秒(info().extended)。
它有它的排程器的 getter:playing、pattern、grid、mode、text、bpm、swing、humanize、lookahead。bpm、swing、humanize 也可以設定。通常是透過 clock: 'lookahead' 的 mountDrums() 拿到一個。想要時鐘而不要卡片的時候再自己建。
參數 名稱 型別 說明 schedulerDrumScheduler決定什麼時候打什麼的 DrumScheduler 。 可省略 預設 new DrumScheduler() engineObject發聲的引擎,通常是 drumEngine() 。它有 context 這個屬性(一個 AudioContext,啟用之前是 null)的話,鼓點就用那個時鐘提前排程。沒有的話,每一下鼓就在聽到的那一刻呼叫 engine.noteOn(note, vel) 來打。 可省略 預設 null onStepFunctiononStep(i, n, ev),每個十六分音符步在聽到的時候呼叫。ev.quiet 和 ev.announce 說明它是不是被安靜地告知(見下面)。 可省略 預設 null onHitFunctiononHit(voice, vel, ev),每一下鼓在聽到的時候呼叫。 可省略 預設 null onLateFunctiononLate(margin, transport):因為頁面佔住主執行緒的時間比預讀還長,有一下鼓交給鼓組的時候已經過了它的時間。margin 是它早了幾秒(負的或很小)。 可省略 預設 null autoEnableBoolean引擎還沒有 AudioContext 的話,在 start() 時呼叫 engine.enable()。 可省略 預設 false nowFunction回傳目前時間(秒)的函式。測試時傳自己的,就能用假的時鐘帶動傳輸控制。 可省略 預設 performance.now() / 1000 hiddenFunction沒有人看得到頁面時回傳 true 的函式。 可省略 預設 checks document.visibilityState heartbeat'auto' | 'interval' | false誰來呼叫 pump():'auto' 是 Web Worker 的計時器(被藏起來的分頁不會讓 worker 變慢),不行就退回 setInterval。'interval' 一律用 setInterval。false(或 'none')是沒人叫,你自己呼叫 pump() 或 frame()。 可省略 預設 'auto' tickNumber兩次心跳之間隔幾秒。 可省略 預設 0.025 latency'auto' | Number一下鼓從排程到聽到之間隔幾秒。'auto' 向瀏覽器問(getOutputTimestamp()、outputLatency)。給數字就是手動設定。 可省略 預設 'auto' staleNumber一步如果被告知的時候已經晚了超過這麼多秒(畫面迴圈在追趕),就會被安靜地告知。 可省略 預設 0.25 startDelayNumber音訊時鐘在跑的時候,第一個強拍會放在 start() 之後這麼多秒,這樣第一下才不會已經晚了。 可省略 預設 0.05
成員 名稱 說明 playinggetter Getter。鼓機有沒有在打。 patterngetter Getter。預設的名稱,自己的格子圖是 'custom',一行節奏的話就是那行文字。 gridgetter Getter。格子圖 { kick: [0|1 × 16], snare, hat, clap }。一行節奏的話,是第 1 小節放在格子上的樣子。 modegetter Getter。'grid' 或 'string'(編譯過的一行節奏)。 textgetter Getter。正在打的那一行節奏,格子圖的話是 ''。 bpmgetter Getter 和 setter。速度。設定它就是 setBpm()。 swinggetter Getter 和 setter。0 到 0.5:每個奇數的十六分音符晚多少,單位是一個十六分音符的幾分之幾。設定它會把已排程的鼓重做。 humanizegetter Getter 和 setter。0..1,幾毫秒的遲到和一點力道的變化。設定它會把已排程的鼓重做。 lookaheadgetter Getter。提前交給鼓組的秒數(0.1,有一下鼓太晚之後會增加到 0.3)。 stepDur()method 現在的速度下,一個十六分音符多少秒:60 / bpm / 4。 times()method 總時間軸,單位是秒:{ sched, heard, live, ct }。sched 是鼓點最早還能交給鼓組的時間,heard 是聽的人現在聽到的時間,live 是音訊時鐘有沒有在跑。沒在跑的時候用的是 now()。 start()method 從第 1 小節開始(已經在打的話就重來)並回傳 true。音訊時鐘在跑的時候,第一個強拍在 startDelay 秒之後。會啟動心跳。 stop()method 停止,把交給鼓組但還沒開始的收回來,並停掉心跳。回傳 false。 toggle(on = !this.playing)method toggle(on) 開始或停止。不給參數就是反過來。開始的時候從第 1 小節起。setPattern(p, o)method 預設名稱、你自己的格子圖,或編譯過的一行節奏(用 { text } 帶上它原本那行字)。改動會馬上聽到。回傳新的 pattern。 toggleCell(lane, i)method toggleCell(lane, i) 切換格子上的一格(lane 是 kick、snare、hat、clap,i 是 0..15)。如果在打的是一行節奏,會先換成它打出來的格子圖。takeover(bar)method 把一行節奏換成它在第 bar 小節打出來的格子圖,這樣格子就可以編輯。回傳 { grid, offGrid, dropped }。 setBpm(v)method 從現在起換成新的速度。現在之後已經交給鼓組的會收回來,用新速度重做。再給同一個速度不會有任何動作。回傳速度。 rewind()method 把現在之後交給鼓組的全部收回來(留 10 毫秒的餘裕,所以已經在響的不會被切斷),再依現在的節奏和速度重做。setter 會替你呼叫它。 pump()method 心跳:把接下來的鼓交給鼓組。頁面被藏起來,或超過 0.1 秒沒有畫面在跑的時候,它也會把聽到的告訴頁面。只有 heartbeat: false 時才需要自己呼叫。 frame()method 每個動畫畫面呼叫一次。把該交的交出去,然後透過 onStep 和 onHit 告訴頁面到現在為止聽到的每一步和每一下。回傳這些事件。 phase()method { bar, beat, pos } 聽的人在哪裡:在小節和拍子裡走了多遠(0..1),以及從開始算起的小節數。停止的時候它不動。info()method 時鐘的狀況:{ clock, heartbeat, why, lookahead, late, extended, audio, latency, skipped, warning }。heartbeat 是 'worker'、'interval' 或 'none',late 是交得太晚的鼓的數量。 dispose()method 停止鼓機。 heardAt(at)method now 時鐘的某個時刻 at,對應到排程器時間軸(聽到的時間)上的哪裡。給 follow() 用。follow({ pos = 0, at, bpm } = {}, { catchUp = 0.5, jump = 0.125, pull = 0.08, still = 0.0005 } = {})method 跟著外部時鐘打(Strudel、DAW、連結的頁面):follow({ pos, at, bpm }) 表示位置 pos(從它開始算起的小節數,0 是第 1 小節的強拍)在 at(now 時鐘,秒)被聽到,速度是 bpm。停著的時候,它會這樣開始:讓第 1 小節落在外部時鐘的 0。正在打的時候,誤差超過 jump 小節(0.125)就用同樣的方式重新開始。比較小的誤差用 catchUp 秒的時間,靠稍微快一點或慢一點的速度(最多 pull,0.08)補回來,然後回到外部速度。誤差小於 still(0.0005 秒)就不處理。第二個參數是 { catchUp, jump, pull, still }。回傳誤差,單位是秒(正數表示鼓機落後)。外部時鐘每有新的讀數就呼叫一次。 followInfo()method 跟隨的時候是 { bpm, err, jumps, steers, at }(err 是最後一次修正的秒數),從來沒跟隨過就是 null。 unfollow()method 停止跟隨。鼓機保持現在的速度,用自己的時鐘打下去。
範例
js 複製
import { DrumScheduler, DrumTransport } from '@openav/drums' ;
let T = 100; // a clock we move by hand, in seconds
const log = [];
const transport = new DrumTransport({
scheduler: new DrumScheduler({ bpm: 120, humanize: 0 }),
heartbeat: false , now: () => T, // no timer, our own clock
onStep: (i) => { if (i % 4 === 0) log.push('step ' + i); },
onHit: (voice, vel) => { if (voice !== 'hat' ) log.push(voice); },
});
transport.start();
for (let k = 0; k < 40; k++) { T += 1 / 20; transport.frame(); } // two seconds in 20 fps frames
console.log(log.join(', ' ));
// step 0, kick, step 4, kick, snare, step 8, kick, step 12, kick, snare
console.log(transport.playing, transport.phase().bar.toFixed(2)); // true 1.00
module 在我們手動撥動的時鐘上跑完一整個小節的四四拍,印出頁面被告知的事:每一拍的強拍、大鼓,和第 2、4 拍的小鼓。
js 複製
import { DrumScheduler, DrumTransport, drumEngine } from '@openav/drums' ;
const kit = drumEngine();
const transport = new DrumTransport({
scheduler: new DrumScheduler({ bpm: 100, swing: 0.1, pattern: 'breakbeat' }),
engine: kit, autoEnable: true , // the click that starts it also starts the audio
onHit: (voice, vel) => console.log(voice, vel.toFixed(2)),
});
document.body.addEventListener('click' , () => transport.start(), { once: true });
(function frame() { transport.frame(); requestAnimationFrame(frame); })();
static 在頁面上點一下,breakbeat 就用鼓組的音訊時鐘開始打。console 在聽到每一下的時候印出來。切到別的分頁:節拍照樣繼續。
相關 DrumScheduler 純粹的鼓的邏輯,沒有計時器也沒有聲音:放進一張格子圖或一行節奏,就提前吐出有時間的事件,時鐘由你傳進來。 mountDrums() 在頁面上放一台鼓機:步進格子、播放控制,和一行可以直接打字修改的節奏。它把每一下鼓發布成 drum/* 訊號。 drumEngine() 一組用 Web Audio 合成的鼓組,沒有取樣檔:送進 General MIDI 的鼓音符,就打出鼓聲。 DrumSequencer 一個 16 步、四條軌道的鼓節奏時鐘,每個畫面用 update(dt) 往前推,每一下鼓就呼叫你的函式。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 drumPhase 鼓機走到這一小節的哪裡,從 0 平滑地增加到快要 1,是你耳朵聽到的位置。 import { DrumTransport } from '@openav/drums'; packages/drums/index.js 第 473 行
GM = object, 13 keys
鼓組裡每種鼓對應的 General MIDI 鼓音符:GM.kick 是 36,GM.snare 是 38。
打擊墊和鼓機在 MIDI 頻道 10 送這些音符。鼓機每打一下就送一個 midi/note/on { note, vel, ch: 10 },所以任何聽 MIDI 的東西都聽得到鼓。從音符反查是哪種鼓用 voiceForNote() ,它也認得 General MIDI 相鄰的音符(35 也是大鼓,40 也是小鼓)。
型別 Object{ kick: 36, rim: 37, snare: 38, clap: 39, lowtom: 41, hat: 42, pedalhat: 44, tom: 45, openhat: 46, hightom: 48, crash: 49, ride: 51, cowbell: 56 }:鼓的名字對應 MIDI 音符號碼。
範例
js 複製
import { GM, voiceForNote } from '@openav/drums' ;
console.log(GM.kick, GM.snare, GM.hat); // 36 38 42
console.log(Object.keys(GM).length); // 13
console.log(voiceForNote(GM.crash)); // crash
module 印出三種鼓的音符、鼓組有幾種鼓,以及鼓的音符可以反查回那種鼓。
相關 voiceForNote() 告訴你一個 General MIDI 鼓音符會打鼓組裡的哪一種鼓,不打的音符回傳 null。 drumEngine() 一組用 Web Audio 合成的鼓組,沒有取樣檔:送進 General MIDI 的鼓音符,就打出鼓聲。 WORDS 一行節奏可以用的鼓的字,以及每個字打哪一種鼓:bd 是 kick,sd 是 snare,hh 是 hat。 drumHit() 一個由你定義的事件函式:鼓機每打一下就會呼叫它,告訴你是哪一種鼓、打得多用力。 import { GM } from '@openav/drums'; packages/drums/index.js 第 23 行
laneSteps()#
函式 @openav/drums laneSteps(v)
把寫成文字的軌道,像 'x...x...x...x...',或寫成陣列的軌道,變成 16 格 0 和 1。
你寫的每一張格子圖都是這樣被讀的:DrumSequencer 、DrumScheduler 和 startDrums() 裡的 { kick: 'x...x...x...x...' }。'x... x... x... x...' 和 '1000100010001000' 是同一條軌道。注意點在這裡是休止,只有在這裡:一行節奏(見 compile() )裡的休止是 ~。
參數 名稱 型別 說明 vString | Array軌道。文字的話,x、X、1 是鼓點,其他都是休止。空白和 | 會被忽略,可以用來幫助眼睛。陣列的話,1 和 true 是鼓點。
回傳 Number[] 一個 16 個數字的陣列,1 是鼓點、0 是休止。太短的軌道用休止補滿,太長的在第 16 格截掉。
範例
js 複製
import { laneSteps } from '@openav/drums' ;
console.log(laneSteps('x... x... x... x...' ).join('' )); // 1000100010001000
console.log(laneSteps([1, 0, true ]).join('' )); // 1010000000000000
console.log(laneSteps('x' ).length, laneSteps('x' .repeat(40)).length); // 16 16
module 印出用空白寫的軌道、用陣列寫的軌道,以及不管多長出來都是 16 格。
相關 PATTERNS 鼓機內建的五個預設格子圖:'four on floor'、breakbeat、'half time'、latin、sparse。 DrumSequencer 一個 16 步、四條軌道的鼓節奏時鐘,每個畫面用 update(dt) 往前推,每一下鼓就呼叫你的函式。 DrumScheduler 純粹的鼓的邏輯,沒有計時器也沒有聲音:放進一張格子圖或一行節奏,就提前吐出有時間的事件,時鐘由你傳進來。 fromGrid() 把一張格子圖,像鼓機卡上的那種,轉成一個人會為它寫的那一行節奏。 import { laneSteps } from '@openav/drums'; packages/drums/index.js 第 199 行
mountDrums()#
函式 @openav/drums mountDrums(container, { signals, engine = null , autoEnableEngine = true , onStep = null , onHit = null , onTakeover = null , bpm = 112, pattern = 'four on floor' , clock = 'frame' , lang = 'en' , heartbeat = 'auto' , latency = 'auto' , now, hidden, rhythm = null , onRhythm = null , onLossy = null , onEditError = null , copyText = null , strudelComment = '' } = {})
在頁面上放一台鼓機:步進格子、播放控制,和一行可以直接打字修改的節奏。它把每一下鼓發布成 drum/* 訊號。
mountDrums(el, { signals }) 在 el 裡蓋一張鼓機卡:播放勾選框、預設選單、速度滑桿、現在在第幾小節,和一個每種鼓一條軌道的格子(大鼓、小鼓、鈸、拍手,一行節奏用到的碎音鈸、疊音鈸、中鼓、開鈸各有自己的一列)。點一個格子就切換它。回傳的物件讓你用程式做卡片上用滑鼠做的事:play(true)、setPattern('breakbeat')、setBpm(96)。
每個畫面都要呼叫 drums.update(dt) ,參數是距離上一個畫面過了幾秒。它讓鼓機前進(用 frame 時鐘的時候)、把聽到的事告訴你的 onStep 和 onHit、畫格子,並讓 drum/*/env 訊號衰減。從來不更新的鼓機不會動。
鼓點走的路跟麥克風聽到的鼓手一樣,所以為麥克風寫的草圖不用改就能換成它:drum/kick、drum/snare、drum/hat、drum/clap 是帶 { level } 的脈衝,drum/<voice>/env 是 0..1 的連續值,還有 midi/note/on { note, vel, ch: 10 },音符是 General MIDI 的鼓音符(見 GM )。一行節奏多出來的鼓,像 drum/crash,會在第一次被打到的時候才定義。
把鼓的模組(rhythm)交給它,格子上面就一直有那一行節奏:預設會顯示它的格子圖是哪一行('four on floor' 就是 bd*4, [~ sd]*2, hh*8),正在響的字會亮起來。接著兩邊互相修改。點一個格子會改寫那一行(用 fromGrid() )。在那一行打字會改變格子:按 Enter,或停手 400 毫秒,就播放打進去的內容。有錯的地方標紅、通知 onEditError,上一行能用的繼續打。按 Escape 丟掉還沒播的。每次改動都存在 history。卡上的 ↶ ,或碰過卡片之後按 Cmd/Ctrl+Z,退一步。Copy 把那一行放進剪貼簿,Open in Strudel 在新分頁的 strudel.cc 打開它(strudelUrl() )。
回傳的物件提供這些:
text
play(on) 像勾選框那樣開始(true)或停止(false)。不給參數就是反過來。回傳現在有沒有在打
setPattern(p, {text}) 預設名稱、你自己的格子圖,或編譯好的一行(text:它原本的那行字)。卡片會跟著變
setBpm(bpm) 速度,像滑桿。回傳速度
update(dt) 每個畫面都要呼叫
deliver() 把現在聽到的告訴 onStep/onHit,但不畫圖(在你畫圖之前呼叫)
phase() { bar, beat, pos }:聽的人在小節和拍子的哪裡,0..1
now() { bar, step, phase, beatPhase, tokens, mode, text },給畫面顯示用
info() { clock, heartbeat, lookahead, late, extended, audio, latency, ... } 時鐘的健康狀況
useRhythm(mod) 之後再把 @openav/pattern 交給它(它可以自己載入)
src() 現在在打的那一行(只有格子又沒有節奏模組的時候是 '')
strudelUrl() 那一行變成 strudel.cc 連結,沒有就是 ''
edit(text) 像打字一樣播放一行:{ ok, error }。有錯就繼續打上一行
history, undo() 之前的幾行,舊的在前。undo 退一步,回傳有沒有退成
copy() 把 copyText() 放進剪貼簿:Promise<{ ok, text }>
barEvents(bar) 某一小節的鼓點:[{ voice, pos, ... }]
voices() 現在格子上有哪幾列
seq, engine, root DrumSequencer 或 DrumTransport、發聲引擎、卡片的元素
dispose() 停止、釋放鼓組並把卡片拆掉
呼叫之前
需要有 DOM 的頁面,而且每個畫面都要呼叫 update(dt)。聲音要等瀏覽器:鼓組的 AudioContext 在第一下鼓(用 lookahead 時鐘的話是 play())的時候才建立,頁面被點一下之前它都是暫停的。回呼和訊號從一開始就會跑。聲音在第一次點擊之後才進來,點擊之前的鼓不會補打。
參數 名稱 型別 說明 containerHTMLElement要放鼓機卡的元素。dispose() 會把它拆掉。 signalsSignals一個 Signals 匯流排。每一下鼓都會發布成 drum/<voice>(脈衝 { level })、drum/<voice>/env(0..1,會衰減)和 midi/note/on { note, vel, ch: 10 }。沒給的話鼓機照樣會打、會呼叫你的函式,但什麼都不發布。 可省略 engineObject發聲的引擎。沒給的話,鼓機自己做一個 drumEngine() 。任何有 enable() 和 noteOn(note, vel01, time) 的東西都可以。 可省略 預設 null autoEnableEngineBoolean讓第一下鼓(用 lookahead 時鐘的話是 play())自己把引擎打開,所以除了瀏覽器要的那一次點擊,不用再多一次點擊。 可省略 預設 true onStepFunctiononStep(i, n, ev),每個十六分音符步在聽到的時候呼叫一次。i 是小節裡的 0..15,n 是從開始算起的步數,在那一步的鼓之前呼叫。時間軸就是靠它數小節。 可省略 預設 null onHitFunctiononHit(voice, vel, ev),每一下鼓在聽到的時候呼叫一次。voice 是像 'kick' 的名字,vel 是 0..1。 可省略 預設 null onTakeoverFunctiononTakeover({ text, bar, offGrid, dropped, varied }):沒有 rhythm 模組的時候,點一個格子把一行節奏換成它正在打的格子圖。offGrid 個鼓點被移到最近的一格,dropped 是沒有對應列的鼓。 可省略 預設 null bpmNumber一開始的速度,每分鐘幾拍。卡上的滑桿範圍是 60 到 180。 可省略 預設 112 patternString | ObjectPATTERNS 裡的預設名稱,或你自己的格子圖 { kick: 'x...x...', … }。不是一行節奏:要用一行節奏開始,就呼叫 drums.edit(line) 或 drums.setPattern(compile(line), { text: line })。 可省略 預設 'four on floor' clock'frame' | 'lookahead''frame':鼓機在 update(dt) 裡前進,每一下在到時間的那個畫面響。'lookahead':鼓點提前交給鼓組,用它的音訊時鐘排程,所以畫面很重或分頁被藏起來時節拍還是穩的('audio' 是同一個意思)。見 DrumTransport 。 可省略 預設 'frame' lang'en' | 'zh''zh' 會用中文為軌道和按鈕命名(大鼓 小鼓 鈸 拍手)。其他的值都是英文。 可省略 預設 'en' heartbeat'auto' | 'interval' | false只在 clock: 'lookahead' 時有用。誰來叫醒排程器:'auto' 是 Web Worker 的計時器(worker 起不來的地方退回 setInterval),'interval' 一律用 setInterval,false 是沒人叫(你要自己夠頻繁地呼叫 drums.update())。 可省略 預設 'auto' latency'auto' | Number只在 clock: 'lookahead' 時有用。一下鼓排程之後多久才聽到,單位是秒。'auto' 向瀏覽器問輸出延遲。給數字就是手動設定(藍牙喇叭是 0.15 到 0.25)。 可省略 預設 'auto' nowFunction只在 clock: 'lookahead' 時有用。回傳目前時間(秒)的函式。預設是 performance.now() / 1000。測試時換掉它,就能讓鼓機跑在假的時鐘上。 可省略 hiddenFunction只在 clock: 'lookahead' 時有用。沒有人看得到頁面時回傳 true 的函式。預設看的是 document.visibilityState。 可省略 rhythmModule@openav/pattern 模組(import * as rhythm from '@openav/pattern',只要有 compile 和 fromGrid 就行)。有了它,格子上面那一行節奏就一直在、可以編輯,格子和那一行會互相改寫。也可以之後再用 drums.useRhythm(mod) 交給它。 可省略 預設 null onRhythmFunctiononRhythm(line, { how, prev }),每一行新的節奏呼叫一次。how 是 'code'、'cell'、'type'、'undo'、'menu' 其中之一。 可省略 預設 null onLossyFunctiononLossy({ text, bar, lost }):點一個格子會損失 16 格放不下的東西(三連音、< >、?、bd:2)。lost 跟 toGrid() 的一樣。沒給的話,卡上會出現一行提示。再點一次就會照樣改寫那一行。 可省略 預設 null onEditErrorFunction打進去的不是一行節奏的時候呼叫 onEditError(error, text)(錯的地方標紅,上一行能用的繼續打),修好之後呼叫 onEditError(null)。 可省略 預設 null copyTextFunctioncopyText(line, bpm) 回傳「複製」按鈕要放進剪貼簿的文字。預設就是那一行本身。 可省略 預設 null strudelCommentString要放在「在 Strudel 打開」送出去的程式碼最前面的註解。 可省略 預設 ''
回傳 Object { seq, engine, root, clock, play(on), setPattern(p, { text }), setBpm(bpm), update(dt), deliver(), phase(), now(), info(), dispose(), useRhythm(mod), src(), strudelUrl(), history, undo(), edit(text), copy(), copyText(), barEvents(bar), voices() }(還有 getter rhythm 和 copied)。
注意 用 clock: 'frame' 的時候,編譯過的一行只有第一小節會在四條軌道、十六格的格子上打:落在格子之間的鼓點會移到最近的一格,沒有軌道的鼓(碎音鈸、疊音鈸、中鼓、開鈸)會被略過,console 會說一聲。clock: 'lookahead' 則照寫的樣子打。 在 p5 草圖裡不用自己呼叫:startDrums() 會替你蓋這張卡,用 lookahead 時鐘,並且每個畫面幫你呼叫 update()。 範例
js 複製
import { mountDrums } from '@openav/drums' ;
const drums = mountDrums(document.getElementById('app' ), { bpm: 120, pattern: 'breakbeat' });
drums.play(true ); // start, like ticking the checkbox
let last = performance.now();
(function frame(now) {
drums.update((now - last) / 1000); // the machine only moves when you call this
last = now;
requestAnimationFrame(frame);
})(last);
module 頁面上出現一張鼓機卡,打著 breakbeat,黃色的那一欄沿著格子跑。在頁面上點一下才聽得到鼓組。點格子就能改節奏。
js 複製
import * as rhythm from '@openav/pattern' ;
import { mountDrums } from '@openav/drums' ;
const told = [];
const drums = mountDrums(document.getElementById('app' ), {
rhythm, bpm: 110, clock: 'lookahead' ,
onRhythm: (line, { how }) => told.push(how + ': ' + line),
onEditError: (error) => told.push(error ? 'error ' + error.code : 'fixed' ),
});
console.log(drums.src()); // bd*4, [~ sd]*2, hh*8
console.log(drums.edit('bd(3,8), cr ~ ~ ~' ).ok); // true: the crash gets a row of its own
console.log(drums.edit('bd [sd' ).ok); // false: the last line that worked keeps playing
console.log(drums.src()); // bd(3,8), cr ~ ~ ~
console.log(drums.undo(), drums.src()); // true bd*4, [~ sd]*2, hh*8
console.log(told.join(' | ' ));
// code: bd*4, [~ sd]*2, hh*8 | type: bd(3,8), cr ~ ~ ~ | fixed | error unclosed | undo: bd*4, [~ sd]*2, hh*8 | fixed
let last = performance.now();
(function frame(now) { drums.update((now - last) / 1000); last = now; requestAnimationFrame(frame); })(last);
module 有了節奏模組,格子上面就會顯示那一行。程式替你打了兩行,其中一行是錯的,然後退回去。你自己在那一行打字,格子會跟著變。
js 複製
import { mountDrums } from '@openav/drums' ;
import { Signals } from '@openav/core' ;
const signals = new Signals();
const g = document.getElementById('c' ).getContext('2d' );
const drums = mountDrums(document.getElementById('app' ), { signals, clock: 'lookahead' , bpm: 100 });
drums.play(true );
let last = performance.now();
(function frame(now) {
drums.update((now - last) / 1000); last = now;
g.fillStyle = '#111' ; g.fillRect(0, 0, 400, 300);
g.fillStyle = '#ff7b54' ;
g.beginPath();
g.arc(200, 150, 30 + signals.get ('drum/kick/env' ) * 90, 0, Math.PI * 2); // 0..1, decays after each hit
g.fill();
requestAnimationFrame(frame);
})(last);
module 畫布上的圓隨著每一下大鼓脹大:鼓機發布 drum/kick/env,草圖只要讀這個訊號。
待確認
藍牙喇叭的 0.15 到 0.25 秒(latency 選項)這個數字來自手冊(docs/p5.md),這裡沒有實際量過。
相關 DrumTransport 包在 DrumScheduler 外面的執行環境:它用鼓組的音訊時鐘,把每一下鼓提前交給鼓組,並在聽到的時候才告訴頁面每一步和每一下。 DrumSequencer 一個 16 步、四條軌道的鼓節奏時鐘,每個畫面用 update(dt) 往前推,每一下鼓就呼叫你的函式。 drumEngine() 一組用 Web Audio 合成的鼓組,沒有取樣檔:送進 General MIDI 的鼓音符,就打出鼓聲。 compile() 把一行節奏文字,像 'bd*4, [~ sd]*2, hh*8',讀成任何一個小節要打哪些鼓。它不會丟出錯誤。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 drumHit() 一個由你定義的事件函式:鼓機每打一下就會呼叫它,告訴你是哪一種鼓、打得多用力。 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 import { mountDrums } from '@openav/drums'; packages/drums/index.js 第 830 行
PATTERNS#
常數 @openav/drums PATTERNS = object, 5 keys
鼓機內建的五個預設格子圖:'four on floor'、breakbeat、'half time'、latin、sparse。
預設的名稱就是 startDrums() 、DrumSequencer 、mountDrums() 的 pattern 收的值。預設就只是資料,所以你可以從其中一個開始改:{ ...PATTERNS.latin, clap: 'x...x...x...x...' } 就是在 latin 上每一拍加一下拍手。
五個預設的內容,一欄一條軌道:
text
kick snare hat clap
four on floor 1000100010001000 0000100000001000 1010101010101010 0000000000000000
breakbeat 1000000110100000 0000100000001001 1010101010101010 0000100000000000
half time 1000000000001000 0000000010000000 1010101010101010 0000000010000000
latin 1000001000100000 0010010000100100 1011101110111011 0000000000000000
sparse 1000000000100000 0000000000000000 0010001000100010 0000000010000000
型別 Object五個預設,每個都是 { kick, snare, hat, clap },各是 16 個字的 1 和 0。
範例
js 複製
import { PATTERNS, DrumSequencer } from '@openav/drums' ;
console.log(Object.keys(PATTERNS).join(' | ' )); // four on floor | breakbeat | half time | latin | sparse
console.log(PATTERNS['half time' ].snare); // 0000000010000000
const seq = new DrumSequencer({ onHit() {} });
seq.setPattern({ ...PATTERNS.latin, clap: 'x...x...x...x...' }); // latin plus a clap on every beat
console.log(seq.pattern, seq.grid.clap.join('' )); // custom 1000100010001000
module 列出預設、讀其中一條軌道,並把 latin 展開成新的格子圖,做出一個變化版。
相關 laneSteps() 把寫成文字的軌道,像 'x...x...x...x...',或寫成陣列的軌道,變成 16 格 0 和 1。 DrumSequencer 一個 16 步、四條軌道的鼓節奏時鐘,每個畫面用 update(dt) 往前推,每一下鼓就呼叫你的函式。 DrumScheduler 純粹的鼓的邏輯,沒有計時器也沒有聲音:放進一張格子圖或一行節奏,就提前吐出有時間的事件,時鐘由你傳進來。 mountDrums() 在頁面上放一台鼓機:步進格子、播放控制,和一行可以直接打字修改的節奏。它把每一下鼓發布成 drum/* 訊號。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 import { PATTERNS } from '@openav/drums'; packages/drums/index.js 第 173 行
voiceForNote()#
函式 @openav/drums voiceForNote(note)
告訴你一個 General MIDI 鼓音符會打鼓組裡的哪一種鼓,不打的音符回傳 null。
打擊墊和鼓機在頻道 10 送音符,好幾個音符會對到同一種鼓:35 和 36 是大鼓,38 和 40 是小鼓,41 和 43 是落地鼓,45 和 47 是中鼓,48 和 50 是高音中鼓,49、52、55、57 是碎音鈸,51、53、59 是疊音鈸。不在表裡的音符,像旋律用的 60,會回傳 null:鼓組寧可不出聲也不亂猜,因為大鼓的打擊墊打出錯的鈸,比沒聲音還糟。
參數 名稱 型別 說明 noteNumberMIDI 音符號碼。
回傳 String | null 鼓的名字('kick'、'crash'…),或 null。
範例
js 複製
import { voiceForNote } from '@openav/drums' ;
console.log(voiceForNote(36), voiceForNote(35)); // kick kick
console.log(voiceForNote(49), voiceForNote(57)); // crash crash
console.log(voiceForNote(60)); // null, a melodic note
module 印出幾個音符打哪一種鼓:兩個音符對同一種鼓,以及一個不打任何鼓的旋律音符。
相關 GM 鼓組裡每種鼓對應的 General MIDI 鼓音符:GM.kick 是 36,GM.snare 是 38。 drumEngine() 一組用 Web Audio 合成的鼓組,沒有取樣檔:送進 General MIDI 的鼓音符,就打出鼓聲。 WORDS 一行節奏可以用的鼓的字,以及每個字打哪一種鼓:bd 是 kick,sd 是 snare,hh 是 hat。 import { voiceForNote } from '@openav/drums'; packages/drums/index.js 第 34 行
@openav/pattern
compile()#
函式 @openav/pattern compile(src, { words = WORDS } = {})
把一行節奏文字,像 'bd*4, [~ sd]*2, hh*8',讀成任何一個小節要打哪些鼓。它不會丟出錯誤。
要把一行節奏變成鼓點,用 compile() 就夠了。compile('bd sd hh').eventsInBar(1) 回答「第 1 小節發生什麼事」,給你一串按 pos 排好的鼓點。小節從 1 開始數,一個小節就是這一行的一個循環:字平分這個小節,所以 bd sd hh 是大鼓在 0、小鼓在 1/3、鈸在 2/3。
eventsInBar(bar, { seed }) 給的每個鼓點是 { word, voice, n, pos, dur, vel, layer, token }。word 是寫的字,voice 是它打的鼓('kick'),pos 是在小節的哪裡,從 0 到(不含)1。dur 是持續多久,單位是小節,可以超過這個小節(bd/2 長兩個小節)。vel 永遠是 1。layer 是它來自第幾個逗號層。token 是 { start, end },這個字在 src 裡的字元範圍,字在響的時候可以拿來點亮它。n 是 bd:2 的取樣編號,沒寫就是 null。n 會保留給你,但不會改變聲音。
它不會丟出錯誤。讀不懂的一行會回傳 { ok: false, error },而且每個小節的 eventsInBar() 都回傳 [],所以相信鼓點之前先檢查 ok。error.index 和 error.length 標出錯的字元,error.message 和 error.hint 是英文句子,error.code 是 empty、unclosed、unexpected、bad-number、bad-euclid、unknown-word、unsupported 其中之一。unknown-word 錯誤還會帶 suggestion,也就是最接近的字,沒有就是 null。可以寫的字和符號列在下面。
text
bd sd hh 字平分一個小節
~ 或 - 休止
[bd sd] 一組佔一格,再平分那一格
bd*4 加快:在它那一格裡打 4 次(小數也行:bd*1.5)
bd/2 放慢:每 2 個小節打一次
<bd sd> 每小節輪一個
bd, hh*8 逗號把幾層疊在一起
bd(3,8) 歐幾里得節奏:3 下平均分在 8 格
bd(3,8,2) 一樣,但晚 2 格開始
bd!2 bd ! 重複這一格(每個複本各佔一格)
bd@3 bd _ _ 這一格拉長成 3 倍
hh*8? hh*8?0.3 每一下用機率省略(1/2 或 0.3)
bd:2 取樣編號,存在 n修飾符號照寫的順序一個接一個(bd(3,8)*2、hh*8?)。字的大小寫不影響(BD 就是 bd)。? 由 seed 和鼓點的時間決定,所以同一個 seed、同一個小節永遠打出一樣的鼓點,換一個小節或換一個 seed 就不同。不支援,並會用 unsupported 錯誤和提示拒絕的有:.(Strudel 的分組)、|、{ }、%、..、^。其中最重要的是點:像 'x...x...' 這種軌道字串不是一行節奏,休止要寫 ~。
參數 名稱 型別 說明 srcString一行節奏,例如 'bd sd hh'。不是字串的東西會被轉成字串(null 和 undefined 變成空的一行,那是錯誤)。 wordsObject哪個字代表哪一種鼓:{ 字: 鼓聲 }。預設是 WORDS 。要多加一個字,就把預設展開:{ ...WORDS, boom: 'kick' }。鼓聲要是鼓組會打的,見 GM 。 可省略 預設 WORDS
回傳 Object { ok, error, src, voices, layers, period, eventsInBar(bar, { seed }) }。ok 是 true 表示這一行讀懂了。error 是 null,或 { code, index, length, message, hint }。voices 列出這一行用到的鼓,依第一次出現的順序。layers 是逗號分出來的層數。period 是寫出來的節奏要幾個小節才會重複。
注意 內部的時間用精確的分數,每個小節各自計算。[bd sd sn]*3 的第 1 000 000 小節跟第 1 小節落在一模一樣的九分之幾,問第 500 小節跟問第 2 小節一樣便宜。 在 p5 草圖裡不用自己呼叫:startDrums() 收下這一行,會替你編譯。想把鼓點當資料用,例如畫譜、畫圖或寫測試的時候,才自己呼叫 compile()。 這種寫法來自 TidalCycles 和 Strudel,這個套件是依他們的公開文件獨立實作的。刻意跟 strudel.cc 不同的地方(旋轉 n 格以上會繞回來、*0 和 (9,8) 是錯誤、不認識的字是錯誤),套件的 README 都有寫。 範例
js 複製
import { compile } from '@openav/pattern' ;
const p = compile('bd sd hh' );
for (const e of p.eventsInBar(1)) console.log(e.word, e.voice, e.pos.toFixed(2));
// bd kick 0.00
// sd snare 0.33
// hh hat 0.67
module 印出第 1 小節的三個鼓點:字、它打的鼓、在小節的哪裡。打開 console 就看得到。
js 複製
import { compile } from '@openav/pattern' ;
const p = compile('<bd sd>, hh*4' );
console.log(p.period); // 2
for (const bar of [1, 2, 3]) {
const words = p.eventsInBar(bar).filter((e) => e.layer === 0).map((e) => e.word);
console.log('bar' , bar, words.join(' ' ));
}
// bar 1 bd
// bar 2 sd
// bar 3 bd
const bad = compile('bd [sd' );
console.log(bad.ok, bad.error.code, bad.error.index); // false unclosed 3
console.log(bad.eventsInBar(1)); // []
module 每個小節都不一樣的一行:<bd sd> 輪流出現,所以 period 是 2。有錯的一行會回傳 ok: false、錯誤代碼,和錯誤從哪個字元開始。
js 複製
import { compile } from '@openav/pattern' ;
const p = compile('bd(3,8), [~ sd]*2, hh*8' );
const g = document.getElementById('c' ).getContext('2d' );
g.fillStyle = '#111' ; g.fillRect(0, 0, 400, 300);
const colors = ['#ff7b54' , '#ffd166' , '#6f9bff' ];
for (const e of p.eventsInBar(1)) {
g.fillStyle = colors[e.layer];
g.fillRect(20 + e.pos * 360, 40 + e.layer * 70, e.dur * 360 - 4, 40); // dur is in bars
}
module 把第 1 小節畫在畫布上,每個逗號層一排:每個鼓點是在它位置上的一塊,長度就是它持續的時間。
相關 parse() 把一行節奏讀成一棵節點樹,每個節點都帶著它的字元範圍。它不會去查鼓組有沒有這個字。 WORDS 一行節奏可以用的鼓的字,以及每個字打哪一種鼓:bd 是 kick,sd 是 snare,hh 是 hat。 euclid() 把一些鼓點盡量平均地分在一些格子上,也就是歐幾里得節奏。euclid(3, 8) 就是 tresillo。 toGrid() 把一行節奏的某一個小節畫成格子圖,並告訴你格子放不下哪些東西。 DrumScheduler 純粹的鼓的邏輯,沒有計時器也沒有聲音:放進一張格子圖或一行節奏,就提前吐出有時間的事件,時鐘由你傳進來。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 import { compile } from '@openav/pattern'; packages/pattern/index.js 第 498 行
euclid()#
函式 @openav/pattern euclid(pulses, steps, rotate = 0)
把一些鼓點盡量平均地分在一些格子上,也就是歐幾里得節奏。euclid(3, 8) 就是 tresillo。
一行節奏裡的 bd(3,8) 就是靠它:compile() 每遇到一個 (k,n) 就會呼叫它。想把這個節奏當資料用,例如畫圖或接到自己的音序器,就單獨呼叫它。euclid(3, 8) 是 x..x..x.,euclid(5, 8) 是 x.xx.xx.。
它照 Toussaint(2005)說明的 Bjorklund 方法做。rotate 把節奏往後移,方向跟 strudel.cc 一樣:euclid(3, 8, 2) 是 x.x..x..,也就是 euclid(3, 8) 的鼓點往右移兩格。
參數 名稱 型別 說明 pulsesNumber有幾下。負數會打空隙:euclid(-3, 8) 是 euclid(3, 8) 沒打的那五格。比格數多的話,每一格都會打。小數會被去掉。 stepsNumber有幾格。小於 1 會得到空陣列。 rotateNumber讓節奏晚這麼多格開始。負數是提早,steps 以上的數字會繞回來。 可省略 預設 0
回傳 Boolean[] 一個長度 steps 的布林陣列,有鼓點的地方是 true。
範例
js 複製
import { euclid } from '@openav/pattern' ;
const show = (bits) => bits.map((b) => (b ? 'x' : '.' )).join('' );
console.log(show(euclid(3, 8))); // x..x..x.
console.log(show(euclid(5, 8))); // x.xx.xx.
console.log(show(euclid(3, 8, 2))); // x.x..x..
module 把三個節奏印成文字:x 是鼓點、. 是休止。第三個是第一個往後移兩格。
js 複製
import { euclid } from '@openav/pattern' ;
const g = document.getElementById('c' ).getContext('2d' );
g.fillStyle = '#111' ; g.fillRect(0, 0, 400, 300);
g.fillStyle = '#ffd166' ;
euclid(5, 16).forEach((hit, i) => { // 5 hits on 16 steps
g.globalAlpha = hit ? 1 : 0.2;
g.fillRect(12 + i * 23, 120, 18, 60);
});
console.log(euclid(5, 16).filter(Boolean).length); // 5
module 畫十六個方塊,點亮其中五個,分得跟十六格能做到的一樣平均。
相關 compile() 把一行節奏文字,像 'bd*4, [~ sd]*2, hh*8',讀成任何一個小節要打哪些鼓。它不會丟出錯誤。 toGrid() 把一行節奏的某一個小節畫成格子圖,並告訴你格子放不下哪些東西。 fromGrid() 把一張格子圖,像鼓機卡上的那種,轉成一個人會為它寫的那一行節奏。 import { euclid } from '@openav/pattern'; packages/pattern/index.js 第 69 行
fromGrid()#
函式 @openav/pattern fromGrid(grid, { steps = 16, words = null , order = null } = {})
把一張格子圖,像鼓機卡上的那種,轉成一個人會為它寫的那一行節奏。
按下鼓機卡上的一個格子,就是在做這件事:格子圖要變回文字。每條軌道變成一層,用 , 接起來,並選最短又準確的寫法。平穩的鼓點寫成 bd*4 或 hh*8,每組一下寫成 [~ sd]*2,歐幾里得節奏寫成 bd(3,8),其他的一拍一拍寫,像 bd [~ ~ ~ bd] bd*2 ~。它不會丟出錯誤。
這一行是精確的:把結果交給 compile() ,每個小節都會落在同樣的格子上。規則寫在套件的 README,測試用一千張隨機的格子圖驗證過。
參數 名稱 型別 說明 gridObject格子:{ 軌道: 'x...x...' } 或 { 軌道: [1, 0, 0, 0, …] }。軌道可以用鼓的名字(kick)或字(bd)命名。x、X、1、true 是鼓點,| 和空白會被忽略。名字不是鼓組認得的軌道會被丟掉。 stepsNumber每條軌道有幾格。 可省略 預設 16 wordsObject保留你自己的寫法:{ kick: 'kick' } 會寫成 kick*4 而不是 bd*4。 可省略 預設 null orderString[]要放在最前面的鼓,照這個順序,例如 ['kick', 'snare']。其他的接在後面,照格子圖的鍵的順序。 可省略 預設 null
回傳 String 一行剛好打出這張格子圖的節奏。完全沒有鼓點的格子圖會得到 '~'。
範例
js 複製
import { fromGrid } from '@openav/pattern' ;
console.log(fromGrid({
kick: 'x...x...x...x...' ,
snare: '....x.......x...' ,
hat: 'x.x.x.x.x.x.x.x.' ,
}));
// bd*4, [~ sd]*2, hh*8
module 印出經典四四拍格子圖的那一行:每一拍大鼓、第 2 和第 4 拍小鼓、八下鈸。
js 複製
import { fromGrid, compile } from '@openav/pattern' ;
const grid = { kick: [1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0], hat: 'x.x.x.x.x.x.x.x.' };
const line = fromGrid(grid, { order: ['kick' ] });
console.log(line); // bd(3,8), hh*8
console.log(compile(line).ok); // true
console.log(fromGrid(grid, { words: { kick: 'kick' } })); // kick(3,8), hh*8
console.log(fromGrid({})); // ~
module 一條軌道也可以是 0 和 1 的陣列。第 0、6、12 格的鼓點被認成歐幾里得節奏。words 保留你自己的寫法,空的格子圖就是一個休止。
相關 toGrid() 把一行節奏的某一個小節畫成格子圖,並告訴你格子放不下哪些東西。 compile() 把一行節奏文字,像 'bd*4, [~ sd]*2, hh*8',讀成任何一個小節要打哪些鼓。它不會丟出錯誤。 euclid() 把一些鼓點盡量平均地分在一些格子上,也就是歐幾里得節奏。euclid(3, 8) 就是 tresillo。 toStrudel() 把一行節奏轉成 Strudel 程式碼:OAV 的鼓的字換成 Strudel 的字,有給的話還會加上速度那行和註解。 import { fromGrid } from '@openav/pattern'; packages/pattern/index.js 第 628 行
fromStrudel()#
函式 @openav/pattern fromStrudel(code)
把從 strudel.cc 複製來的程式碼,像 s("bd*4").fast(2),讀成 OAV 會打的那一行節奏,並附上找到的速度。
它讓人可以貼上自己在 Strudel 做的東西。它讀 s("…") 和 sound("…"),引號可以是 "、' 或反引號。好幾個(前面也可以有 $: 或 name: 這種標籤)會變成好幾層。.fast(n) 或 .slow(n) 會折進那一行,變成 […]*n 或 […]/n。setcpm(x) 的 bpm 是 x 乘 4,setcps(x) 是 x 乘 240。.bank(…) 進 ignored,因為 OAV 打的是自己的鼓組。其他的呼叫或敘述都進 unsupported,剩下的照樣會打。它不會丟出錯誤。
它只是把文字重新整理,不會檢查字。fromStrudel('s("bd kik")') 會回 ok: true 和 src: 'bd kik',所以要把 src 交給 compile() ,看它的 ok。startDrums() 可以直接收 Strudel 程式碼,這些事都替你做了。
參數 名稱 型別 說明 codeString從 strudel.cc 編輯器複製來的 Strudel 程式碼。
回傳 Object { strudel, ok, src, bpm, ignored, unsupported }。文字裡沒有 s(…) 或 sound(…) 的話,strudel 是 false。src 裡有一行節奏的話,ok 是 true。bpm 是 setcpm() 或 setcps() 給的速度,沒有就是 null。ignored 列出刻意略過的(.bank(…)),unsupported 列出 OAV 不做的(.jux(rev)、.gain(…))。
範例
js 複製
import { fromStrudel, compile } from '@openav/pattern' ;
const r = fromStrudel('setcpm(110/4)\ns("bd*4, [~ sd]*2").bank("RolandTR909").fast(2).jux(rev)' );
console.log(r.src); // [bd*4, [~ sd]*2]*2
console.log(r.bpm); // 110
console.log(r.ignored); // ['.bank("RolandTR909")']
console.log(r.unsupported); // ['.jux(rev)']
console.log(compile(r.src).ok); // true
module 讀一段常見的 Strudel 程式:.fast(2) 折進那一行,找到速度,.bank() 被略過,.jux() 被回報為不支援。
js 複製
import { fromStrudel } from '@openav/pattern' ;
console.log(fromStrudel('note("c e g")' ).strudel); // false, no s("…") in it
console.log(fromStrudel('s("bd kik")' ).ok); // true: the words are not checked
console.log(fromStrudel('setcps(0.5)\ns("bd")' ).bpm); // 120
module 邊界情況:沒有 s(…) 的文字對 OAV 來說不是 Strudel,拼錯的字還是會通過,setcps(0.5) 是 120 bpm。
相關 toStrudel() 把一行節奏轉成 Strudel 程式碼:OAV 的鼓的字換成 Strudel 的字,有給的話還會加上速度那行和註解。 strudelUrl() 做出一個 strudel.cc 的連結,打開後 Strudel 編輯器裡就是這一行節奏。 compile() 把一行節奏文字,像 'bd*4, [~ sd]*2, hh*8',讀成任何一個小節要打哪些鼓。它不會丟出錯誤。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 import { fromStrudel } from '@openav/pattern'; packages/pattern/index.js 第 727 行
parse()#
函式 @openav/pattern parse(src)
把一行節奏讀成一棵節點樹,每個節點都帶著它的字元範圍。它不會去查鼓組有沒有這個字。
parse() 是 compile() 的前半段:只檢查文法、建出樹,到此為止。想看一行的結構,例如幫字上色或找錯誤的時候用它。要鼓點的話,呼叫 compile()。
樹根永遠是 layer,它的 layers 是最上層用逗號分開的幾部分,每一部分是一個 seq。節點的種類有 step { word, n }、rest、seq { steps: [{ node, weight }] }、layer { layers }、alt { steps }(每小節一個)、fast 和 slow { factor, node }、euclid { pulses, slots, rotate, node }、maybe { chance, id, node }。每個節點都有 start 和 end,也就是它在 src 裡的字元範圍。權重和倍數是分數 [分子, 分母],所以 bd*1.5 的倍數是 [3, 2]。
參數 名稱 型別 說明 srcString一行節奏,例如 'bd [sd sd]'。
回傳 Object { ok: true, tree, error: null },或 { ok: false, tree: null, error },error 跟 compile() 的一樣,是 { code, index, length, message, hint }。
注意 因為 parse() 不看鼓組,parse('kik') 會成功,compile('kik') 卻會失敗,錯誤是 unknown-word。其他的錯(括號沒關、*0、(9,8))兩個都會失敗。 範例
js 複製
import { parse } from '@openav/pattern' ;
const r = parse('bd [sd hh]*2' );
console.log(r.ok); // true
console.log(r.tree.layers[0].steps.map((s) => s.node.type)); // ['step', 'fast']
console.log(parse('bd [sd' ).error.code); // unclosed
module 印出第一層每一格的種類(一個普通的字,接著一組被加快的),以及括號沒關的那一行的錯誤代碼。
js 複製
import { parse } from '@openav/pattern' ;
// light every word of the line: each step node knows its characters
const src = 'bd*4, [~ sd]*2, hh*8' ;
const words = [];
const walk = (node) => {
if (node.type === 'step' ) words.push(src.slice(node.start, node.end) + '@' + node.start);
for (const s of node.steps || []) walk(s.node);
for (const l of node.layers || []) walk(l);
if (node.node) walk(node.node);
};
walk(parse(src).tree);
console.log(words.join(' ' )); // bd@0 sd@9 hh@16
module 走過整棵樹,印出每個字和它開始的字元位置,在編輯器裡幫字上色就需要這份資料。
相關 compile() 把一行節奏文字,像 'bd*4, [~ sd]*2, hh*8',讀成任何一個小節要打哪些鼓。它不會丟出錯誤。 toGrid() 把一行節奏的某一個小節畫成格子圖,並告訴你格子放不下哪些東西。 toStrudel() 把一行節奏轉成 Strudel 程式碼:OAV 的鼓的字換成 Strudel 的字,有給的話還會加上速度那行和註解。 import { parse } from '@openav/pattern'; packages/pattern/index.js 第 123 行
SHORT = object, 13 keys
Tidal 和 Strudel 為每種鼓寫的短字:kick 是 bd,snare 是 sd。跟 WORDS 方向相反。
fromGrid() 沒給你自己的寫法時,就是用這些字寫。toStrudel() 也用它們(pedalhat 用 hh)來說 Strudel 的話。
型別 Object一個凍結的物件,有 13 組 { 鼓聲: 字 },鼓組每種鼓一組。pedalhat 沒有短字,所以對到 OAV 自己的 'pedalhat'。
範例
js 複製
import { SHORT } from '@openav/pattern' ;
console.log(SHORT.kick, SHORT.snare, SHORT.hat, SHORT.hightom); // bd sd hh ht
console.log(SHORT.pedalhat); // pedalhat
module 印出幾種鼓的短字,以及唯一沒有短字的那一種。
相關 WORDS 一行節奏可以用的鼓的字,以及每個字打哪一種鼓:bd 是 kick,sd 是 snare,hh 是 hat。 fromGrid() 把一張格子圖,像鼓機卡上的那種,轉成一個人會為它寫的那一行節奏。 toStrudel() 把一行節奏轉成 Strudel 程式碼:OAV 的鼓的字換成 Strudel 的字,有給的話還會加上速度那行和註解。 import { SHORT } from '@openav/pattern'; packages/pattern/index.js 第 548 行
strudelUrl()#
函式 @openav/pattern strudelUrl(src, opts = {})
做出一個 strudel.cc 的連結,打開後 Strudel 編輯器裡就是這一行節奏。
格式就是 strudel.cc 自己的網址列顯示的那種,鼓機卡上的 在 Strudel 打開 連結用的就是它。要在新分頁 打開:已經開著的 strudel.cc 分頁不會重讀改變過的 #。
參數 名稱 型別 說明 srcString一行節奏。 optsObjecttoStrudel() 的選項:{ bpm, comment, words }。 可省略 預設 {}
回傳 String 一個網址:'https://strudel.cc/#' 加上 toStrudel() 的程式碼,轉成 UTF-8 的 base64,再做網址編碼。
注意 OAV 只是做出連結,沒有任何 Strudel 的程式碼在 OAV 裡執行,也沒有隨 OAV 一起發佈。 範例
js 複製
import { strudelUrl } from '@openav/pattern' ;
const url = strudelUrl('bd*4, [~ sd]*2, hh*8' , { bpm: 110 });
console.log(url.startsWith('https://strudel.cc/#' )); // true
const code = atob(decodeURIComponent(url.split('#' )[1]));
console.log(code); // setcpm(110/4)
// s("bd*4, [~ sd]*2, hh*8")
const a = document.getElementById('app' ).appendChild(document.createElement('a' ));
a.href = url; a.target = '_blank' ; a.textContent = 'Open in Strudel' ;
module 做出連結,再解碼一次讓你看到裡面的程式碼,並放在頁面上,做成會在新分頁打開的連結。
待確認
這個版本沒有實際在 strudel.cc 打開過:連結格式是 2026-10-15 從 strudel.cc 的網址列讀出來的(見 README),strudel.cc 改版的話可能會失效。
相關 toStrudel() 把一行節奏轉成 Strudel 程式碼:OAV 的鼓的字換成 Strudel 的字,有給的話還會加上速度那行和註解。 fromStrudel() 把從 strudel.cc 複製來的程式碼,像 s("bd*4").fast(2),讀成 OAV 會打的那一行節奏,並附上找到的速度。 mountDrums() 在頁面上放一台鼓機:步進格子、播放控制,和一行可以直接打字修改的節奏。它把每一下鼓發布成 drum/* 訊號。 import { strudelUrl } from '@openav/pattern'; packages/pattern/index.js 第 872 行
toGrid()#
函式 @openav/pattern toGrid(p, { bar = 1, steps = 16, seed = 0, bars = 16, words = WORDS } = {})
把一行節奏的某一個小節畫成格子圖,並告訴你格子放不下哪些東西。
它是 fromGrid() 的反方向,鼓機卡就是用它畫出一行節奏的格子。每個鼓點會放到離它最近的那一格。lost 的欄位告訴你這樣損失了什麼:offGrid 是落在格子之間(三連音)而被移到最近一格的鼓點數量。stacked 是同一種鼓在同一格有兩下。varies 為 true 表示其他小節不一樣(< >、/2)。random 為 true 表示這一行有 ?。samples 為 true 表示有像 bd:2 的取樣編號。只有這些都沒發生,exact 才是 true。
編譯失敗的一行不會丟出錯誤,你會得到 ok: false。
參數 名稱 型別 說明 pString | Object一行節奏,或 compile() 的結果。 barNumber要看哪個小節,從 1 開始數。 可省略 預設 1 stepsNumber格子有幾格。 可省略 預設 16 seedNumber決定每一個 ?,跟 eventsInBar() 裡的用法一樣。 可省略 預設 0 barsNumber要看幾個小節,用來判斷其他小節有沒有不同(lost.varies)。最多看到這一行的 period,而且不會超過 64。 可省略 預設 16 wordsObject哪個字代表哪一種鼓,跟 compile() 一樣。 可省略 預設 WORDS
回傳 Object { ok, grid, words, voices, exact, lost }。grid 是 { 鼓聲: [0 或 1,共 steps 個] }。words 是這一行為每種鼓用的字。voices 列出鼓。exact 是 true 表示格子完整放得下這個小節。lost 數出放不下的東西:{ offGrid, stacked, varies, random, samples }。這一行讀不懂的話,ok 是 false,其他都是空的。
範例
js 複製
import { toGrid } from '@openav/pattern' ;
const g = toGrid('bd*4, [~ sd]*2, hh*8' );
console.log(g.exact); // true
console.log(g.grid.snare.join('' )); // 0000100000001000
console.log(g.words); // { kick: 'bd', snare: 'sd', hat: 'hh' }
module 一行放得進十六格:exact 是 true,每種鼓有它那一排 0 和 1。
js 複製
import { toGrid } from '@openav/pattern' ;
console.log(toGrid('bd [sd sd sd]' ).lost.offGrid); // 2, the triplet sits between steps
console.log(toGrid('<bd sd>' , { bar: 2 }).lost.varies); // true
console.log(toGrid('hh*8?' ).lost.random); // true
console.log(toGrid('bd:2' ).exact); // false, the sample number is lost
console.log(toGrid('bd . sd' ).ok); // false, not a line of rhythm
module 五行節奏,以及各自放進格子要付出的代價:三連音會移位,輪流的小節和機率顯示不出來,編譯失敗的一行根本沒有格子。
相關 fromGrid() 把一張格子圖,像鼓機卡上的那種,轉成一個人會為它寫的那一行節奏。 compile() 把一行節奏文字,像 'bd*4, [~ sd]*2, hh*8',讀成任何一個小節要打哪些鼓。它不會丟出錯誤。 DrumScheduler 純粹的鼓的邏輯,沒有計時器也沒有聲音:放進一張格子圖或一行節奏,就提前吐出有時間的事件,時鐘由你傳進來。 mountDrums() 在頁面上放一台鼓機:步進格子、播放控制,和一行可以直接打字修改的節奏。它把每一下鼓發布成 drum/* 訊號。 import { toGrid } from '@openav/pattern'; packages/pattern/index.js 第 656 行
toStrudel()#
函式 @openav/pattern toStrudel(src, { bpm = null , comment = '' , words = WORDS } = {})
把一行節奏轉成 Strudel 程式碼:OAV 的鼓的字換成 Strudel 的字,有給的話還會加上速度那行和註解。
字會換成 Strudel 的鼓的名字:kick 變 bd,pedalhat 變 hh。歐幾里得的旋轉在 n 格以上的,像 bd(3,8,10),會寫成 OAV 實際打的旋轉(bd(3,8,2)),因為 strudel.cc 會把它當作沒旋轉。讀不懂的一行會原樣放過去,所以結果在 Strudel 裡可能打不出來,先用 compile() 檢查。它不會丟出錯誤。
要在 Strudel 編輯器裡打開結果,用 strudelUrl() 。
參數 名稱 型別 說明 srcString一行節奏。 bpmNumber速度。大於 0 的話會寫一行 setcpm(bpm/4),因為 OAV 的一個小節就是 Strudel 的一個四拍的循環。 可省略 預設 null commentString要放在最前面的一行 // 註解。 可省略 預設 '' wordsObject哪個字代表哪一種鼓,跟 compile() 一樣。 可省略 預設 WORDS
回傳 String Strudel 程式碼,一到三行,最後一行是 s("…")。
範例
js 複製
import { toStrudel } from '@openav/pattern' ;
console.log(toStrudel('kick*4, pedalhat*8' , { bpm: 110, comment: 'from OAV' }));
// // from OAV
// setcpm(110/4)
// s("bd*4, hh*8")
module 印出三行 Strudel 程式碼:註解、速度,和用 Strudel 的鼓名寫的節奏。
js 複製
import { toStrudel } from '@openav/pattern' ;
console.log(toStrudel('bd(3,8,10)' )); // s("bd(3,8,2)")
console.log(toStrudel('bd . sd' )); // s("bd . sd"), a line that does not read passes through
module 超過格數的旋轉,會寫成 OAV 實際打的樣子,有錯的一行則不會被動到。
相關 strudelUrl() 做出一個 strudel.cc 的連結,打開後 Strudel 編輯器裡就是這一行節奏。 fromStrudel() 把從 strudel.cc 複製來的程式碼,像 s("bd*4").fast(2),讀成 OAV 會打的那一行節奏,並附上找到的速度。 compile() 把一行節奏文字,像 'bd*4, [~ sd]*2, hh*8',讀成任何一個小節要打哪些鼓。它不會丟出錯誤。 import { toStrudel } from '@openav/pattern'; packages/pattern/index.js 第 832 行
WORDS = object, 26 keys
一行節奏可以用的鼓的字,以及每個字打哪一種鼓:bd 是 kick,sd 是 snare,hh 是 hat。
短的字是 TidalCycles 和 Strudel 用的:bd 大鼓、sd 和 sn 小鼓、hh 鈸、oh 開鈸、cp 拍手、rim 和 rs 鼓邊、lt 落地鼓、mt 中鼓、ht 高音中鼓、cr 碎音鈸、rd 疊音鈸、cb 牛鈴。每種鼓自己的名字也能用:kick、snare、hat、openhat、pedalhat、clap、lowtom、tom、hightom、crash、ride、cowbell。值就是 GM 裡的鼓的名字。
它是 compile() 、toGrid() 、toStrudel() 的 words 選項的預設值。要讓一行節奏認得新的字,就傳一份加了你的字的複本。
型別 Object一個凍結的物件,有 26 組 { 字: 鼓聲 }。鍵是小寫,compile() 不管你寫的大小寫。
範例
js 複製
import { WORDS, compile } from '@openav/pattern' ;
console.log(WORDS.bd, WORDS.sn, WORDS.rs, WORDS.mt); // kick snare rim tom
console.log(Object.keys(WORDS).length); // 26
const mine = compile('boom tss boom tss' , { words: { ...WORDS, boom: 'kick' , tss: 'hat' } });
console.log(mine.ok, mine.voices.join(' ' )); // true kick hat
module 查幾個字、數有幾個,再把 WORDS 展開到新物件裡,讓一行節奏認得兩個你自己的字。
相關 SHORT Tidal 和 Strudel 為每種鼓寫的短字:kick 是 bd,snare 是 sd。跟 WORDS 方向相反。 compile() 把一行節奏文字,像 'bd*4, [~ sd]*2, hh*8',讀成任何一個小節要打哪些鼓。它不會丟出錯誤。 GM 鼓組裡每種鼓對應的 General MIDI 鼓音符:GM.kick 是 36,GM.snare 是 38。 voiceForNote() 告訴你一個 General MIDI 鼓音符會打鼓組裡的哪一種鼓,不打的音符回傳 null。 import { WORDS } from '@openav/pattern'; packages/pattern/index.js 第 31 行
時間軸、cue 與樂譜#
草圖裡的小節與 cue;整場演出用 @openav/timeline 與 @openav/score。 16
oav-p5
function barStarted(bar) {}
一個由你定義的事件函式:鼓機每一小節的第一格就會呼叫它,帶著小節的號碼。
定義 function barStarted(bar) { … },就可以每小節做一次事:function barStarted(bar) { drift += 9; }。它是在你聽到那一小節第一格的那個畫面、draw() 之前被呼叫的,鼓機啟動時的第一次呼叫是 barStarted(1)。在同一格裡,cueFired() 在它之前,stepStarted() 在它之後。
參數 名稱 型別 說明 barNumber剛開始的小節,從 1 算起。
注意 分頁被藏起來的時候,每一小節仍然會呼叫一次,分頁回來的時候也不會補呼叫漏掉的。這段時間不會呼叫 stepStarted() ,也不會通知打擊。 範例
let shade = 200;
function setup() {
createCanvas(400, 300);
colorMode(HSB, 360, 100, 100);
startDrums('four on floor' , 150);
}
function barStarted(bar) {
shade = (shade + 40) % 360; // a new colour every bar
}
function draw() {
background(shade, 60, 80);
fill(0, 0, 100);
textAlign(CENTER, CENTER);
textSize(48);
text(drumBar, width / 2, height / 2);
}
每一小節的開頭背景換一個顏色,數字數著現在是第幾小節。
相關 stepStarted() 一個由你定義的事件函式:鼓機 16 格裡的每一格都會呼叫它,帶著這一格和小節。 cueFired() 一個由你定義的事件函式:用 cue() 宣告的 cue,到了它的那一小節就會呼叫它,帶著 cue 的名字和小節。 drumBar 鼓機現在在第幾小節,從啟動的時候起算,第一小節是 1。 drumHit() 一個由你定義的事件函式:鼓機每打一下就會呼叫它,告訴你是哪一種鼓、打得多用力。 OAVp5.drums.on() 在任何地方監聽鼓機:OAVp5.drums.on('hit', fn) 會像 drumHit 一樣呼叫 fn,並回傳一個可以取消監聽的函式。 p5/oav-p5.js 第 1490 行
cue(bar, name, [every])
替某一小節安排事情:到了第 bar 小節,就呼叫 cueFired(name, bar),cueName 變成 name,給了 every,之後每隔那麼多小節再來一次。
鼓機會數小節,所以可以照著計畫走:在 setup() 裡寫 cue(1, 'calm', 12); cue(5, 'bloom', 12); cue(9, 'storm', 12);,就是三個場景,每 12 小節輪一圈。cue 到的時候會呼叫 cueFired(name, bar) ,cueName 裡也會是它的名字,不想用 callback 的話,在 draw() 裡讀它就好。同一格有好幾個 cue 的時候,會依你宣告的順序發動,並且在 barStarted() 、stepStarted() 和那一格的打擊之前。
用同樣的值呼叫兩次 cue() 不會重複加,所以放在 draw() 裡也安全。計數從 startDrums() 開始,cue 只在鼓機走過它的位置時才發動:請在 setup() 裡緊接著 startDrums() 宣告,第 1 小節的 cue 才不會漏掉。鼓機停止再啟動之後,cue 會從第 1 小節重新發動。
參數 名稱 型別 說明 barNumber第幾小節,第一小節是 1。小數表示小節裡的位置:4.5 是第 4 小節的一半(會取最接近的那一格,共 16 格)。小於 1 或不是數字的值,會在主控台印一個警告,什麼都不會加。 nameAny給它取的名字,通常是字串。會傳給 cueFired() ,並成為 cueName 。 everyNumber第一次之後,每隔這麼多小節再來一次(小數可以,至少一格)。0 或不傳:只發動一次。 可省略 預設 0
注意 如果 cue 是在它的那一小節已經過了之後才宣告的,有 every 就會在下一次重複時發動,沒有的話,要等到鼓機重新啟動才會發動。鼓機沒在跑的時候,任何 cue 都不會發動。 範例
let scene = 'calm' ;
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 140);
cue(1, 'calm' , 4); // bars 1, 5, 9 ...
cue(3, 'storm' , 4); // bars 3, 7, 11 ...
textAlign(CENTER, CENTER);
textSize(32);
}
function cueFired(name, bar) {
scene = name;
}
function draw() {
background(scene === 'storm' ? color(130, 30, 40) : color(20, 40, 100));
fill(255);
text(scene + ' (bar ' + drumBar + ')' , width / 2, height / 2);
}
畫面每兩小節在 "calm"(藍色)和 "storm"(紅色)之間切換,文字說明現在的場景和小節。
let flash = 0;
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 130);
cue(2.5, 'flash' , 2); // halfway through bar 2, then every 2 bars
textAlign(CENTER, CENTER);
}
function cueFired(name, bar) {
if (name === 'flash' ) flash = 1;
}
function draw() {
flash *= 0.92;
background(20 + flash * 200);
fill(255 - flash * 255);
text('a flash halfway through every second bar (bar ' + drumBar + ')' , width / 2, height / 2);
}
畫面在第 2 小節的中間閃一下,之後每隔一小節的中間又閃一次。
function setup() {
createCanvas(400, 300);
startDrums('latin' , 120);
textAlign(CENTER, CENTER);
textSize(28);
}
function draw() {
cue(2, 'drop' , 4); // the same call every frame adds nothing
background(20);
fill(255);
text(cueName === '' ? 'waiting for the first cue' : 'last cue: ' + cueName, width / 2, height / 2);
}
在 draw() 裡呼叫 cue() 也沒問題:到了第 2 小節,文字變成 "last cue: drop" 就一直是這樣。
相關 cueFired() 一個由你定義的事件函式:用 cue() 宣告的 cue,到了它的那一小節就會呼叫它,帶著 cue 的名字和小節。 cueName 最近一個發動的 cue 的名字,第一個發動之前是空字串。 barStarted() 一個由你定義的事件函式:鼓機每一小節的第一格就會呼叫它,帶著小節的號碼。 drumBar 鼓機現在在第幾小節,從啟動的時候起算,第一小節是 1。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 OAVp5.drums.cue() 跟 cue(bar, name, every) 一樣,但會回傳建立出來的 cue(或原本就有的那個),bar 無效的時候回傳 null。 p5/oav-p5.js 第 1733 行
function cueFired(name, bar) {}
一個由你定義的事件函式:用 cue() 宣告的 cue,到了它的那一小節就會呼叫它,帶著 cue 的名字和小節。
定義 function cueFired(name, bar) { … },cue 該發動的那一格就會呼叫它,順序在 barStarted() 、stepStarted() 和那一格的打擊之前。很適合用來換場景:function cueFired(name) { look = SCENES[name]; }。如果你只需要最近的名字,在 draw() 裡讀 cueName 就好。
參數 名稱 型別 說明 nameAny你給 cue() 的名字。 barNumbercue 發動時所在的小節,從 1 算起。小節中間的 cue(4.5)給的是它落在的那一整小節(4)。
注意 分頁被藏起來的時候,cue 照樣會發動,所以回來的時候場景是對的。同一格有好幾個 cue,會依宣告的順序各呼叫一次。 範例
let scene = '-' ;
let when = 0;
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 150);
cue(1, 'intro' );
cue(3, 'verse' , 4);
cue(5, 'chorus' , 4);
textAlign(CENTER, CENTER);
textSize(32);
}
function cueFired(name, bar) {
scene = name + ' (bar ' + bar + ')' ;
when = millis();
}
function draw() {
background(20 + max(0, 1 - (millis() - when) / 400) * 120); // a flash on every cue
fill(255);
text(scene, width / 2, height / 2);
}
每次 cue 發動,畫面閃一下並顯示場景名稱:intro 只有一次,之後 verse 和 chorus 輪流。
相關 cue() 替某一小節安排事情:到了第 bar 小節,就呼叫 cueFired(name, bar),cueName 變成 name,給了 every,之後每隔那麼多小節再來一次。 cueName 最近一個發動的 cue 的名字,第一個發動之前是空字串。 barStarted() 一個由你定義的事件函式:鼓機每一小節的第一格就會呼叫它,帶著小節的號碼。 stepStarted() 一個由你定義的事件函式:鼓機 16 格裡的每一格都會呼叫它,帶著這一格和小節。 OAVp5.drums.on() 在任何地方監聽鼓機:OAVp5.drums.on('hit', fn) 會像 drumHit 一樣呼叫 fn,並回傳一個可以取消監聽的函式。 p5/oav-p5.js 第 1487 行
cueName同一條也包含: OAVp5.drums.lastCue
最近一個發動的 cue 的名字,第一個發動之前是空字串。
可以在 draw() 裡直接讀,不用等 cueFired() :look = SCENES[cueName];。它只是最近一個 cue 的名字,所以回答「我現在在哪個場景」用它最適合,「某件事剛剛開始」則適合用 cueFired()。cue 用 cue() 宣告。
型別 String
範圍 '' or a cue namecue 發動的時候改變,之後一直是那個名字,直到下一個。一開始是 ''。
注意 如果草圖本來就宣告了自己的 cueName 變數,就用你的:oav-p5 不動這個名稱,並用一張卡片提醒你改讀 OAVp5.drums.lastCue。 範例
const SCENES = { calm: '#3b5bdb' , bloom: '#e64980' , storm: '#495057' };
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 150);
cue(1, 'calm' , 6);
cue(3, 'bloom' , 6);
cue(5, 'storm' , 6);
textAlign(CENTER, CENTER);
textSize(32);
}
function draw() {
background(SCENES[cueName] || '#111' ); // nothing yet: dark
fill(255);
text(cueName, width / 2, height / 2);
}
背景和文字跟著最近一個 cue 走:藍色 "calm"、粉紅色 "bloom"、灰色 "storm",每六小節輪一圈。
相關 cue() 替某一小節安排事情:到了第 bar 小節,就呼叫 cueFired(name, bar),cueName 變成 name,給了 every,之後每隔那麼多小節再來一次。 cueFired() 一個由你定義的事件函式:用 cue() 宣告的 cue,到了它的那一小節就會呼叫它,帶著 cue 的名字和小節。 drumBar 鼓機現在在第幾小節,從啟動的時候起算,第一小節是 1。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 p5/oav-p5.js 第 317 行
drumBar同一條也包含: OAVp5.drums.bar
鼓機現在在第幾小節,從啟動的時候起算,第一小節是 1。
一小節是 16 格、四拍。用它替作品安排結構:每四小節換一段的話,floor((drumBar - 1) / 4) % 3 就是現在是第幾段。要讓某件事剛好發生在某一小節,cue() 比在 draw() 裡判斷 drumBar 更簡潔。barStarted() 則是每小節開頭會被呼叫的函式。
型別 Number
範圍 0, 1, 2, 3 …鼓機播第一格之前是 0,之後是 1、2、3 ……,沒有上限。鼓機停止之後停在最後的值。
注意 如果草圖本來就宣告了自己的 drumBar 變數,就用你的:oav-p5 不動這個名稱,並用一張卡片提醒你改讀 OAVp5.drums.bar。 範例
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 150);
textAlign(CENTER, CENTER);
textSize(64);
}
function draw() {
background(20);
fill(255);
text(drumBar, width / 2, height / 2);
}
一個大數字,每一小節開頭加一。
const SECTIONS = ['#3b5bdb' , '#e8590c' , '#2f9e44' ];
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 160);
noStroke();
}
function draw() {
const section = floor((max(drumBar, 1) - 1) / 2) % SECTIONS.length; // a new section every 2 bars
background(SECTIONS[section]);
fill(255);
text('bar ' + drumBar + ', section ' + (section + 1), 20, 30);
}
背景每兩小節換一個顏色,文字說明現在是第幾小節、第幾段。
相關 barStarted() 一個由你定義的事件函式:鼓機每一小節的第一格就會呼叫它,帶著小節的號碼。 drumStep 鼓機剛剛播到這一小節 16 格裡的第幾格,從 0 到 15。 cue() 替某一小節安排事情:到了第 bar 小節,就呼叫 cueFired(name, bar),cueName 變成 name,給了 every,之後每隔那麼多小節再來一次。 OAVp5.drums.count 鼓機從啟動到現在一共播了幾格,從 0 開始算,第一格之前是 -1。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 p5/oav-p5.js 第 316 行
drumStep同一條也包含: OAVp5.drums.step
鼓機剛剛播到這一小節 16 格裡的第幾格,從 0 到 15。
一小節有 16 格(十六分音符),一拍有 4 格,所以 drumStep % 4 === 0 就是正拍,drumStep === 0 是小節的開頭。速度 120 的時候一格是 125 毫秒。可以用它讓某一格亮起來或挑選畫面:fill(i === drumStep ? 255 : 80)。如果你比較想在每一格被呼叫,就定義 stepStarted() 。想要平滑移動,不要 16 次跳動的話,用 drumPhase 。
型別 Number
範圍 0..15在你聽到那一格的那個畫面、draw() 之前改變。一開始是 0,鼓機停止之後停在最後的值。
注意 如果草圖本來就宣告了自己的 drumStep 變數,就用你的:oav-p5 不動這個名稱,並用一張卡片提醒你改讀 OAVp5.drums.step。分頁被藏起來的時候,時鐘照樣在數,但不會通知任何事件。 範例
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 120);
noStroke();
}
function draw() {
background(20);
for (let i = 0; i < 16; i++) {
fill(drumsAreOn && i === drumStep ? 255 : 70);
rect(20 + i * 22, 130, 18, 40); // sixteen cells, the current one lights up
}
}
一排十六個格子,現在的那一格亮著,從左到右一格一格走。
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 110);
textAlign(CENTER, CENTER);
textSize(18);
}
function draw() {
background(drumStep % 4 === 0 ? 70 : 20); // a flash on every beat
fill(255);
text('step ' + drumStep + ' of 16' + (drumStep === 0 ? ' (bar starts)' : '' ), width / 2, height / 2);
}
每一拍(第 0、4、8、12 格)背景亮一下,文字數著現在是第幾格。
p5/oav-p5.js 第 316 行
function stepStarted(step, bar) {}
一個由你定義的事件函式:鼓機 16 格裡的每一格都會呼叫它,帶著這一格和小節。
定義 function stepStarted(step, bar) { … },每一格都能做事:if (step % 4 === 0) ping = 1; 就是每一拍脈動一下。它是在你聽到那一格的那個畫面、draw() 之前被呼叫的,在 barStarted() 之後、那一格的打擊之前。如果你比較想讀值而不是被呼叫,drumStep 裡是同一個數字。
參數 名稱 型別 說明 stepNumber剛開始的那一格,0 到 15。每 4 格是一拍。 barNumber它所在的小節,從 1 算起。
注意 分頁被藏起來的時候不會呼叫它,所以每一格就加一個粒子的草圖,在你回來的時候不會一口氣冒出一大堆。 範例
let ping = 0;
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 120);
noStroke();
}
function stepStarted(step, bar) {
if (step % 4 === 0) ping = 1; // on every beat
}
function draw() {
background(20);
ping *= 0.9;
fill(255, 160, 80);
circle(width / 2, height / 2, 40 + ping * 120);
}
一個圓,每一拍(每四格)脹一下,中間縮回去。
相關 drumStep 鼓機剛剛播到這一小節 16 格裡的第幾格,從 0 到 15。 barStarted() 一個由你定義的事件函式:鼓機每一小節的第一格就會呼叫它,帶著小節的號碼。 drumHit() 一個由你定義的事件函式:鼓機每打一下就會呼叫它,告訴你是哪一種鼓、打得多用力。 cueFired() 一個由你定義的事件函式:用 cue() 宣告的 cue,到了它的那一小節就會呼叫它,帶著 cue 的名字和小節。 OAVp5.drums.on() 在任何地方監聽鼓機:OAVp5.drums.on('hit', fn) 會像 drumHit 一樣呼叫 fn,並回傳一個可以取消監聽的函式。 p5/oav-p5.js 第 1491 行
OAVp5.drums.count#
變數 oav-p5 OAVp5.drums.count
鼓機從啟動到現在一共播了幾格,從 0 開始算,第一格之前是 -1。
其他的數字都是從它切出來的:drumBar 是 floor(count / 16) + 1,drumStep 是 count % 16,cue() 是在 count 走到它那一小節的位置時發動。想要一個只會一直變大的數字的時候很好用,例如很長的時間軸上的位置,或是清單的索引。
型別 Number
範圍 -1, 0, 1, 2 …每一格加一,到了新的小節也不會歸零。機器重新啟動的時候,才會從 0 重新算。
範例
function setup() {
createCanvas(400, 300);
startDrums('half time' , 140);
textAlign(CENTER, CENTER);
textSize(28);
}
function draw() {
background(20);
fill(255);
const n = OAVp5.drums.count;
text('step ' + n + ' = bar ' + (floor(n / 16) + 1) + ', step ' + (n % 16), width / 2, height / 2);
}
一路累加的格數,以及它換算成的小節和格。
相關 drumBar 鼓機現在在第幾小節,從啟動的時候起算,第一小節是 1。 drumStep 鼓機剛剛播到這一小節 16 格裡的第幾格,從 0 到 15。 cue() 替某一小節安排事情:到了第 bar 小節,就呼叫 cueFired(name, bar),cueName 變成 name,給了 every,之後每隔那麼多小節再來一次。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 p5/oav-p5.js 第 1437 行
OAVp5.drums.cue()#
函式 oav-p5 OAVp5.drums.cue(bar, name, [every])
跟 cue(bar, name, every) 一樣,但會回傳建立出來的 cue(或原本就有的那個),bar 無效的時候回傳 null。
想拿著這個 cue 的時候用它,例如想知道它在第幾格發動。只是安排計畫的話,cue() 比較短。所有 cue 的清單在 OAVp5.drums.cues 。
參數 名稱 型別 說明 barNumber第幾小節,從 1 算起,4.5 是第 4 小節的一半。見 cue() 。 nameAny這個 cue 的名字。 everyNumber每隔幾小節再來一次,0 或不傳:只一次。 可省略
回傳 Object | null 這個 cue:{ bar, name, every, at, period, key },其中 at 和 period 以格為單位。bar 小於 1 或不是數字就是 null。重複前面呼叫過的,會回傳已經存在的那個 cue。
範例
let c = null ;
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 120);
c = OAVp5.drums.cue(3.5, 'half' , 4);
textAlign(CENTER, CENTER);
}
function draw() {
background(20);
fill(255);
text(c ? 'cue "' + c.name + '" at step ' + c.at + ', every ' + c.period + ' steps' : 'no cue' , width / 2, height / 2);
}
第 3.5 小節是累計的第 40 格,「每 4 小節」是 64 格。
相關 cue() 替某一小節安排事情:到了第 bar 小節,就呼叫 cueFired(name, bar),cueName 變成 name,給了 every,之後每隔那麼多小節再來一次。 OAVp5.drums.cues 到目前為止宣告過的所有 cue,照宣告的順序排。 cueFired() 一個由你定義的事件函式:用 cue() 宣告的 cue,到了它的那一小節就會呼叫它,帶著 cue 的名字和小節。 p5/oav-p5.js 第 1439 行
OAVp5.drums.cues#
變數 oav-p5 OAVp5.drums.cues
到目前為止宣告過的所有 cue,照宣告的順序排。
想把計畫顯示在畫面上,或檢查宣告了哪些 cue 的時候讀它。它是鼓機自己的清單,請當成唯讀,要宣告 cue 請用 cue() 。
型別 Array<Object>每一項是 { bar, name, every, at, period, key }:你傳進去的值,以及以格為單位的 at 和 period。一開始是空的。
範例
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 120);
cue(1, 'calm' , 8);
cue(5, 'storm' , 8);
textSize(14);
}
function draw() {
background(20);
fill(255);
OAVp5.drums.cues.forEach((c, i) => {
text('bar ' + c.bar + (c.every ? ' (every ' + c.every + ')' : '' ) + ': ' + c.name, 20, 30 + i * 24);
});
}
把計畫列成清單:每個 cue 一行。
相關 cue() 替某一小節安排事情:到了第 bar 小節,就呼叫 cueFired(name, bar),cueName 變成 name,給了 every,之後每隔那麼多小節再來一次。 OAVp5.drums.cue() 跟 cue(bar, name, every) 一樣,但會回傳建立出來的 cue(或原本就有的那個),bar 無效的時候回傳 null。 cueName 最近一個發動的 cue 的名字,第一個發動之前是空字串。 p5/oav-p5.js 第 1437 行
@openav/score
Director#
類別 @openav/score new Director({ score, modules = [], api = {}, onStatus = null , onCue = null , maxStep = 2 } = {})
執行總譜的段落模組:每個模組進場一次、每個畫面更新、退場一次,觸發 cue,而且某個模組當掉時只關掉它,不會讓演出停下來。
段落模組 就是負責表演一個段落(黎明、暴風雨、終曲)的程式碼。演出走過總譜的時候,導演會呼叫它的掛鉤:
js
{
id: 'dusk' , // the id of the segment it performs
enter(api, ctx) {}, // the segment began, or a seek landed inside it
update(api, ctx) {}, // every frame while the segment (and its linger) is current
exit(api, ctx) {}, // it ended, or a seek left it: give back what you took
param(key, value, ctx) { return value; }, // optional: rewrite a timeline param meanwhile
cue(name, ctx) {}, // optional: one of its cues was just passed
reset(api) {}, // optional: R was pressed, forget everything
}每個掛鉤都會收到一個 ctx:p(這一段走了多少,0 到 1,餘韻期間大於 1,hold 的時候剛好是 1)、t(段內第幾秒)、dt、dur、T(演出時間)、segment、cue(name) 和 cueT(name)(cue 的位置,一個是進度、一個是演出秒數)、phase('active'、'linger',崩潰後的 exit() 裡是 'failed')、holding、held(在 hold 上等了幾秒,演出時間 T 凍結時可以拿它當環境動態的時鐘)、playing 和 api。
一個模組只進場一次、只退場一次,不管演出是怎麼走到那裡的:seek 到段落中間,會用正確的 p 呼叫 enter,所以模組必須能從中間開始。丟出錯誤的模組只有它自己會被關掉:導演用 phase: 'failed' 呼叫一次它的 exit(),印出一行 console.error,之後不再呼叫它,其他模組和演出照常進行。reset 可以把它請回來。
在演出裡,createShow() 依 score 選項建好導演,每個畫面在時間軸前進之後呼叫 update(),導演的 param() 接到 timeline.layer 上。不用 createShow() 的話,這些接線要自己做:director.update(timeline.t, dt, { holding: timeline.holding, playing: timeline.playing })。
參數 名稱 型別 說明 scoreScore要表演的 Score 。必填,沒有的話建構函式會丟出 "Director needs a score"。 modulesArray | Object段落模組:一個陣列(依每個模組的 id 對應到段落),或以段落 id 為 key 的物件。沒有模組的段落就單純不被表演。 可省略 預設 [] apiObject | Function每個模組的掛鉤收到的第一個參數(也是 ctx.api)。如果是函式,會在第一次需要時呼叫一次,然後使用它回傳的結果。 可省略 預設 {} onStatusFunction | null當段落、hold 的狀態或被關掉的模組清單改變的時候,以 status() 呼叫,不是每個畫面都呼叫。 可省略 預設 null onCueFunction | null播放頭每經過一個 cue 就呼叫一次:{ name, cue, T, n, segment }。 可省略 預設 null maxStepNumber兩次 update() 之間跳超過這麼多秒,就算是 seek,中間的 cue 不會觸發。如果你用很高的 timeline.rate 排練,把它調大。 可省略 預設 2
成員 名稱 說明 apigetter 模組收到的 api 物件(傳進來的 api 如果是函式,會在第一次使用時呼叫)。 register(mod)method 加入一個段落模組(同 id 的會被取代)。模組沒有字串 id 時會丟出錯誤。回傳導演本身。 moduleFor(seg)method 負責表演某個段落的模組:先找它的 module 欄位指定的名稱,再找跟它自己同 id 的,都沒有就是 null。 holdinggetter 根據最近一次 update(),演出是否停在 hold 上。 update(T, dt = 0, state = {})method 每個畫面在時間軸前進之後呼叫一次:update(T, dt, { holding, playing }),T 是演出時間。負責模組的進場、更新、退場,並觸發從上一次呼叫以來經過的 cue。 param(key, value, T = this._T)method 時間軸的參數圖層:param(key, value, T) 讓目前段落的模組改寫這個值(模組回傳一個數字就取代它)。表演者的覆寫之後套用,仍然以覆寫為準。 seek(T)method 播放頭跳到了 T:不再涵蓋 T 的段落的模組現在退場,下一次 update() 會用正確的 p 讓新的模組進場。 reset()method 回到起點:所有進行中的模組退場,每個模組都會收到 reset(api),被關掉的模組也回來了。 status()method 給控台和後台用:{ cut, T, total, segment, next, holding, held, active, failed, errors, missing },包含目前段落的進度與剩餘時間、下一個段落與倒數,以及哪些模組被關掉了。
注意 最後一個段落不會因為時間而退場:演出停在尾端(或超過尾端)時,最後一個模組仍維持進場狀態。它只在 seek 離開或 reset() 時退場。 新段落的模組會先進場、舊的才退場,在 linger 期間兩個會同時在跑。 module 或 id 對不到任何段落的模組,或段落指定了沒人登記的模組(會列在 status().missing),都只是不被表演,演出照常進行。範例
js 複製
import { Score, Director } from '@openav/score' ;
const score = new Score({ cuts: [{ id: 'full' , segments: [
{ id: 'dawn' , dur: 4, cues: { birds: 0.5 } },
{ id: 'noon' , dur: 4 },
] }] });
const say = (id) => ({
id,
enter(api, ctx) { console.log(id, 'enter, p =' , ctx.p); },
exit() { console.log(id, 'exit' ); },
cue(name) { console.log(id, 'cue' , name); },
});
const director = new Director({ score, modules: [say('dawn' ), say('noon' )] });
for (let T = 0; T <= 8; T += 1) director.update(T, 1, { playing: true }); // one call per frame
module 主控台印出 dawn enter, p = 0、dawn cue birds(第 2 秒),然後第 4 秒是 noon enter, p = 0 和 dawn exit。最後一個段落會一直維持進場到結束。
js 複製
import { Score, Director } from '@openav/score' ;
const score = new Score({ cuts: [{ id: 'full' , segments: [{ id: 'dawn' , dur: 4 }, { id: 'noon' , dur: 4 }] }] });
const sun = { id: 'dawn' , param: (key, value, ctx) => (key === 'sun' ? ctx.p : value) };
const director = new Director({ score, modules: [sun] });
director.update(1, 1, { playing: true });
console.log(director.param('sun' , 0.9, 1), director.param('moon' , 0.9, 1)); // 0.25 0.9
director.update(2, 1, { playing: true });
console.log(director.param('sun' , 0.9, 2)); // 0.5
module dawn 在進行的時候,它的模組只改動 sun 這個參數,跟著自己的進度走:主控台印出 0.25 0.9,然後是 0.5。
js 複製
import { Score, Director } from '@openav/score' ;
console.error = (message) => console.log('logged:' , String(message).slice(0, 62)); // show the director's error line here
const score = new Score({ cuts: [{ id: 'full' , segments: [{ id: 'dawn' , dur: 4 }, { id: 'noon' , dur: 4 }] }] });
const dawn = { id: 'dawn' , update() { throw new Error('boom' ); } };
const noon = { id: 'noon' , enter() { console.log('noon is still performed' ); } };
const director = new Director({ score, modules: [dawn, noon] });
director.update(1, 1, { playing: true }); // dawn throws: it is switched off, once
director.update(5, 1, { playing: true }); // the show goes on
console.log(director.status().failed, director.status().errors);
director.reset(); // brings dawn back
console.log(director.status().failed);
module 崩潰被關在原地:主控台印出 logged: [score] segment module "dawn" crashed in update: boom and was(平常那是一行紅色的錯誤訊息),接著是 noon is still performed、[ 'dawn' ] { dawn: 'update: boom' },reset 之後是 []。
相關 Score 把一場演出寫成「每一段有多長」,而不是絕對秒數:每個起點、終點、cue 和倒數,以及每一個版本(cut)的時間,都由它算出來。 ScoreError 總譜建不起來的時候丟出的錯誤:它的 problems 陣列一次列出所有原因,錯誤訊息裡也會一起列出來。 Timeline 用關鍵影格回答「時間 t 的時候,每個參數是多少?」,並在外面包一層播放控制:播放、跳轉、場景、hold、循環、cue 和錄製。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 mountConsole() 把導演控台掛進一個元素:播放控制與 scrubber、圖層總覽、輸入、映射、參數推桿、訊號音量表,以及全螢幕的演出模式。 import { Director } from '@openav/score'; packages/score/director.js 第 38 行
new Score({ cuts, cut } = {})
把一場演出寫成「每一段有多長」,而不是絕對秒數:每個起點、終點、cue 和倒數,以及每一個版本(cut)的時間,都由它算出來。
把時間軸寫成一張絕對秒數的表,它就長不大:某個場景拉長一點,後面每個 cue、曲線和 if (t > 466) 全都錯了。在總譜裡你只寫每一段「多長 」,Score 從這些長度算出每個起點、終點、cue 的時間、倒數和場景。改一個 dur,它後面的一切都跟著挪。第二個版本只是幾行資料。
一個 cut 是演出的一個版本,一個 segment(段落)是其中的一段:
js
{ id: 'full' , title: 'A day' , segments: [
{ id: 'standby' , dur: 6, hold: true , acts: ['Press Space when the room is ready' ] },
{ id: 'dawn' , dur: 24, cues: { birds: 0.3 } }, // a ratio of the segment
{ id: 'noon' , dur: 30, cues: { flare: { s: 8 } } }, // or seconds into it
{ id: 'dusk' , dur: 24, linger: 8 },
] }
{ id: 'short' , from : 'full' , scale: 0.5 } // the same show at half the length dur 是一個段落唯一擁有的時間。cues 是有名字的時刻(比例 0 <= r < 1,或 { s: 秒數 }),acts 是操作者要做的事(顯示在控台上的字串),linger 讓段落的模組在段落結束後再多跑幾秒,hold 讓播放停在段落尾端,等操作者放行,module 在負責表演的模組不是段落自己的 id 時,指定模組名稱。衍生的 cut 會縮放 dur、linger 和 { s } 形式的 cue,所以 cue 在音樂裡的位置不變。
總譜在建立的時候就會全部檢查一遍。id 重複、dur 等於或小於 0、from 指到不存在的 cut、cut 衍生自己、cue 落在段落外面,每一種都會丟出 ScoreError ,一次把所有問題列出來。在演出裡,createShow() 依 score 選項建好總譜,交給 Timeline 和 Director ,網址上的 ?cut=<id> 可以選 cut。
參數 名稱 型別 說明 cutsArray | Object這場演出可以表演成哪些 cut:一個 cut 的陣列,或以 cut id 為 key 的物件。一個 cut 是 { id, title?, start?, segments: [...] },或衍生的 { id, title?, from, scale }。必填,沒有 cut 就會丟出錯誤。 cutString要表演的 cut 的 id。預設是第一個,不在 cuts 裡的 id 是錯誤。 可省略 預設 the first cut
成員 名稱 說明 cutsgetter 所有的 cut,給版本選單用:[{ id, title, total, from, scale, base }]。 validate()method 把所有東西再檢查一遍:有問題就丟出列出所有問題的 ScoreError,沒問題就回傳 true。建構函式已經呼叫過了。 table()method 每個段落一列,{ index, id, title, start, end, dur, linger, hold },給 console.table() 和文件用。 segment(id)method 這個 id 的段落,沒有就是 undefined。 indexAt(T)method 演出時間 T 正在播的段落的索引。一個段落擁有 [start, end) 這段時間。小於 0(或 NaN)回答 0,超過結尾回答最後一個。 segmentAt(T)method 演出時間 T 正在播的段落(規則同 indexAt)。 cueTime(name)method 以演出秒數表示的某個時刻:'dusk' 是段落開頭,'dusk.end' 是段落結尾,'dusk.glow' 是 cut 裡宣告的 cue。名字在這裡沒有意義就回傳 NaN。 cues()method 這個 cut 的所有 cue,依時間排序:[{ name, cue, T, p, n, segment }],n 是它在這個順序中的位置。 cuesBetween(T0, T1)method 播放頭從 T0 走到 T1 的途中經過的 cue:時間滿足 T0 < 時間 <= T1 的那些,依序排列。T1 <= T0 時什麼都沒有。 baseTime(T)method 把時間 T 換算成這個 cut 衍生來源的那個 cut 上的時間(T / scale)。不是衍生的 cut 就等於 T。只對完整版寫一次的自動化曲線,就是靠這個跟著短版一起拉伸。 scenes()method 把段落轉成給 Timeline 用的 scenes 陣列,帶著 acts、hold 和 cues(cue 的時間是演出秒數)。
注意 普通欄位:cut(正在表演的 id)、title、segments(每一段都有 start、end、dur、index、cues ……)、total(演出總長)、startT(reset 回到哪裡)、scale 和 base。 段落和 cue 的 id 不能含有句點:cueTime('dusk.glow') 就是靠句點把段落和它的 cue 分開。cue 也不能叫 end。 如果兩個 cut 不只是長度不同、連結構都不同,就不要讓它們共用自動化曲線,改由段落模組來驅動參數,那裡的 ctx.p 本來就會跟著縮放。 範例
js 複製
import { Score } from '@openav/score' ;
const score = new Score({ cuts: [{ id: 'full' , segments: [
{ id: 'dawn' , dur: 24, cues: { birds: 0.25 } },
{ id: 'noon' , dur: 30, cues: { flare: { s: 8 } } },
{ id: 'dusk' , dur: 26 },
] }] });
console.table(score.table());
console.log(score.total, score.segmentAt(40).id, score.cueTime('noon.flare' ));
module 主控台會顯示三個段落的表格(dawn 0 到 24、noon 24 到 54、dusk 54 到 80),然後印出 80 noon 32:演出長 80 秒,第 40 秒在 noon,flare 這個 cue 在第 32 秒。
js 複製
import { Score } from '@openav/score' ;
const cuts = [
{ id: 'full' , segments: [{ id: 'dawn' , dur: 24, cues: { birds: 0.25 } }, { id: 'noon' , dur: 30, cues: { flare: { s: 8 } } }] },
{ id: 'short' , from : 'full' , scale: 0.5 },
];
for (const cut of ['full' , 'short' ]) {
const score = new Score({ cuts, cut });
console.log(cut, score.total, score.cueTime('dawn.birds' ), score.cueTime('noon.flare' ));
}
module 同一場演出的兩種長度:full 54 6 32,然後 short 27 3 16。所有時間都減半,cue 在音樂裡的位置不變。
js 複製
import { Params } from '@openav/core' ;
import { Score } from '@openav/score' ;
import { Timeline } from '@openav/timeline' ;
const cuts = [
{ id: 'full' , segments: [{ id: 'a' , dur: 20 }, { id: 'b' , dur: 40, hold: true }, { id: 'c' , dur: 20 }] },
{ id: 'short' , from : 'full' , scale: 0.5 },
];
const params = new Params([{ key: 'haze' , min: 0, max: 1, def: 0 }]);
for (const cut of ['full' , 'short' ]) {
const timeline = new Timeline({ params, score: new Score({ cuts, cut }), automation: { haze: [[0, 0], [80, 1]] } });
console.log(cut, timeline.total, timeline.scenes.map((s) => s.id + '@' + s.t).join(' ' ), timeline.valueAt('haze' , 20));
}
module 時間軸的場景和總長都從總譜來,而且讀的是被拉伸的 haze 曲線(只對完整版寫過一次):full 80 a@0 b@20 c@60 0.25,然後 short 40 a@0 b@10 c@30 0.5。
相關 ScoreError 總譜建不起來的時候丟出的錯誤:它的 problems 陣列一次列出所有原因,錯誤訊息裡也會一起列出來。 Director 執行總譜的段落模組:每個模組進場一次、每個畫面更新、退場一次,觸發 cue,而且某個模組當掉時只關掉它,不會讓演出停下來。 Timeline 用關鍵影格回答「時間 t 的時候,每個參數是多少?」,並在外面包一層播放控制:播放、跳轉、場景、hold、循環、cue 和錄製。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 import { Score } from '@openav/score'; packages/score/score.js 第 48 行
ScoreError#
類別 @openav/score new ScoreError(problems)
總譜建不起來的時候丟出的錯誤:它的 problems 陣列一次列出所有原因,錯誤訊息裡也會一起列出來。
通常不用自己建:new Score() 會丟出它。error.problems 是字串陣列,error.message 是同一份清單串成的文字,開頭是「Score is invalid (3 problems)」。error.name 是 'ScoreError',它是一般的 Error,所以 instanceof ScoreError 和 instanceof Error 都成立。
它會列出找到的所有問題,不是只列第一個,所以跑一次就能看到所有要修的地方。有問題的總譜上不了台。
參數 名稱 型別 說明 problemsArray<String>所有原因,每個一個字串。每一個都會指出是哪個 cut、哪個段落、哪裡不對。
範例
js 複製
import { Score, ScoreError } from '@openav/score' ;
try {
new Score({ cuts: [
{ id: 'a' , segments: [{ id: 'x' , dur: 0 }, { id: 'x' , dur: 5 }] },
{ id: 'b' , from : 'missing' , scale: 1 },
] });
} catch (error) {
console.log(error instanceof ScoreError, error.problems.length);
for (const problem of error.problems) console.log('-' , problem);
}
module 三個錯誤一次全部報出來:主控台印出 true 3,然後每個錯誤一行(dur 是 0、id 重複、from 指到不存在的 cut)。
相關 Score 把一場演出寫成「每一段有多長」,而不是絕對秒數:每個起點、終點、cue 和倒數,以及每一個版本(cut)的時間,都由它算出來。 Director 執行總譜的段落模組:每個模組進場一次、每個畫面更新、退場一次,觸發 cue,而且某個模組當掉時只關掉它,不會讓演出停下來。 import { ScoreError } from '@openav/score'; packages/score/score.js 第 30 行
@openav/timeline
CURVES#
常數 @openav/timeline CURVES = object, 5 keys
關鍵影格可以用來說明「這一段怎麼抵達」的曲線名稱:linear、ease、in、out、step,每個都是 0..1 對應到 0..1 的函式。
在 Timeline 裡,關鍵影格的第三項是「抵達 這個影格的那一段」的曲線名稱:[30, 320, 'ease'] 的意思是「用 ease 的方式,在第 30 秒抵達 320」。預設是 linear,名稱不認得或打錯也會退回 linear,所以打錯字不會讓演出停下來。你也可以傳自己的函式,不用名稱。
linear 走直線。ease 是 smoothstep:慢、快、慢。in 起頭慢(f²)。out 起頭快、輕輕落地(1 − (1 − f)²)。step 比較特別:這一段會一直守著舊 值,到了關鍵影格才跳過去。放在這裡的 step 函式永遠回傳 0,內插的時候不會用它來算,列在這裡只是為了讓這個物件涵蓋整套名稱。
在 Params 的 schema 裡宣告了 step 的參數,不管你寫哪種曲線,都會停在每個關鍵影格。
範例
js 複製
import { CURVES } from '@openav/timeline' ;
for (const name of ['linear' , 'ease' , 'in' , 'out' ]) {
console.log(name, CURVES[name](0.5)); // halfway through the segment
}
module 主控台印出每條曲線在一半的時間點走到了哪裡:linear 0.5、ease 0.5、in 0.25、out 0.75。
js 複製
import { Params } from '@openav/core' ;
import { Timeline } from '@openav/timeline' ;
const params = new Params([{ key: 'speed' , min: 0, max: 4, def: 0 }]);
const timeline = new Timeline({ params, automation: {
speed: [[0, 0.3], [16, 1.6, 'in' ], [20, 3.4, 'step' ], [30, 0.8, 'out' ]],
} });
for (const t of [8, 16, 19, 20, 25, 30]) console.log(t, timeline.valueAt('speed' , t).toFixed(2));
module 一條軌道,三種曲線:主控台印出緩慢的起頭(8 0.63)、第 16 秒抵達 16 1.60、一路守著(19 1.60)到第 20 秒 step 才跳(20 3.40),最後用 out 曲線降到 30 0.80。
js 複製
import { Params } from '@openav/core' ;
import { Timeline } from '@openav/timeline' ;
const params = new Params([{ key: 'speed' , min: 0, max: 4, def: 0 }]);
const timeline = new Timeline({ params, automation: {
speed: [[0, 0], [10, 4, (f) => f * f * f]], // your own curve: a function of 0..1
} });
console.log(timeline.valueAt('speed' , 5));
module 函式可以用在任何可以寫名稱的地方。走到一半的時候,三次方曲線只走了八分之一:主控台印出 0.5(4 × 0.125)。
相關 Timeline 用關鍵影格回答「時間 t 的時候,每個參數是多少?」,並在外面包一層播放控制:播放、跳轉、場景、hold、循環、cue 和錄製。 Params 世界所聽的那些值:一份宣告好的參數清單,每個參數每個畫面都解析成一個數字,除非有手、旋鈕或路由把它蓋掉。 approach() 一階低通跟隨的一步:把 current 往 target 推近,經過 seconds 秒走完 63% 的差距。 import { CURVES } from '@openav/timeline'; packages/timeline/index.js 第 28 行
HOLD_EPS#
常數 @openav/timeline HOLD_EPS = 0.000001
hold 讓演出停住的時候,播放頭停在邊界前多遠的地方:一微秒,這樣它仍然屬於正在等候的那個場景。
hold: true 的場景會在尾端讓播放停住。Timeline 把播放頭停在邊界前面 HOLD_EPS(0.000001 秒)的地方,而不是正好停在邊界上,所以 sceneIndexAt(t) 和導演仍然說「現在是被 hold 的那個場景」,在 6 秒的 hold 上 timeline.t 讀到的是 5.999999,下一個場景還沒開始。之後 release() 才把播放頭移到邊界本身。
只有在你自己拿播放頭跟邊界做比較的時候才會用到:hold 的時候,timeline.t 是 邊界 - HOLD_EPS,不是剛好等於邊界。
範例
js 複製
import { Params } from '@openav/core' ;
import { Timeline, HOLD_EPS } from '@openav/timeline' ;
const params = new Params([]);
const timeline = new Timeline({ params, total: 20, scenes: [{ id: 'wait' , t: 0, hold: true }, { id: 'go' , t: 6 }] });
timeline.play();
for (let i = 0; i < 10; i++) timeline.advance(1);
console.log(timeline.holding, timeline.currentScene().id, Math.abs(6 - timeline.t - HOLD_EPS) < 1e-9);
module 演出停在第 6 秒的稍前方,仍然在被 hold 的場景裡:主控台印出 true wait true(6 - timeline.t 在捨入誤差內等於 HOLD_EPS)。
相關 Timeline 用關鍵影格回答「時間 t 的時候,每個參數是多少?」,並在外面包一層播放控制:播放、跳轉、場景、hold、循環、cue 和錄製。 Director 執行總譜的段落模組:每個模組進場一次、每個畫面更新、退場一次,觸發 cue,而且某個模組當掉時只關掉它,不會讓演出停下來。 Score 把一場演出寫成「每一段有多長」,而不是絕對秒數:每個起點、終點、cue 和倒數,以及每一個版本(cut)的時間,都由它算出來。 import { HOLD_EPS } from '@openav/timeline'; packages/timeline/index.js 第 38 行
Timeline#
類別 @openav/timeline new Timeline({ params, automation = {}, scenes = [], total = 600, score = null , start = null , loop = false , onCue = null })
用關鍵影格回答「時間 t 的時候,每個參數是多少?」,並在外面包一層播放控制:播放、跳轉、場景、hold、循環、cue 和錄製。
timeline.state(t) 回傳時間 t 的時候每個參數的 { key: value },由 automation 內插得來。它的工作就這一件:時間歸時間軸管,世界從來不讀時鐘。在演出裡,createShow() 依 timeline 選項建好時間軸,Loop 每個畫面呼叫 advance(dt),state() 的結果再交給 params.resolve() ,被手或旋鈕覆寫的參數仍然以覆寫為準。
兩個關鍵影格之間,值走直線。關鍵影格選填的第三項,說的是「抵達 這個影格的那一段」怎麼走:'linear'、'ease'、'in'、'out'、'step',或你自己寫的 0..1 函式(見 CURVES )。第一個影格之前,值就是第一個影格的值。最後一個影格之後,就是最後一個的值。沒有任何關鍵影格的參數維持在 def。宣告了 step 的參數會停在每個關鍵影格,不做內插。
場景給了演出一個結構:{ id: 'storm', t: 90, title: 'Storm', note: 'clusters welcome' }。onSceneChange 是用來觸發聲音、燈光或提詞的掛鉤,主控台會把標題和備註顯示給表演者。hold: true 的場景,播放到它的尾端就停住,直到有人呼叫 release()。場景的 cues 是 { name, t },t 是演出秒數(不是場景內的第幾秒),而且只有播放 經過它們的時候才會觸發:seek、跳轉或 reset 都不會。
呼叫之前
時間只在你呼叫 play()、並且每個畫面呼叫 advance(dt) 的時候才會往前走(演出會替你做)。state(t) 和 valueAt(key, t) 隨時都能用,不論有沒有在播放。起始場景的回呼,要等到第一次 advance() 或 seek() 才會觸發。
參數 名稱 型別 說明 paramsParams | Array演出的 Params ,或一個單純的 schema 陣列。時間軸讀 schema 來知道要解析哪些參數。必填。 automationObject每個參數的關鍵影格:{ hue: [[0, 200], [30, 320, 'ease'], [60, 40]] }。一個關鍵影格是 [秒數, 值, 曲線?],依時間排序。 可省略 預設 {} scenesArray場景:{ id, t, title?, note?, acts?, hold?, cues? },依 t 排序。t 是負的就是開演前的待機。有給 score 的時候會被忽略。 可省略 預設 [] totalNumber演出的長度,單位是秒。播放到這裡會自己停下來。(createShow() 沒特別指定的時候傳 120。)有給 score 的時候會被忽略。 可省略 預設 600 scoreScore | null一份 Score :場景、總長和起點都改從它來,自動化曲線讀的是 score.baseTime(t),所以衍生的 cut 會把曲線一起拉伸。 可省略 預設 null startNumber | 'first' | nullreset() 要回到哪裡,播放頭一開始也在這裡:一個秒數,或 'first' 代表第一個場景的起點(例如 −30 的待機)。null 表示 0,或總譜的起點。 可省略 預設 null loopBoolean | String | Objecttrue 是演出到尾端就繞回去繼續播,'scene' 是反覆播目前所在的場景,給場景 id 就反覆播那個場景,{ from, to } 反覆播一段範圍(秒數或場景 id)。它是個普通屬性,隨時可以設定 timeline.loop。 可省略 預設 false onCueFunction | null等於呼叫一次 timeline.onCue(cb):cb(cue, { cause })。 可省略 預設 null
成員 名稱 說明 valueAt(key, t = this.t)method 一個參數在時間 t(預設是現在)的值:套用曲線之後的自動化數值,還沒經過 layer,也還沒套用覆寫。 state(t = this.t)method 時間 t 的所有參數,格式是 { key: value }。這是基準狀態,params.resolve() 之後會讓覆寫值蓋過它。如果設了 layer,每個值都會先經過它。 sceneIndexAt(t = this.t)method 包含時間 t 的那個場景的索引(所有場景都還沒開始時是第一個)。 sceneEnd(i)method 第 i 個場景的結束時間(秒):下一個場景的開頭,最後一個場景則是 total。 currentScene(t = this.t)method 包含時間 t(預設是現在)的那個場景物件。 onSceneChange(cb)method 登記 cb(index, scene, { cause }),播放頭落進另一個場景時呼叫。cause 是 'play'、'release'、'seek'、'jump'、'reset' 或 'loop'。登記之後沒辦法取消。 onSeek(cb)method 登記 cb(t, kind),播放頭被手動移動時呼叫:kind 是 'seek'、'jump' 或 'reset'。播放中不會呼叫。 play()method 開始播放。如果停在 hold 上,這會改成放行。 pause()method 停止播放,並把正在錄的片段收尾。 toggle()method 暫停中就播放,播放中就暫停(在 hold 上則是放行)。回傳現在是不是在播放。 seek(t, cause = 'seek')method 把播放頭移到 t,範圍限制在第一個場景的起點(為負時)或 0,到 total 之間。用這個方式離開 hold 就等於放行。cause(預設 'seek')是 onSceneChange 和 onSeek 收到的原因。seek 不會觸發 cue。 jumpScene(d)method 跳到相隔 d 個場景的那個場景的開頭(1 是下一個,-1 是上一個),到第一個或最後一個就停住。它是原因為 'jump' 的 seek。 next()method → 鍵:停在 hold 上就放行並繼續播放,否則跳到下一個場景。 prev()method ← 鍵:跳到上一個場景。 release()method 讓 hold 放行:下一個場景立刻開始,播放繼續。如果本來就沒有停住,回傳 false。 reset()method 回到起點(start,預設 0):停止播放、不在 hold、循環次數歸零。 advance(dt)method 每個畫面呼叫一次,傳入經過的秒數。播放中會讓 t 前進 dt × rate,遇到 hold 就停住,遇到循環就繞回去,到 total 就停下來,途中會觸發場景改變和 cue。回傳是否仍在播放。 loopRange(t = this.t)method 時間 t 所在的循環範圍,格式是秒數的 { a, b }。loop 關著或範圍是空的時候回傳 null。 onLoop(cb)method 登記 cb({ t, from, to, count }),播放頭每次繞回循環的起點時呼叫。 cues()method 所有場景的所有 cue,依時間排序,格式是 { …cue, scene: sceneId, sceneIndex }。 onCue(cb)method 登記 cb(cue, { cause }),播放經過場景 cue 的時候(cause 是 'play'),或 fireCue() 手動觸發的時候('manual')呼叫。 fireCue(c)method 手動觸發一個 cue(點 scrubber 上的 ◆)。接受 cues() 的一個項目,或任何 { name, t }。 record(key, source, opts = {})method 備好一條錄製軌:時間軸播放的時候,source() 回傳的值會被當成關鍵影格寫進 automation[key]。有人正在碰控制項時 source 回傳數字,沒有人碰時回傳 null,這會讓片段收尾。key 不認識、或 source 不是函式會丟出錯誤。回傳錄製器({ key, take, active, commit(), stop() })。 recordinggetter 有沒有任何一條錄製軌處於備好的狀態。 stopRecording()method 把所有備好的錄製器都關掉,並把它們手上的內容寫進去。 onRecord(cb)method 登記 cb({ key, from, to, count, cause, keyframes }),片段收尾時呼叫。count 是 0 表示太短,沒有保留。 mergeTake(key, raw, opts = {})method 把一段錄好的片段([t, value] 的陣列)寫進 automation[key]:先精簡,再取代它時間範圍內的關鍵影格,範圍外的保留。回傳 { from, to, count },少於兩個點則回傳 null。
注意 這些是可以直接讀、也可以直接設定的普通欄位:t、playing、holding、rate(1 是真實速度,4 是四倍速排練)、total、start、scenes、automation、score、loop、loops(上次 reset 以來繞了幾圈)和 layer。 layer 是 (key, value, t) => value。Director 會接在這裡,讓段落模組可以改動參數,表演者的覆寫之後才套用,所以仍然以覆寫為準。有 score 的時候,你傳的 scenes 和 total 都會被忽略,reset() 回到的是總譜的起點。 範例
js 複製
import { Params } from '@openav/core' ;
import { Timeline } from '@openav/timeline' ;
const params = new Params([{ key: 'hue' , min: 0, max: 360, def: 200 }]);
const timeline = new Timeline({
params,
total: 60,
automation: { hue: [[0, 200], [30, 320], [60, 40, 'ease' ]] },
});
for (const t of [0, 15, 30, 45]) console.log(t, timeline.state(t).hue);
module 主控台印出四個時刻的色相:0 200、15 260、30 320、45 180。什麼都沒有在播放,state(t) 只是把答案查出來。
js 複製
import { Params } from '@openav/core' ;
import { Timeline } from '@openav/timeline' ;
const params = new Params([{ key: 'energy' , min: 0, max: 1, def: 0.2 }]);
const timeline = new Timeline({
params,
total: 20,
scenes: [
{ id: 'standby' , t: 0, hold: true },
{ id: 'go' , t: 6, cues: [{ name: 'flash' , t: 10 }] },
],
});
timeline.onSceneChange((i, scene, { cause }) => console.log('scene' , scene.id, cause));
timeline.onCue((cue) => console.log('cue' , cue.name, 'at' , cue.t));
timeline.play();
for (let i = 0; i < 8; i++) timeline.advance(1); // one second per call
console.log('holding:' , timeline.holding, 'at' , timeline.t.toFixed(3));
timeline.release(); // the operator says go
for (let i = 0; i < 6; i++) timeline.advance(1);
module 主控台依序印出 scene standby play、holding: true at 6.000(演出停在待機場景的尾端等候),然後是 scene go release,最後是 cue flash at 10。
js 複製
import { Params } from '@openav/core' ;
import { Timeline } from '@openav/timeline' ;
const params = new Params([{ key: 'hue' , min: 0, max: 360, def: 100 }]);
const timeline = new Timeline({ params, total: 30, automation: { hue: [[0, 100], [30, 100]] } });
let knob = null ; // a number while a hand holds the knob
timeline.record('hue' , () => knob);
timeline.onRecord((take) => console.log('take' , take.from , 'to' , take.to, take.cause));
timeline.play();
for (let i = 0; i < 20; i++) {
knob = i >= 5 && i < 12 ? 100 + (i - 5) * 20 : null ; // turned from second 5 to 11
timeline.advance(1);
}
console.log(JSON.stringify(timeline.automation.hue));
module 旋鈕轉了七秒,主控台印出 take 6 to 12 release,接著是新的關鍵影格:這段錄製取代了曲線的中段,原本的兩端保留下來。
相關 Params 世界所聽的那些值:一份宣告好的參數清單,每個參數每個畫面都解析成一個數字,除非有手、旋鈕或路由把它蓋掉。 Loop 畫面的時鐘:每個動畫畫面呼叫你的函式一次,並告訴你距離上一次過了幾秒,分頁被藏起來時它也繼續跳。 Score 把一場演出寫成「每一段有多長」,而不是絕對秒數:每個起點、終點、cue 和倒數,以及每一個版本(cut)的時間,都由它算出來。 Director 執行總譜的段落模組:每個模組進場一次、每個畫面更新、退場一次,觸發 cue,而且某個模組當掉時只關掉它,不會讓演出停下來。 CURVES 關鍵影格可以用來說明「這一段怎麼抵達」的曲線名稱:linear、ease、in、out、step,每個都是 0..1 對應到 0..1 的函式。 HOLD_EPS hold 讓演出停住的時候,播放頭停在邊界前多遠的地方:一微秒,這樣它仍然屬於正在等候的那個場景。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 mountConsole() 把導演控台掛進一個元素:播放控制與 scrubber、圖層總覽、輸入、映射、參數推桿、訊號音量表,以及全螢幕的演出模式。 import { Timeline } from '@openav/timeline'; packages/timeline/index.js 第 40 行
數值隨時間變化(CHOP)#
lag()、env()、spring()、trail():像 TouchDesigner 那樣整形一個數值。 28
oav-p5
count(on, [limit], [name])同一條也包含: OAVp5.chop.count
數 on 打開過幾次,或某個事件(像 'kick')發生過幾次。給了上限就會循環:0、1、…、上限 − 1(TouchDesigner 的 Count CHOP)。
不用自己維護的計數器:count(mouseIsPressed) 是到現在的點擊次數,count(beat, 4) 跟著拍子數 0 1 2 3 0 1 …,count('kick') 數鼓機的大鼓。開關要關掉再打開,才會算第二次。
事件的計數從第一次呼叫 count('kick') 才開始,之前的大鼓不算。你自己用 trigger() 發出的事件,從它第一次發出就開始算,不管 count() 什麼時候第一次被呼叫。
呼叫之前
事件名字的規則跟 env() 一樣:'kick'、'snare'、'hat'、'beat' 來自麥克風,或是呼叫過 startDrums() 之後來自鼓機,而鼓機沒開的時候,第一次 count('kick') 會去要麥克風。第一次呼叫會載入 @openav/chop,要幾個畫面。
參數 名稱 型別 說明 onBoolean | Number | String可以是開關:布林值,或大於 0.5 的數字,每次從關變成開就算一次。也可以是事件名字('kick'、'beat'、'note',或你自己用 trigger() 發出的):那種事件到現在發生了幾次。 limitNumber數到這個數字就繞回去:4 會得到 0 1 2 3 0 1 …。不寫、或是 0 以下,就一直往上數。 可省略 預設 0 nameString | Number只用在開關的寫法(count(beat, 4, 'a')):給它的記憶取的名字,寫在最後一個參數。事件名字的寫法不需要。 可省略 從 arguments 讀
回傳 Number 計數,從 0 開始的整數。@openav/chop 還沒載好之前(幾個畫面),以及在 draw() 外面沒有名字的開關,都回傳 0。
注意 沒有名字的呼叫靠它在 draw() 裡的順序找回自己的記憶:第一個 lag() 是第一個 lag,第二個是第二個。放進 if,或放進長度會變的迴圈,順序就會對不上。這時會跳出一張卡片,從那之後 oav-p5 改成照「寫在哪一行」來對應。最穩的做法是在最後加一個名字。在 draw() 外面,沒有名字的呼叫沒有東西可以記,會回傳「回傳」欄寫的值,也會跳出卡片。 草圖如果自己有叫 count 的變數或函式,就由草圖保留。OAVp5.chop.count(…) 是同一個函式的另一個名字。 範例
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(96);
}
function draw() {
background(20);
fill(255);
text(count(mouseIsPressed), width / 2, height / 2); // clicks so far
}
每點一下加一的計數器。按住不放只算一次。
function setup() {
createCanvas(400, 300);
noStroke();
startDrums('bd*4, [~ sd]*2, hh*8' , 108);
}
function draw() {
background(20);
const nth = count('kick' , 4); // 0 1 2 3 0 1 ...
for (let i = 0; i < 4; i++) {
fill(i === nth ? color(255, 90, 31) : 70);
circle(100 + i * 67, 150, i === nth ? 50 : 30);
}
}
四個圓點依序亮起,鼓機每一下大鼓前進一格。如果沒聽到聲音,點一下畫布。
const colors = ['#ff5a1f' , '#78beff' , '#ffd36b' ];
function setup() {
createCanvas(400, 300);
}
function mousePressed() { trigger('boom' ); }
function draw() {
background(colors[count('boom' , 3)]); // 0 1 2 0 1 2 ...
fill(20);
text('clicks: ' + count('boom' ), 20, 30);
}
每點一下,背景就在三種顏色之間輪流換,文字則不停地數點擊次數。
相關 env() 一個 0 到 1 的脈衝:每次事件(大鼓、拍子、琴鍵、按下)之後就跳起來,然後慢慢退下去(TouchDesigner 的 Trigger CHOP)。 trigger() 用名字發出一個你自己的事件,讓 env() 和 count() 去對它起反應。 beat 麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 p5/oav-p5.js 第 3057 行
drawTrail(values, [x], [y], [w], [h], [lo], [hi], [name])同一條也包含: OAVp5.chop.drawTrail
在畫布上畫一條穿過 values 的線,所以它會在作品裡、也會被錄進影片:最舊的在 x,lo 在方框底,hi 在方框頂。
就是 scope() 顯示在角落的那張圖,只是畫在畫布上:stroke(255); drawTrail(trail(micLevel, 3), 0, height - 100, width, 100)。這條線是草圖自己的 stroke() 顏色和粗細畫出的一個形狀(noStroke() 生效時是白色),不填色,畫完之後會把你原本的樣式還原。
最舊的值在左邊的 x,最新的在 x + w。傳數字而不傳陣列是捷徑:drawTrail(micLevel, 0, 0, width, 100) 不用另外呼叫 trail() 就能畫出最近 2 秒。
呼叫之前
在 createCanvas() 之後的 draw() 裡呼叫。傳數字的話,第一次呼叫會載入 @openav/chop,要幾個畫面,那之前不會畫任何東西。
參數 名稱 型別 說明 valuesArray<Number> | Number像 trail() 回傳的那種陣列。或是單一個數字:這時 drawTrail 會自己保留最近 2 秒的軌跡。 xNumber方框左邊的位置。 可省略 預設 0 yNumber方框上緣的位置。 可省略 預設 0 wNumber方框的寬度。不寫就是畫布的寬度。 可省略 預設 width hNumber方框的高度。不寫就是畫布的高度。 可省略 預設 height loNumber畫在方框底部的那個值。 可省略 預設 0 hiNumber畫在方框頂部的那個值。超出 lo 到 hi 的值會貼在方框的邊上。hi 等於 lo 的話,線會從中間穿過。 可省略 預設 1 nameString | Number只有 values 是單一個數字的時候才用:給它自己的軌跡取的名字,寫在第 8 個參數(hi 後面)。 可省略 從 arguments 讀
回傳 Array<Number> 畫出來的那個陣列:你傳進來的,或是那個數字自己的軌跡。少於 2 個值的時候不會畫。
注意 沒有名字的呼叫靠它在 draw() 裡的順序找回自己的記憶:第一個 lag() 是第一個 lag,第二個是第二個。放進 if,或放進長度會變的迴圈,順序就會對不上。這時會跳出一張卡片,從那之後 oav-p5 改成照「寫在哪一行」來對應。最穩的做法是在最後加一個名字。在 draw() 外面,沒有名字的呼叫沒有東西可以記,會回傳「回傳」欄寫的值,也會跳出卡片。 OAVp5.chop.drawTrail(…) 是同一個函式的另一個名字。範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const wave = 0.5 + 0.5 * sin(frameCount * 0.05);
stroke(255, 211, 107);
strokeWeight(2);
drawTrail(wave, 20, 100, width - 40, 100); // a number: keeps its own 2 seconds
}
一條移動的正弦線,直接畫在畫布上,所以錄影也會錄到。
function setup() {
createCanvas(400, 300);
noFill();
strokeWeight(2);
}
function draw() {
background(20);
const raw = frameCount % 120 < 60 ? 1 : 0;
const smooth = lag(raw, 0.3, 0.6);
stroke(95, 224, 138);
drawTrail(trail(raw, 3), 20, 60, width - 40, 80); // 0 at the bottom, 1 at the top
stroke(255, 211, 107);
drawTrail(trail(smooth, 3), 20, 60, width - 40, 80); // same box: drawn on top
}
綠色是原始的方波,黃色是加了 lag 的版本,畫在同一個方框裡,看得出延遲。
function setup() {
createCanvas(400, 300);
noFill();
stroke(255);
}
function draw() {
background(20);
const wave = sin(frameCount * 0.05); // -1..1
drawTrail(trail(wave, 3), 20, 100, width - 40, 100, -1, 1); // lo and hi say what the box shows
}
一條會掉到 0 以下的波:lo = -1、hi = 1 讓方框底是 -1、頂是 1,整條波就放得下。
相關 trail() value 最近幾秒的歷史,做成一個陣列,每秒 60 個值,最舊的在前、最新的在最後(TouchDesigner 的 Trail CHOP)。scope() 把 value 顯示在網頁角落的小示波器上,並原樣還給你,所以可以包住任何運算式來觀察它。 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 p5/oav-p5.js 第 3152 行
env([event], [attack], [decay], [name])同一條也包含: OAVp5.chop.env
一個 0 到 1 的脈衝:每次事件(大鼓、拍子、琴鍵、按下)之後就跳起來,然後慢慢退下去(TouchDesigner 的 Trigger CHOP)。
每個畫面都呼叫它,把這個數字當成大小或亮度來用:circle(width / 2, height / 2, 60 + env('kick', 0.01, 0.3) * 200) 讓圓在每一下大鼓都脹大。事件之後,數字在 attack 秒內升到 1,再用 decay 秒落回 0,一開始退得快,最後輕輕落地,就像大鼓的那一下看起來該有的樣子。
上一個脈衝還沒退完時又來一個事件,會從脈衝現在的位置重新開始,不會回到 0,所以很快的連打會堆成一個平台,不會閃爍。attack 是從 0 算起的:從一半的地方再觸發,只花一半的 attack。
事件有這些:'kick'、'snare'、'hat' 就是 kickDetected() 聽到的那些打擊(來自麥克風,或鼓機在播的時候來自鼓機),'beat' 是 beat ,'note' 是任何一個琴鍵按下,'bar' 和 'step' 來自鼓機,cue 的名字在 cue() 觸發時發出,其他的名字由你用 trigger() 發出。開關的寫法 env(mouseIsPressed, 0.02, 0.5) 在每次按下時發出事件。
呼叫之前
'kick'、'snare'、'hat'、'beat' 要有東西去產生它們。草圖還沒啟動鼓機(startDrums() )的話,第一次 env('kick') 就會像讀 micLevel 一樣去要麥克風,而 ?oav_mic=off 表示永遠不會有事件。鼓機一啟動,就不會為它去開麥克風。'note' 要有琴鍵:彈一個、按 🎹,或呼叫 OAVp5.play() 。你自己取的名字要靠 trigger() :4 秒內沒有任何東西觸發它,會跳出一張卡片告訴你。
參數 名稱 型別 說明 eventString | Boolean | Number要對什麼起反應。可以是名字:'kick'、'snare'、'hat'、'beat'(來自麥克風或鼓機)、'note'(任何琴鍵)、'bar'、'step'(鼓機)、某個 cue 的名字,或你自己用 trigger() 發出的事件。也可以是開關:布林值,或大於 0.5 的數字,每次從關變成開就算一次事件。不寫就是 'beat'。 可省略 預設 'beat' attackNumber從 0 升到 1 要花的秒數。 可省略 預設 0.01 decayNumber從 1 落回 0 要花的秒數。 可省略 預設 0.3 nameString | Number只用在開關的寫法(env(mouseIsPressed, …, 'a')):給它的記憶取的名字,寫在最後一個參數。像 'kick' 這種有名字的事件不需要。 可省略 從 arguments 讀
回傳 Number 0 到 1:什麼都沒發生是 0,脈衝的頂點是 1。@openav/chop 還沒載好之前(幾個畫面)回傳 0。
注意 有名字的事件不需要待在 draw() 裡:env('kick') 在 setup() 或 mousePressed() 裡也能用。只有開關的寫法會在畫面之間記東西,沒有名字的開關在 draw() 外面回傳 0。在 draw() 裡的呼叫跟 lag() 一樣,靠順序來對應。 OAVp5.chop.env(…) 是同一個函式的另一個名字。範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const k = env(mouseIsPressed, 0.02, 0.5); // 1 right after each press, then it fades
circle(width / 2, height / 2, 60 + k * 200);
}
點一下畫布:圓每次都彈開,再慢慢退回去。
function setup() {
createCanvas(400, 300);
noStroke();
startDrums('bd*4, [~ sd]*2, hh*8' , 108); // a drum machine, so no microphone is needed
}
function draw() {
background(20);
const kick = env('kick' , 0.01, 0.3); // 1 on every kick, 0 again 0.3 s later
const snare = env('snare' , 0.005, 0.15);
fill(255, 90, 31);
circle(120, 150, 40 + kick * 160);
fill(120, 190, 255);
circle(280, 150, 40 + snare * 160);
}
兩個圓分別在鼓機的大鼓和小鼓時脹大。如果沒聽到聲音,點一下畫布:瀏覽器要先有一次點擊才會播聲音。
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
OAVp5.play(48 + floor(random(24)), 0.8); // play a key, as a controller would
}
function draw() {
background(20);
const k = env('note' , 0.005, 0.8); // every key makes it flash
fill(255, 211, 107);
circle(width / 2, height / 2, 40 + k * 240);
}
點一下畫布,或在控制器或 🎹 上彈一個鍵:每個鍵都讓圓跳一下。
相關 trigger() 用名字發出一個你自己的事件,讓 env() 和 count() 去對它起反應。 count() 數 on 打開過幾次,或某個事件(像 'kick')發生過幾次。給了上限就會循環:0、1、…、上限 − 1(TouchDesigner 的 Count CHOP)。 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 beat 麥克風(或鼓機)每聽到一個拍子,就在那一個畫面是 true。 kickDetected() 你來定義這幾個函式。麥克風(或鼓機)聽到大鼓、小鼓或鈸的時候,oav-p5 會呼叫 kickDetected(level)、snareDetected(level)、hatDetected(level)。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 p5/oav-p5.js 第 3016 行
lag(value, [up], [down], [name])同一條也包含: OAVp5.chop.lag
讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。
包住任何會跳的數字:circle(width / 2, height / 2, 50 + lag(micLevel, 0.03, 0.4) * 300) 就是一個音量表。有人說話時 0.03 秒內張開,要 0.4 秒才收回去。up 是跟上升的時間,down 是跟下降的時間。不寫 down,兩個都是 up。兩個都不寫,就都是 0.1。
這裡的秒數跟 TouchDesigner 一樣:lag(x, 0.1) 的動法就像把 Lag CHOP 設成 0.1,所以在這裡學的數字可以直接帶到 TouchDesigner。意思大約是輸出走完一次跳動的 90% 所花的時間(底層是一階的 lag,時間常數是秒數除以 2.3)。每秒 30 格和每秒 144 格畫出來的曲線一樣。
它看到的第一個值照單全收,所以不會從 0 慢慢滑進來。布林值算成 0 或 1:lag(mouseIsPressed, 0.2) 就是一個柔軟的開關。
呼叫之前
不用呼叫別的東西,也不用宣告:直接寫在 draw() 裡。第一次呼叫時會從 oav-p5.js 旁邊載入 @openav/chop,要幾個畫面。
參數 名稱 型別 說明 valueNumber | Boolean要跟隨的數字。布林值算成 0 或 1。不是有限數字的東西(字串、NaN)會直接穿過去,lag 保留原來的狀態。 upNumber跟上升的秒數:輸出大約花這麼久走完往上跳的 90%。負數算 0。 可省略 預設 0.1 downNumber跟下降的秒數。不寫的話跟 up 一樣。 可省略 預設 up nameString | Number給這次呼叫的記憶取的名字,寫在最後一個參數。可以不寫。不寫的話,oav-p5 用 draw() 裡呼叫的先後順序來區分。有名字的呼叫,在哪裡呼叫都是同一個。 可省略 從 arguments 讀
回傳 Number 平順過的數值。@openav/chop 還沒載好之前(幾個畫面),以及在 draw() 外面沒有名字的呼叫,原樣回傳 value。
注意 沒有名字的呼叫靠它在 draw() 裡的順序找回自己的記憶:第一個 lag() 是第一個 lag,第二個是第二個。放進 if,或放進長度會變的迴圈,順序就會對不上。這時會跳出一張卡片,從那之後 oav-p5 改成照「寫在哪一行」來對應。最穩的做法是在最後加一個名字。在 draw() 外面,沒有名字的呼叫沒有東西可以記,會回傳「回傳」欄寫的值,也會跳出卡片。 草圖如果自己有叫 lag 的變數或函式,就由草圖保留。OAVp5.chop.lag(…) 是同一個函式的另一個名字。 範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const x = lag(mouseX, 0.5); // follows the mouse, half a second behind
circle(x, height / 2, 40);
}
一個追著滑鼠跑的圓:它不會跳,而是滑過去,大約半秒追上。
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const open = lag(mouseIsPressed, 0.15, 0.8); // a boolean counts as 0 or 1
fill(255, 211, 107);
circle(width / 2, height / 2, 40 + open * 240);
}
按住滑鼠:圓很快脹大,放開之後慢慢縮回去。
function setup() {
createCanvas(400, 300);
noStroke();
}
function draw() {
background(20);
for (let i = 0; i < 6; i++) {
const seconds = 0.1 + i * 0.12;
const x = lag(mouseX, seconds, seconds, 'dot' + i); // a name per dot
fill(255, 211, 107);
circle(x, 40 + i * 44, 24);
}
}
六個圓點跟著滑鼠,每一個都比上面那個慢一點。有了名字,之後就算增減圓點,每一個記得的還是自己的位置。
相關 spring() 像彈簧上的重物那樣跟上 value:會衝過頭、彈幾下、然後穩住(TouchDesigner 的 Spring CHOP)。 env() 一個 0 到 1 的脈衝:每次事件(大鼓、拍子、琴鍵、按下)之後就跳起來,然後慢慢退下去(TouchDesigner 的 Trigger CHOP)。 micLevel 麥克風聽到的聲音有多大聲,從 0(安靜的房間)到 1(大喊)。 knob1 控制器上旋鈕的位置,從 0 到 1:knob1 是第一顆旋鈕,knob2 是第二顆,一直到 knob8。 trail() value 最近幾秒的歷史,做成一個陣列,每秒 60 個值,最舊的在前、最新的在最後(TouchDesigner 的 Trail CHOP)。scope() 把 value 顯示在網頁角落的小示波器上,並原樣還給你,所以可以包住任何運算式來觀察它。 OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 p5/oav-p5.js 第 3008 行
scope(value, [label])同一條也包含: OAVp5.chop.scope
把 value 顯示在網頁角落的小示波器上,並原樣還給你,所以可以包住任何運算式來觀察它。
它就是 CHOP 的檢視器:每個標籤是一條線,畫在跟 🎹 對角的網頁角落一個小圖上,顯示這個值最近 4 秒的樣子和現在的數字。在一個畫面裡用兩個標籤呼叫兩次,就有兩條線可以比較,例如原始的和平滑過的。刻度至少是 0 到 1,值跑出去就跟著放大。
它在網頁上,不在畫布上:不會錄進影片,也不會進 📷,而且不會搶走草圖的點擊。要把同樣的線畫進作品裡,用 drawTrail() 。
呼叫之前
只在 draw() 裡有作用:其他地方它只會原樣回傳 value。?oav_scope=off 會把它藏起來,?oav_hints=off 也會,這是它沒出現時唯一要知道的事。見 scope 。第一次呼叫會載入 @openav/chop,要幾個畫面。
參數 名稱 型別 說明 valueNumber | Boolean要觀察的數字。布林值畫成 0 或 1。不是有限數字的東西原樣回傳,也不會畫。 labelString這條線在圖例上的名字。每個標籤是一條線,超過 9 個字元的標籤在圖例上會被截短。不寫的話,這條線用編號。 可省略
回傳 Number | Boolean 原封不動的 value,所以 circle(x, y, scope(lag(micLevel, 0.03, 0.4), 'lag') * 300) 這樣寫就行。
注意 沒有名字的呼叫靠它在 draw() 裡的順序找回自己的記憶:第一個 lag() 是第一個 lag,第二個是第二個。放進 if,或放進長度會變的迴圈,順序就會對不上。這時會跳出一張卡片,從那之後 oav-p5 改成照「寫在哪一行」來對應。最穩的做法是在最後加一個名字。在 draw() 外面,沒有名字的呼叫沒有東西可以記,會回傳「回傳」欄寫的值,也會跳出卡片。 OAVp5.chop.scope(…) 是同一個函式的另一個名字。線條只保留最近一秒內還有呼叫的:停止呼叫一秒之後,那條線就會從圖上消失。範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const wave = 0.5 + 0.5 * sin(frameCount * 0.05);
circle(width / 2, height / 2, 40 + scope(wave, 'wave' ) * 200); // watch it in the corner
}
圓一漲一縮,網頁角落的小圖把同一個數字畫成一條移動的線。
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const raw = frameCount % 120 < 60 ? 1 : 0; // a square wave
scope(raw, 'raw' );
const smooth = scope(lag(raw, 0.3, 0.6), 'lag' );
fill(255, 211, 107);
circle(width / 2, height / 2, 40 + smooth * 200);
}
角落的圖有兩條線:方波,以及 lag() 把它變成的樣子,上升 0.3 秒、下降 0.6 秒。
相關 drawTrail() 在畫布上畫一條穿過 values 的線,所以它會在作品裡、也會被錄進影片:最舊的在 x,lo 在方框底,hi 在方框頂。 trail() value 最近幾秒的歷史,做成一個陣列,每秒 60 個值,最舊的在前、最新的在最後(TouchDesigner 的 Trail CHOP)。lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 scope scope() 要不要在網頁角落畫它的示波器:on 或 off。hints=off 也會把它關掉。hints oav-p5 要不要在 🎹 旁邊顯示說明的卡片(麥克風被擋、沒有 Web MIDI、你的回呼出錯):on 或 off。 p5/oav-p5.js 第 3072 行
slope(value, [name])同一條也包含: OAVp5.chop.slope
value 每秒變化多快:上升時是正的,下降時是負的,靜止時是 0(TouchDesigner 的 Slope CHOP)。
問的是東西動得多用力,而不是在哪裡:abs(slope(mouseX)) 是滑鼠每秒移動幾像素,slope(micLevel) 在一個字開頭的時候會往上衝。它的雜訊跟輸入一樣多,閃爍的時候先平滑一下:lag(abs(slope(x)), 0.05, 0.3)。
呼叫之前
不用呼叫別的東西,也不用宣告:直接寫在 draw() 裡。第一次呼叫會載入 @openav/chop,要幾個畫面。
參數 名稱 型別 說明 valueNumber | Boolean要觀察的數字。布林值算成 0 或 1。不是有限數字的東西,就保留上一個斜率。 nameString | Number給這次呼叫的記憶取的名字,寫在最後一個參數。可以不寫。不寫的話,oav-p5 用 draw() 裡呼叫的先後順序來區分。有名字的呼叫,在哪裡呼叫都是同一個。 可省略 從 arguments 讀
回傳 Number 每秒的變化量,單位跟 value 一樣(像素位置就是每秒幾像素)。第一次呼叫是 0。@openav/chop 還沒載好之前,以及在 draw() 外面沒有名字的呼叫,也是 0。
注意 沒有名字的呼叫靠它在 draw() 裡的順序找回自己的記憶:第一個 lag() 是第一個 lag,第二個是第二個。放進 if,或放進長度會變的迴圈,順序就會對不上。這時會跳出一張卡片,從那之後 oav-p5 改成照「寫在哪一行」來對應。最穩的做法是在最後加一個名字。在 draw() 外面,沒有名字的呼叫沒有東西可以記,會回傳「回傳」欄寫的值,也會跳出卡片。 OAVp5.chop.slope(…) 是同一個函式的另一個名字。範例
function setup() {
createCanvas(400, 300);
textSize(16);
}
function draw() {
background(20);
const v = abs(slope(mouseX)); // pixels per second
fill(255);
text(nf(v, 1, 0) + ' px/s' , 20, 120);
rect(20, 140, min(v / 10, width - 40), 24);
}
左右移動滑鼠:長條和數字顯示你動得多快,停下來就回到 0。
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const r = spring(mouseIsPressed ? 140 : 60, 100, 6); // a radius that bounces
const moving = lag(abs(slope(r)), 0.05, 0.3); // how fast it is changing, smoothed
noFill();
stroke(255, 211, 107);
strokeWeight(2 + min(14, moving / 40));
circle(width / 2, height / 2, r * 2);
}
按住滑鼠:圓環長大、彈跳,變化最快的時候外框最粗。
相關 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 speed() 每秒把 rate 加進一個累計值,所以 rotate(speed(TWO_PI)) 每秒轉一圈,而且速率隨時可以變,累計值不會跳(TouchDesigner 的 Speed CHOP)。 spring() 像彈簧上的重物那樣跟上 value:會衝過頭、彈幾下、然後穩住(TouchDesigner 的 Spring CHOP)。 trail() value 最近幾秒的歷史,做成一個陣列,每秒 60 個值,最舊的在前、最新的在最後(TouchDesigner 的 Trail CHOP)。OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 p5/oav-p5.js 第 3052 行
speed(rate, [name])同一條也包含: OAVp5.chop.speed
每秒把 rate 加進一個累計值,所以 rotate(speed(TWO_PI)) 每秒轉一圈,而且速率隨時可以變,累計值不會跳(TouchDesigner 的 Speed CHOP)。
用累計值來轉或移動東西:rotate(speed(TWO_PI)) 是每秒轉一圈,x = speed(60) % width 是每秒滑 60 像素,到邊界再繞回來。你傳的是速率,不是位置,所以速率可以跟著旋鈕或麥克風走(speed(micLevel * TWO_PI) 越大聲轉越快),角度也不會跳,換成 frameCount * rate 就會跳。
時間是以秒計,任何畫面更新率都一樣。一個畫面最多只算四分之一秒,所以從被藏起來的分頁回來時,圖形不會一下子衝出去。
呼叫之前
不用呼叫別的東西,也不用宣告:直接寫在 draw() 裡。第一次呼叫會載入 @openav/chop,要幾個畫面。
參數 名稱 型別 說明 rateNumber | Boolean每秒要加多少,單位看你怎麼用結果(給 rotate() 就是弧度,給位置就是像素)。每個畫面都可以不一樣。布林值算成 0 或 1。 nameString | Number給這次呼叫的記憶取的名字,寫在最後一個參數。可以不寫。不寫的話,oav-p5 用 draw() 裡呼叫的先後順序來區分。有名字的呼叫,在哪裡呼叫都是同一個。 可省略 從 arguments 讀
回傳 Number 累計值,一開始是 0。@openav/chop 還沒載好之前(幾個畫面),以及在 draw() 外面沒有名字的呼叫,都回傳 0。
注意 沒有名字的呼叫靠它在 draw() 裡的順序找回自己的記憶:第一個 lag() 是第一個 lag,第二個是第二個。放進 if,或放進長度會變的迴圈,順序就會對不上。這時會跳出一張卡片,從那之後 oav-p5 改成照「寫在哪一行」來對應。最穩的做法是在最後加一個名字。在 draw() 外面,沒有名字的呼叫沒有東西可以記,會回傳「回傳」欄寫的值,也會跳出卡片。 很多草圖都有一個叫 speed 的變數。你的草圖有的話,名稱就歸你的草圖,而 OAVp5.chop.speed(…) 是同一個函式的另一個名字(第三個範例)。 範例
function setup() {
createCanvas(400, 300);
rectMode(CENTER);
}
function draw() {
background(20);
translate(width / 2, height / 2);
rotate(speed(TWO_PI)); // one turn a second
rect(0, 0, 120, 120);
}
一個方塊,每秒平穩地轉一圈。
function setup() {
createCanvas(400, 300);
rectMode(CENTER);
}
function draw() {
background(20);
const rate = map(mouseX, 0, width, -TWO_PI, TWO_PI); // turns per second, left to right
translate(width / 2, height / 2);
rotate(speed(rate));
rect(0, 0, 120, 120);
}
移動滑鼠:左邊讓方塊往一個方向轉,中間讓它停下,右邊往另一個方向轉,而且不會跳。
let speed = 2; // the sketch's own variable called speed
function setup() {
createCanvas(400, 300);
rectMode(CENTER);
}
function draw() {
background(20);
translate(width / 2, height / 2);
rotate(OAVp5.chop.speed(speed)); // the same function, under another name
rect(0, 0, 120, 120);
}
草圖保留自己的 speed(每秒 2 弧度),並用 OAVp5.chop.speed 呼叫這個函式。
相關 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 spring() 像彈簧上的重物那樣跟上 value:會衝過頭、彈幾下、然後穩住(TouchDesigner 的 Spring CHOP)。 slope() value 每秒變化多快:上升時是正的,下降時是負的,靜止時是 0(TouchDesigner 的 Slope CHOP)。OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 p5/oav-p5.js 第 3034 行
spring(value, [stiffness], [damping], [name])同一條也包含: OAVp5.chop.spring
像彈簧上的重物那樣跟上 value:會衝過頭、彈幾下、然後穩住(TouchDesigner 的 Spring CHOP)。
包住一個會跳的目標:circle(width / 2, height / 2, spring(mouseIsPressed ? 300 : 100, 100, 6))。大小會衝過頭、回來、再穩住,跟單純滑向新目標的 lag() 不一樣。
剛性(stiffness)和阻尼(damping)是兩個旋鈕。很硬、阻尼很小的彈簧像桌邊的尺一樣一直顫。很軟、阻尼很大的就只是緩緩靠近。目標跳很大,或畫面更新率很低,都不會弄壞它:任何更新率的動作都一樣。
呼叫之前
不用呼叫別的東西,也不用宣告:直接寫在 draw() 裡。第一次呼叫會載入 @openav/chop,要幾個畫面。
參數 名稱 型別 說明 valueNumber | Boolean彈簧被拉向的目標。布林值算成 0 或 1。 stiffnessNumber擺動得多快:100 大約每秒擺 1.6 次。越大越俐落。負數算 0。 可省略 預設 100 dampingNumber多快穩下來:6 會彈兩三下,20 以上就不彈了。剛好不再彈的界線是 2 * sqrt(stiffness)。 可省略 預設 6 nameString | Number給這次呼叫的記憶取的名字,寫在最後一個參數。可以不寫。不寫的話,oav-p5 用 draw() 裡呼叫的先後順序來區分。有名字的呼叫,在哪裡呼叫都是同一個。 可省略 從 arguments 讀
回傳 Number 彈簧現在的位置。看到的第一個值照單全收。@openav/chop 還沒載好之前(幾個畫面),以及在 draw() 外面沒有名字的呼叫,原樣回傳 value。
注意 沒有名字的呼叫靠它在 draw() 裡的順序找回自己的記憶:第一個 lag() 是第一個 lag,第二個是第二個。放進 if,或放進長度會變的迴圈,順序就會對不上。這時會跳出一張卡片,從那之後 oav-p5 改成照「寫在哪一行」來對應。最穩的做法是在最後加一個名字。在 draw() 外面,沒有名字的呼叫沒有東西可以記,會回傳「回傳」欄寫的值,也會跳出卡片。 OAVp5.chop.spring(…) 是同一個函式的另一個名字。範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const size = spring(mouseIsPressed ? 300 : 100, 100, 6); // overshoots, then settles
circle(width / 2, height / 2, size);
}
按住滑鼠:圓彈開、衝過頭、再穩住。放開就彈回去。
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const x = spring(mouseX, 60, 4); // soft and bouncy
const y = spring(mouseY, 60, 4);
circle(x, y, 40);
}
一個用軟彈簧掛在滑鼠上的圓:它繞著滑鼠擺動,不會黏在上面。
function setup() {
createCanvas(400, 300);
noStroke();
}
function draw() {
background(20);
const target = mouseIsPressed ? 340 : 60;
const dampings = [2, 6, 20];
for (let i = 0; i < 3; i++) {
const x = spring(target, 100, dampings[i]); // same pull, three dampings
circle(x, 60 + i * 90, 36);
}
}
按住滑鼠:三個圓被同一個彈簧、不同的阻尼往右拉。最上面的彈很多下,中間的彈幾下,最下面的完全不彈。
相關 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 speed() 每秒把 rate 加進一個累計值,所以 rotate(speed(TWO_PI)) 每秒轉一圈,而且速率隨時可以變,累計值不會跳(TouchDesigner 的 Speed CHOP)。 slope() value 每秒變化多快:上升時是正的,下降時是負的,靜止時是 0(TouchDesigner 的 Slope CHOP)。OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 p5/oav-p5.js 第 3039 行
trail(value, [seconds], [name])同一條也包含: OAVp5.chop.trail
value 最近幾秒的歷史,做成一個陣列,每秒 60 個值,最舊的在前、最新的在最後(TouchDesigner 的 Trail CHOP)。
要畫出來或量一量的歷史:drawTrail(trail(micLevel, 3), 20, 20, width - 40, 100) 畫出麥克風音量最近三秒的樣子,Math.max(...trail(x, 2)) 是兩秒內的最高值。不管畫面更新率是多少,都是每秒取 60 個值,兩個畫面之間的空隙用內插補起來,所以每秒 30 格和 144 格得到同樣的形狀。
在畫面之間改 seconds,歷史會跟著調整大小,保留最新的值。上一個畫面拿到的陣列之後不會再被改動。
呼叫之前
不用呼叫別的東西,也不用宣告:直接寫在 draw() 裡。第一次呼叫會載入 @openav/chop,要幾個畫面。
參數 名稱 型別 說明 valueNumber | Boolean要記錄的數字。布林值算成 0 或 1。不是有限數字的東西會被略過。 secondsNumber要保留幾秒的歷史,範圍是 0.05 到 600。 可省略 預設 2 nameString | Number給這次呼叫的記憶取的名字,寫在最後一個參數。可以不寫。不寫的話,oav-p5 用 draw() 裡呼叫的先後順序來區分。有名字的呼叫,在哪裡呼叫都是同一個。 可省略 從 arguments 讀
回傳 Array<Number> 大約 seconds * 60 + 1 個數字(2 秒是 121 個),每次呼叫都是一個新的陣列。長度從第一次呼叫就固定,並且用第一個值填滿,所以從第一個畫面就能畫。@openav/chop 還沒載好之前,以及在 draw() 外面沒有名字的呼叫,是空陣列。
注意 沒有名字的呼叫靠它在 draw() 裡的順序找回自己的記憶:第一個 lag() 是第一個 lag,第二個是第二個。放進 if,或放進長度會變的迴圈,順序就會對不上。這時會跳出一張卡片,從那之後 oav-p5 改成照「寫在哪一行」來對應。最穩的做法是在最後加一個名字。在 draw() 外面,沒有名字的呼叫沒有東西可以記,會回傳「回傳」欄寫的值,也會跳出卡片。 OAVp5.chop.trail(…) 是同一個函式的另一個名字。要在畫布上看這條線用 drawTrail() ,要在網頁角落看就用 scope() 。範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const wave = 0.5 + 0.5 * sin(frameCount * 0.05); // 0..1
stroke(255);
drawTrail(trail(wave, 3), 20, 100, width - 40, 100); // the last 3 seconds
}
用自己的歷史畫出一條正弦波:新的值從右邊進來,波形往左捲動。
function setup() {
createCanvas(400, 300);
noStroke();
}
function draw() {
background(20);
const t = trail(mouseX, 1); // where the mouse was during the last second
for (let i = 0; i < t.length; i += 6) {
fill(255, 211, 107, 10 + (i / t.length) * 200);
circle(t[i], mouseY, 24);
}
}
滑鼠後面拖著一串殘影:越舊的越淡。
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const t = trail(mouseX, 3); // the last 3 seconds of the mouse position
const left = Math.min(...t), right = Math.max(...t);
noStroke();
fill(255, 211, 107, 90);
rect(left, 0, right - left, height); // the band the mouse covered
stroke(255);
drawTrail(t, 0, 20, width, 60, 0, width);
}
一條色帶顯示滑鼠最近三秒往左往右走了多遠,上方畫出軌跡本身。
相關 drawTrail() 在畫布上畫一條穿過 values 的線,所以它會在作品裡、也會被錄進影片:最舊的在 x,lo 在方框底,hi 在方框頂。 scope() 把 value 顯示在網頁角落的小示波器上,並原樣還給你,所以可以包住任何運算式來觀察它。 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 slope() value 每秒變化多快:上升時是正的,下降時是負的,靜止時是 0(TouchDesigner 的 Slope CHOP)。OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 p5/oav-p5.js 第 3050 行
trigger([name])同一條也包含: OAVp5.chop.trigger
用名字發出一個你自己的事件,讓 env() 和 count() 去對它起反應。
在事情發生的地方呼叫它:function mousePressed() { trigger('boom'); }。然後在 draw() 的任何地方,用 env('boom', 0.01, 0.6)(會退下去的脈衝)或 count('boom')(到現在發生幾次)去讀它。它沒有回傳值。
它在哪裡都能用,不只是 draw(),每呼叫一次就是一個事件,連同發生的時間一起被記下來。內建的名字也可以用手發出:trigger('kick') 會讓 env('kick') 跳一下,就像真的大鼓一樣。
參數 名稱 型別 說明 nameString事件的名字。隨便取,只是要留意內建的意思:'kick'、'snare'、'hat'、'beat'、'note'、'bar'、'step' 也會被麥克風、琴鍵和鼓機發出。不寫就是 'trigger'。 可省略 預設 'trigger'
注意 OAVp5.chop.trigger(…) 是同一個函式的另一個名字。範例
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
trigger('boom' ); // an event of our own
}
function draw() {
background(20);
const k = env('boom' , 0.01, 0.6);
circle(width / 2, height / 2, 60 + k * 220);
}
點一下畫布:每點一下就發出一次 "boom",圓跳一下。
function setup() {
createCanvas(400, 300);
noStroke();
}
function mousePressed() { trigger('boom' ); }
function keyPressed() { trigger('spark' ); }
function draw() {
background(20);
fill(255, 90, 31);
circle(120, 150, 40 + env('boom' , 0.01, 0.6) * 160);
fill(120, 190, 255);
circle(280, 150, 40 + env('spark' , 0.005, 0.2) * 160);
}
點一下讓橘色的圓跳,按任何一個鍵讓藍色的圓跳:各自只對自己的事件起反應。
相關 env() 一個 0 到 1 的脈衝:每次事件(大鼓、拍子、琴鍵、按下)之後就跳起來,然後慢慢退下去(TouchDesigner 的 Trigger CHOP)。 count() 數 on 打開過幾次,或某個事件(像 'kick')發生過幾次。給了上限就會循環:0、1、…、上限 − 1(TouchDesigner 的 Count CHOP)。 OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 p5/oav-p5.js 第 3032 行
OAVp5.chop
CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。
這一組的每個函式這裡都有:OAVp5.chop.lag、.env、.trigger、.speed、.spring、.trail、.slope、.count、.scope、.drawTrail 跟一般的名稱是同一批函式,給那些自己的 speed 或 count 保留了一般名稱的草圖用。
其餘的是用來看裡面的:state 說模組載好了沒,module 就是模組本身,slots() 列出正在用的每一個記憶,reset() 把它們忘掉。
注意 這是進階用法:只呼叫 lag() 這些函式的草圖用不到它。 範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const x = OAVp5.chop.lag(mouseX, 0.4); // the same as lag(mouseX, 0.4)
fill(255);
text('chop ' + OAVp5.chop.state + ', ' + OAVp5.chop.slots().length + ' memory in use' , 20, 30);
circle(x, height / 2, 40);
}
一個透過 OAVp5.chop.lag 追著滑鼠的圓,上方印出模組的狀態和正在用的記憶數量。
相關 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 env() 一個 0 到 1 的脈衝:每次事件(大鼓、拍子、琴鍵、按下)之後就跳起來,然後慢慢退下去(TouchDesigner 的 Trigger CHOP)。 OAVp5.chop.state @openav/chop 模組目前的狀態:'off'、'loading'、'on' 或 'error'。 OAVp5.chop.slots() 列出 CHOP 函式現在用的每一個記憶:是哪一種、怎麼找到它、最新的值是多少。 OAVp5.chop.reset() 忘掉叫 name 的那個記憶(lag、speed 的累計、計數…),不給名字就全部忘掉:下一次呼叫會重新開始。 p5/oav-p5.js 第 2861 行
OAVp5.chop.module#
變數 oav-p5 OAVp5.chop.module
@openav/chop 模組本身,載好之後才有,之前是 null:CHOP 函式底下用的那些類別和函式。
要用到比 p5 風格呼叫更底層的東西時才需要:OAVp5.chop.module.DEFAULTS 列出這些函式用的預設值,new OAVp5.chop.module.Lag({ up: 0.1 }) 做出一個你自己保管的 lag。先確認它不是 null。
型別 Object | null第一次呼叫 CHOP 函式、而且模組載好之前是 null,之後就是那個模組(Lag、Spring、Trail、envelope()、DEFAULTS 等等)。
注意 這是進階用法。這個模組也以 @openav/chop 發布,給不經過 oav-p5 的草圖和演出使用。 範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
lag(mouseX, 0.3, 0.3, 'warm-up' ); // the first call loads the module
const m = OAVp5.chop.module;
fill(255);
text(m ? 'spring defaults: ' + JSON.stringify(m.DEFAULTS.spring) : 'not loaded yet' , 20, 30);
}
模組一載好,就印出它裡面 spring 的預設值。
相關 OAVp5.chop.state @openav/chop 模組目前的狀態:'off'、'loading'、'on' 或 'error'。 OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 p5/oav-p5.js 第 2842 行
OAVp5.chop.reset()#
函式 oav-p5 OAVp5.chop.reset([name])
忘掉叫 name 的那個記憶(lag、speed 的累計、計數…),不給名字就全部忘掉:下一次呼叫會重新開始。
接下來的那次呼叫會從零開始,就像是第一次:speed() 的累計值回到 0,lag() 照單全收輸入,count() 回到 0。想讓某個有名字的效果重新開始時用,例如按一個鍵的時候。
不給名字的時候,沒有名字的記憶也一起丟掉。下一個畫面會重新照順序建立,所以不會壞掉,但每一個沒有名字的值也都重新開始。trigger() 的事件次數不是某個呼叫的記憶,不受影響。
參數 名稱 型別 說明 nameString你在呼叫的最後一個參數給的名字(speed(1, 'spin') 裡的 'spin')。每個函式裡那個名字的記憶都會被忘掉。不寫的話,不管有沒有名字,全部忘掉。 可省略
範例
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
OAVp5.chop.reset('spin' ); // forget the named speed
}
function draw() {
background(20);
translate(width / 2, height / 2);
rotate(speed(1, 'spin' ));
rect(-40, -40, 80, 80);
}
方塊平穩地轉。點一下:它跳回起始的角度,再從那裡繼續轉。
相關 OAVp5.chop.slots() 列出 CHOP 函式現在用的每一個記憶:是哪一種、怎麼找到它、最新的值是多少。 OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 speed() 每秒把 rate 加進一個累計值,所以 rotate(speed(TWO_PI)) 每秒轉一圈,而且速率隨時可以變,累計值不會跳(TouchDesigner 的 Speed CHOP)。 count() 數 on 打開過幾次,或某個事件(像 'kick')發生過幾次。給了上限就會循環:0、1、…、上限 − 1(TouchDesigner 的 Count CHOP)。 p5/oav-p5.js 第 2855 行
OAVp5.chop.slots()#
函式 oav-p5 OAVp5.chop.slots()
列出 CHOP 函式現在用的每一個記憶:是哪一種、怎麼找到它、最新的值是多少。
用來查為什麼兩個呼叫好像共用同一個記憶,或迴圈是不是做出了好幾百個:OAVp5.chop.slots().length 就是現在有幾個。它只讀取狀態,不會改任何東西。
回傳 Array<Object> 每一個記憶一筆 { kind, key, mode, value }。kind 是哪個函式('lag'、'spring' …)。key 是靠順序找到的呼叫的 '#1'、'#2',有名字的就是名字,靠行號對應的就是那一行程式。mode 說明是哪一種:'order'、'name' 或 'site'。value 是它最近一次的輸出(trail 沒有)。
範例
function setup() {
createCanvas(400, 300);
textSize(14);
}
function draw() {
background(20);
lag(mouseX, 0.3);
lag(mouseY, 0.3);
spring(mouseX, 100, 6, 'ball' );
fill(255);
const lines = OAVp5.chop.slots().map((s) => s.kind + ' ' + s.key + ' (' + s.mode + ')' );
text(lines.join('\n' ), 20, 30);
}
列出這三個呼叫在用的記憶:兩個靠順序找到的 lag,和一個靠名字找到的 spring。
相關 OAVp5.chop.reset() 忘掉叫 name 的那個記憶(lag、speed 的累計、計數…),不給名字就全部忘掉:下一次呼叫會重新開始。 OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 p5/oav-p5.js 第 2844 行
OAVp5.chop.state#
變數 oav-p5 OAVp5.chop.state
@openav/chop 模組目前的狀態:'off'、'loading'、'on' 或 'error'。
在任何一個函式第一次被呼叫之前是 off,@openav/chop 在載入的時候(幾個畫面)是 loading,載好之後是 on,載不到是 error:這時會跳出卡片,所有函式也都改成把輸入直接穿過去。單獨呼叫 trigger() 不會載入它,因為它只記錄「發生了一件事」。
型別 String
範圍 'off' | 'loading' | 'on' | 'error'第一次呼叫 CHOP 函式的時候會變,模組載好時再變一次。在任何呼叫之前是 off。
範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('chop: ' + OAVp5.chop.state, 20, 30); // off, then loading, then on
if (frameCount > 30) circle(lag(mouseX, 0.3), height / 2, 30); // the first lag() loads the module
}
前半秒它顯示 off。接著第一次呼叫 lag() 就載入模組:loading,然後 on。
相關 OAVp5.chop.module @openav/chop 模組本身,載好之後才有,之前是 null:CHOP 函式底下用的那些類別和函式。 OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 p5/oav-p5.js 第 2841 行
@openav/chop
approach()#
函式 @openav/chop approach(current, target, seconds, dt)
一階低通跟隨的一步:把 current 往 target 推近,經過 seconds 秒走完 63% 的差距。
平滑裡最小的一塊。每個畫面呼叫一次,value = approach(value, target, 0.2, dt),value 就會追著 target 跑:經過 seconds 走完 63%,2.3 × seconds 走完 90%,4.6 × seconds 走完 99%。不管 dt 多大都是精確解,所以 30 fps 和 144 fps 畫出同一條曲線。
這裡的 seconds 是時間常數,比 TouchDesigner 的 Lag CHOP 的「秒數」短。Lag 收的是 TouchDesigner 那種秒數,會先除以 TD_LAG_RATIO 再呼叫這個函式。@openav/mapping 的 smooth 也是用 approach 一步一步走,意思同樣是時間常數。
呼叫之前
不需要先做什麼。它只是算術,沒有記憶:current 由你自己保管,並把距離上次呼叫的秒數傳進來。想要自帶記憶的版本,用 Lag 。
參數 名稱 型別 說明 currentNumber數值現在的位置。 targetNumber它要去的地方。 secondsNumber時間常數:經過這麼多秒,與 target 的差距縮小 63%(1 − 1/e)。0、負數或 NaN 會直接跳到 target,不管 dt 是多少。 dtNumber距離上一步過了幾秒。0、負數或 NaN 時 current 原地不動。
回傳 Number 新的數值,落在 current 與 target 之間。
範例
js 複製
import { approach } from '@openav/chop' ;
let value = 0;
const seen = [];
for (let frame = 0; frame < 6; frame++) {
value = approach(value, 1, 0.1, 1 / 60); // chase 1, time constant 0.1 s
seen.push(value.toFixed(3));
}
document.getElementById('app' ).textContent = seen.join(' ' );
module 一個數值追著 1 跑的六個畫面:每個畫面大約走完剩下差距的 15%(0.154、0.283、0.393 …)。
js 複製
import { approach } from '@openav/chop' ;
// one second of time constant: the gap is 63% closed after 1 s, 90% after 2.3 s
const seconds = 1;
const after = (t) => approach(0, 1, seconds, t).toFixed(3);
document.getElementById('app' ).textContent =
`after 1 s: ${after(1)} after 2.3 s: ${after(2.3026)} after 4.6 s: ${after(4.6052)}` ;
module 印出 0.632、0.900、0.990:值得記住的三個數字。
相關 Lag 輸入比輸出高的時候用一種速度跟上去,比輸出低的時候用另一種速度,就是 TouchDesigner 的 Lag CHOP。 TD_LAG_RATIO Math.LN10,約 2.303:一個 lag 走完 90% 要幾個時間常數,也就是 TouchDesigner 的 Lag 秒數的意思。lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 import { approach } from '@openav/chop'; packages/chop/index.js 第 38 行
new Count({ threshold = 0.5 } = {})
數輸入打開了幾次(true,或超過 threshold 的數字),給上限就會在 0 … 上限 − 1 之間循環。就是 Count CHOP。
count.step(input, limit) 數的是上升的那一下,也就是輸入從關變成開的瞬間,不是開著的時間。按住按鈕十秒也只算一次。給 limit 4,結果會是 0 1 2 3 0 1 …,一個拍子就這樣變成「四拍裡的第幾拍」。
就算給了上限,底下的計數器還是一直累加,所以之後改上限也不會失去進度。不是數字的值算成關。
在 p5 草圖裡請用 count() :count(beat, 4),或用 count('kick') 數鼓機的大鼓。
呼叫之前
不用載入任何東西。每個畫面都呼叫 step,連輸入關著的時候也要:它得看到「關」,才認得出下一次的「開」。它沒有 dt,因為計數跟時間無關。
參數 名稱 型別 說明 thresholdNumber數字超過這個值才算「打開」。布林值 true 是 1,false 是 0。 可省略 預設 0.5
成員 名稱 說明 step(input, limit = 0)method step(input, limit?):看一眼輸入並回傳計數。limit 大於 0 的話,計數會循環在 0 … limit − 1(小數會無條件捨去)。reset(value = 0)method reset(value?):把計數設成 value(沒給就是 0,小數會取整數)並忘掉輸入是否開著。回傳 Count 本身。
注意 公開的欄位有 threshold、on(輸入現在是不是開著)和 total(循環之前的計數)。 範例
js 複製
import { Count } from '@openav/chop' ;
const count = new Count();
const inputs = [0, 1, 1, 0, 1, true , false , true ];
document.getElementById('app' ).textContent =
inputs.map((x) => count.step(x)).join(' ' );
module 印出 0 1 1 1 2 2 2 3:輸入每次從關變開就加一,一直開著不會再加。
js 複製
import { Count } from '@openav/chop' ;
const count = new Count();
const beats = [];
for (let i = 0; i < 12; i++) beats.push(count.step(i % 2, 4)); // off, on, off, on …
document.getElementById('app' ).textContent = beats.join(' ' );
module 給上限 4,計數就會循環:0 1 1 2 2 3 3 0 0 1 1 2。
相關 count() 數 on 打開過幾次,或某個事件(像 'kick')發生過幾次。給了上限就會循環:0、1、…、上限 − 1(TouchDesigner 的 Count CHOP)。 wrapCount() 把計數循環到 0 … 上限 − 1:wrapCount(5, 4) 是 1。上限是 0 或更小就維持原樣。 envelope() 給定事件發生的時間 times,算出起音加衰減的包絡在時間 t 的高度(0 到 peak),就是 Trigger CHOP。 import { Count } from '@openav/chop'; packages/chop/index.js 第 225 行
DEFAULTS = object, 5 keys
這個套件裡每個 CHOP 的預設設定,收在一個凍結的物件裡:lag、envelope、spring、trail 和 count。
給文件和要顯示預設值的介面用,例如輸入欄的提示文字。這些類別和 envelope() 在自己的簽名裡已經寫了同樣的數字。改這個物件不會改變它們的行為。
型別 Object{ lag: { up: 0.1, down: 0.1 }, envelope: { attack: 0.01, decay: 0.3 }, spring: { stiffness: 100, damping: 6 }, trail: { seconds: 2, rate: 60 }, count: { threshold: 0.5 } }。已凍結:指定新值不會有作用。
範例
js 複製
import { DEFAULTS } from '@openav/chop' ;
document.getElementById('app' ).textContent = JSON.stringify(DEFAULTS.spring);
module 印出 {"stiffness":100,"damping":6}:沒給設定時 Spring 用的值。
相關 Lag 輸入比輸出高的時候用一種速度跟上去,比輸出低的時候用另一種速度,就是 TouchDesigner 的 Lag CHOP。 Spring 像吊在彈簧上的重物一樣追著輸入:會衝過頭、彈回來、再安定下來,就是輸入當作位置的 Spring CHOP。 Trail 把輸入最近幾秒的變化記成一串數值,最舊的在前,固定每秒 60 個,就是 Trail CHOP。 Count 數輸入打開了幾次(true,或超過 threshold 的數字),給上限就會在 0 … 上限 − 1 之間循環。就是 Count CHOP。 envelope() 給定事件發生的時間 times,算出起音加衰減的包絡在時間 t 的高度(0 到 peak),就是 Trigger CHOP。 import { DEFAULTS } from '@openav/chop'; packages/chop/index.js 第 243 行
envelope()#
函式 @openav/chop envelope(times, t, attack = 0.01, decay = 0.3, peak = 1)
給定事件發生的時間 times,算出起音加衰減的包絡在時間 t 的高度(0 到 peak),就是 Trigger CHOP。
把某件事發生的時間(大鼓、按鍵、cue)存成一個清單,畫的時候問它「現在多高」:scale(1 + envelope(kicks, now, 0.01, 0.3))。它在 attack 秒內直線升到 1,再用 decay 秒落回 0,一開始快、收尾柔。
它是 times 和 t 的純函式:問兩次、或在 30 fps 與 144 fps 問同一個時間點,答案一樣。attack 和 decay 每個畫面都可以改(例如用旋鈕調 decay)。上一個事件還沒消散時又來一個,包絡從目前的高度重新出發,不會先掉到 0,所以快速的連擊會堆成一個高原,不會閃爍。起音以 0 到峰值計算,所以從一半高度重新觸發只要一半的起音時間。
在 p5 草圖裡不用 import:env() 和 trigger() 會替你保管事件清單,也會讀鼓機和麥克風。
呼叫之前
不用載入任何東西。事件的時間由你自己記(例如大鼓來的時候 kicks.push(performance.now() / 1000)),t 要用同一個時鐘。
參數 名稱 型別 說明 timesNumber[]事件發生的時間,單位是秒,用哪個時鐘都行,最舊的排前面。空陣列(或沒給)回傳 0。比 t 晚的事件還沒發生,會被忽略。只有最後幾筆有影響,所以可以把舊的剪掉。 tNumber要讀取的時間點,跟 times 用同一個時鐘。 attackNumber從 0 升到峰值要幾秒。0 就一開始直接在峰值。 可省略 預設 0.01 decayNumber到達峰值後落回 0 要幾秒:事件之後 attack + decay 秒起正好是 0。 可省略 預設 0.3 peakNumber包絡的最高點。 可省略 預設 1
回傳 Number 0 到 peak:事件剛發生時是 peak,消散之後是 0。
範例
js 複製
import { envelope } from '@openav/chop' ;
const kicks = [1]; // one kick, at 1 s
const times = [0.9, 1, 1.005, 1.01, 1.1, 1.2, 1.31];
document.getElementById('app' ).innerHTML = times
.map((t) => `${t} s: ${envelope(kicks, t).toFixed(2)}` )
.join('<br>' );
module 大鼓之前是 0.00,1.005 秒到起音一半 0.50,1.01 秒到峰值 1.00,然後是 0.49、0.13,1.31 秒回到 0.00。
js 複製
import { envelope } from '@openav/chop' ;
const ctx = document.getElementById('c' ).getContext('2d' );
const kicks = [];
let nextKick = 0;
function frame(ms) {
const t = ms / 1000;
if (t >= nextKick) { kicks.push(nextKick); nextKick += 0.5; } // a kick every half second
const level = envelope(kicks, t, 0.01, 0.3);
ctx.fillStyle = '#111' ; ctx.fillRect(0, 0, 400, 300);
ctx.fillStyle = '#fc4' ;
ctx.beginPath(); ctx.arc(200, 150, 40 + level * 80, 0, Math.PI * 2); ctx.fill();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
module 一個圓盤,每次大鼓(每秒兩次)都脹大,0.3 秒內縮回去。
js 複製
import { envelope } from '@openav/chop' ;
// three hits 50 ms apart pile up; one lone hit at the same moment is much lower
const pile = envelope([0, 0.05, 0.1], 0.11);
const lone = envelope([0], 0.11);
document.getElementById('app' ).textContent =
`pile-up: ${pile.toFixed(2)} lone hit: ${lone.toFixed(2)}` ;
module 印出「pile-up: 0.95 lone hit: 0.44」:每一擊都從目前高度重新出發,所以密集的擊打會把它撐在高處。
相關 envelopeStage() 一個包絡在被觸發 elapsed 秒之後的高度:先直線升到 peak,再用 decay 秒落回 0。 env() 一個 0 到 1 的脈衝:每次事件(大鼓、拍子、琴鍵、按下)之後就跳起來,然後慢慢退下去(TouchDesigner 的 Trigger CHOP)。 Lag 輸入比輸出高的時候用一種速度跟上去,比輸出低的時候用另一種速度,就是 TouchDesigner 的 Lag CHOP。 Count 數輸入打開了幾次(true,或超過 threshold 的數字),給上限就會在 0 … 上限 − 1 之間循環。就是 Count CHOP。 import { envelope } from '@openav/chop'; packages/chop/index.js 第 86 行
envelopeStage()#
函式 @openav/chop envelopeStage(elapsed, attack, decay, from = 0, peak = 1)
一個包絡在被觸發 elapsed 秒之後的高度:先直線升到 peak,再用 decay 秒落回 0。
envelope() 的基本零件,它把好幾個這樣的片段串起來處理一串事件。單獨使用時,它回答的是:「elapsed 秒前發生的一次大鼓,現在有多高?」用 envelope() 的預設值(attack 0.01、decay 0.3),5 毫秒時是 0.5,10 毫秒是 1,100 毫秒是 0.49,310 毫秒起是 0。
下降一開始很快、收尾很柔:大鼓的律動配這個形狀看起來最對。它剛好在 decay 秒落到 0,不是無限接近。
參數 名稱 型別 說明 elapsedNumber觸發之後過了幾秒。小於 0(或 NaN)時回傳 from。 attackNumber從 0 升到 peak 要幾秒。0 或更小就一開始直接在峰值。 decayNumber從 peak 落到 0 要幾秒,走 (1 − u)² 的曲線(u 從 0 跑到 1)。0 或更小就在起音結束後立刻變 0。 fromNumber觸發當下的高度。起音仍然以 0 到峰值計算,所以從一半高度開始只要一半的起音時間。 可省略 預設 0 peakNumber包絡的最高點。 可省略 預設 1
範例
js 複製
import { envelopeStage } from '@openav/chop' ;
const row = [0, 0.005, 0.01, 0.1, 0.16, 0.31].map(
(t) => `${t} s: ${envelopeStage(t, 0.01, 0.3).toFixed(2)}`
);
document.getElementById('app' ).textContent = row.join(' ' );
module 印出觸發後六個時間點的高度:0.00、0.50、1.00、0.49、0.25、0.00。
相關 envelope() 給定事件發生的時間 times,算出起音加衰減的包絡在時間 t 的高度(0 到 peak),就是 Trigger CHOP。 env() 一個 0 到 1 的脈衝:每次事件(大鼓、拍子、琴鍵、按下)之後就跳起來,然後慢慢退下去(TouchDesigner 的 Trigger CHOP)。 Lag 輸入比輸出高的時候用一種速度跟上去,比輸出低的時候用另一種速度,就是 TouchDesigner 的 Lag CHOP。 import { envelopeStage } from '@openav/chop'; packages/chop/index.js 第 70 行
new Lag({ up = 0.1, down = up, value = null } = {})
輸入比輸出高的時候用一種速度跟上去,比輸出低的時候用另一種速度,就是 TouchDesigner 的 Lag CHOP。
每一個要平滑的數值做一個 Lag,每個畫面呼叫一次 step(input, dt)。new Lag({ up: 0.03, down: 0.4 }) 就是音量表:幾乎立刻張開,慢慢收回。up 和 down 用 TouchDesigner 的秒數:up: 1 的話,從 0 跳到 1 之後一秒是 0.9,30、60 或 144 fps 都一樣。
每一步都是「輸入在這 dt 秒內維持新值」的精確解,所以影格率不會改變曲線。布林值算成 0 或 1(lag.step(mouseIsPressed, dt) 是柔和的開關)。不是數字的值(像 NaN)原樣傳回,記憶不受影響。
在 p5 草圖裡不用 import 它:lag() 會替每一個呼叫保管一個 Lag。
呼叫之前
不用載入或啟動任何東西。把距離上一個畫面的秒數當作 dt 傳進來(瀏覽器裡:兩次 performance.now() / 1000 的差)。第一次 step 只是把輸入記下來,所以第一個畫面輸出等於輸入。
參數 名稱 型別 說明 upNumber追上升高的輸入要幾秒(輸入比輸出高的時候)。用 TouchDesigner 的秒數:大約是走完 90% 跳躍的時間。0 表示立刻跟上。 可省略 預設 0.1 downNumber追上降低的輸入要幾秒(輸入比輸出低的時候)。不給就跟 up 一樣。 可省略 預設 up valueNumber起始值。不給的話,第一個輸入原樣當作輸出,所以不會從 0 慢慢滑進來。 可省略 預設 null
注意 公開的欄位有 up、down 和 value(最後一次的輸出,第一次 step 之前是 null)。up、down 隨時可以改。 底層的時間常數是 seconds / TD_LAG_RATIO(見 TD_LAG_RATIO ),不是 seconds。想要單純 63% 那一種,直接呼叫 approach() 。 範例
js 複製
import { Lag } from '@openav/chop' ;
const lag = new Lag({ up: 0.03, down: 0.4 }); // opens fast, closes slowly
const seen = [];
for (let frame = 0; frame < 12; frame++) {
const input = frame >= 2 && frame < 6 ? 1 : 0; // on for four frames
seen.push(lag.step(input, 1 / 60).toFixed(2));
}
document.getElementById('app' ).textContent = seen.join(' ' );
module 輸入打開時,輸出兩個畫面內就衝上去,輸入關掉之後才慢慢掉下來(0.00 0.00 0.72 0.92 0.98 0.99 0.90 0.82 …)。
js 複製
import { Lag } from '@openav/chop' ;
const ctx = document.getElementById('c' ).getContext('2d' );
ctx.fillStyle = '#111' ; ctx.fillRect(0, 0, 400, 300);
ctx.lineWidth = 2;
const input = (t) => (t < 0.25 || t > 1.25 ? 0 : 1); // a pulse, 1 s long
for (const [fps, color] of [[30, '#555' ], [30, '#fa4' ], [144, '#4af' ]]) {
const lag = new Lag({ up: 0.1, down: 0.4 });
ctx.strokeStyle = color; ctx.beginPath();
for (let k = 0; k <= fps * 2; k++) {
const t = k / fps;
const y = color === '#555' ? input(t) : lag.step(input(t), k ? 1 / fps : 0);
ctx.lineTo(20 + t * 180, 260 - y * 220);
}
ctx.stroke();
}
module 一個一秒長的脈衝(灰)分別用 30 fps(橘)和 144 fps(藍)走過 Lag:大約 0.1 秒升上去、0.4 秒左右落下來,兩條曲線疊在一起。
js 複製
import { Lag } from '@openav/chop' ;
const lag = new Lag(); // up and down are both 0.1
lag.step(0.5, 0); // the first input is taken as it is
const nan = lag.step(NaN, 1 / 60); // not a number: passed through, memory kept
const kept = lag.value; // still 0.5
const toward = lag.step(true , 1 / 60); // a boolean counts as 1, so it moves up from 0.5
document.getElementById('app' ).textContent =
`NaN in: ${nan}, memory still ${kept}, true in: ${toward.toFixed(2)}` ;
module 印出「NaN in: NaN, memory still 0.5, true in: 0.66」:不是數字就不動記憶,布林值算成 0 或 1。
相關 approach() 一階低通跟隨的一步:把 current 往 target 推近,經過 seconds 秒走完 63% 的差距。 TD_LAG_RATIO Math.LN10,約 2.303:一個 lag 走完 90% 要幾個時間常數,也就是 TouchDesigner 的 Lag 秒數的意思。lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 Spring 像吊在彈簧上的重物一樣追著輸入:會衝過頭、彈回來、再安定下來,就是輸入當作位置的 Spring CHOP。 envelope() 給定事件發生的時間 times,算出起音加衰減的包絡在時間 t 的高度(0 到 peak),就是 Trigger CHOP。 import { Lag } from '@openav/chop'; packages/chop/index.js 第 49 行
new Slope()
輸入變化得多快,單位是每秒多少,第一步是 0。就是 Slope CHOP。
slope.step(value, dt) 回傳 (value − 上一個值) / dt。每秒升 3 的數值,在任何影格率下都回傳 3,沒動的數值是 0,往下掉的是負數。用來表達「旋鈕剛剛轉得多用力」:scale(1 + abs(slope.step(knob, dt)))。
它不做任何平滑:輸入跳一下,就是一步裡的一根大尖峰。想讓它淡出,把結果交給 Lag 。dt 是 0 或更小,什麼都不改,回傳上一次的斜率。值不是數字時也回傳上一次的斜率。
在 p5 草圖裡請用 slope() 。
呼叫之前
不用載入任何東西。第一次 step 只是把值記下來並回傳 0,因為沒有更早的值可以比。
範例
js 複製
import { Slope } from '@openav/chop' ;
const slope = new Slope();
const seen = [];
for (const [value, dt] of [[0, 1 / 60], [0.05, 1 / 60], [0.1, 1 / 60], [0.1, 1 / 60]]) {
seen.push(slope.step(value, dt).toFixed(1));
}
document.getElementById('app' ).textContent = seen.join(' ' );
module 印出 0.0 3.0 3.0 0.0:第一步沒有東西,上升時每秒 3 單位,停下來就是 0。
相關 slope() value 每秒變化多快:上升時是正的,下降時是負的,靜止時是 0(TouchDesigner 的 Slope CHOP)。Speed 每秒把 rate 加進一個累計值:每秒多少單位的速率變成位置,就是 Speed CHOP。 Lag 輸入比輸出高的時候用一種速度跟上去,比輸出低的時候用另一種速度,就是 TouchDesigner 的 Lag CHOP。 import { Slope } from '@openav/chop'; packages/chop/index.js 第 209 行
new Speed({ value = 0 } = {})
每秒把 rate 加進一個累計值:每秒多少單位的速率變成位置,就是 Speed CHOP。
speed.step(rate, dt) 把 rate * dt 加到累計值裡並回傳。rate 是 1 的話,累計值每秒增加 1。rotate(speed.step(TWO_PI, dt)) 就是每秒轉一圈。餵給它會變動的速率(旋鈕、micLevel),動作會平順地變快變慢,不會像把 frameCount 乘上速率那樣一跳一跳。
它是速率的積分,所以不論影格率都一樣。布林值的速率在 true 的時候算每秒 1。速率不是數字、或 dt 是 0 或更小,就什麼都不加。
在 p5 草圖裡請用 speed() ,它會替每個呼叫保管一個。
呼叫之前
不用載入任何東西。把距離上一個畫面的秒數當作 dt 傳進來。
參數 名稱 型別 說明 valueNumber累計值的起點。不是有限數字的話就從 0 開始。 可省略 預設 0
範例
js 複製
import { Speed } from '@openav/chop' ;
const speed = new Speed();
const seen = [];
for (let i = 0; i < 4; i++) seen.push(speed.step(2, 0.5).toFixed(1)); // 2 units a second
document.getElementById('app' ).textContent = seen.join(' ' );
module 印出 1.0 2.0 3.0 4.0:每秒 2 單位,每一步半秒。
js 複製
import { Speed } from '@openav/chop' ;
const ctx = document.getElementById('c' ).getContext('2d' );
const spin = new Speed();
let last = performance.now();
function frame(now) {
const dt = (now - last) / 1000; last = now;
const angle = spin.step(Math.PI * 2, dt); // one turn a second
ctx.fillStyle = '#111' ; ctx.fillRect(0, 0, 400, 300);
ctx.strokeStyle = '#fc4' ; ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(200, 150);
ctx.lineTo(200 + Math.cos(angle) * 90, 150 + Math.sin(angle) * 90); ctx.stroke();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
module 一根每秒轉一圈的指針,不論影格率。把速率換成會變動的數字(音量、旋鈕),它就會平順地加速減速。
相關 speed() 每秒把 rate 加進一個累計值,所以 rotate(speed(TWO_PI)) 每秒轉一圈,而且速率隨時可以變,累計值不會跳(TouchDesigner 的 Speed CHOP)。 Slope 輸入變化得多快,單位是每秒多少,第一步是 0。就是 Slope CHOP。 Lag 輸入比輸出高的時候用一種速度跟上去,比輸出低的時候用另一種速度,就是 TouchDesigner 的 Lag CHOP。 import { Speed } from '@openav/chop'; packages/chop/index.js 第 100 行
new Spring({ stiffness = 100, damping = 6, value = null } = {})
像吊在彈簧上的重物一樣追著輸入:會衝過頭、彈回來、再安定下來,就是輸入當作位置的 Spring CHOP。
每個數值做一個 Spring,每個畫面呼叫一次 step(target, dt),畫它回傳的值。用預設值(stiffness 100、damping 6)的話,輸入從 0 跳到 1,輸出在約三分之一秒時衝到 1.37,大約 1.4 秒之後就落在 1 的 1% 以內。把 damping 調到 20,它就會滑到目標上,不衝過頭。
每一步都是 springStep() 的精確解,所以 30 fps 和 144 fps 動得一樣,一個很長的畫面也不會讓它發散。輸入是它被拉向的位置,布林值算成 0 或 1,不是數字的值原樣傳回,什麼都不改。
在 p5 草圖裡請用 spring() 。
呼叫之前
不用載入任何東西。把距離上一個畫面的秒數當作 dt 傳進來。第一次 step 只是把輸入記下來(彈簧就靜止在那裡開始)。
參數 名稱 型別 說明 stiffnessNumber彈簧拉得多用力,也就是擺動多快。沒有阻尼的時候,每秒大約擺 √stiffness / 2π 次(100 大約是 1.6 次)。 可省略 預設 100 dampingNumber多快安定下來。stiffness 100 配 damping 6 會彈幾下。2 * √stiffness(100 的話是 20)以上就完全不彈。 可省略 預設 6 valueNumber起始位置。不給的話,第一個輸入原樣當作位置,所以不會從 0 擺進來。 可省略 預設 null
注意 公開的欄位有 stiffness、damping、value(位置,第一次 step 之前是 null)和 velocity。 範例
js 複製
import { Spring } from '@openav/chop' ;
const spring = new Spring(); // stiffness 100, damping 6
spring.step(0, 0); // rest at 0
const seen = [];
for (let frame = 0; frame < 24; frame += 3) {
for (let i = 0; i < 3; i++) spring.step(1, 1 / 60); // the input jumps to 1
seen.push(spring.value.toFixed(2));
}
document.getElementById('app' ).textContent = seen.join(' ' );
module 輸入跳到 1 之後每隔三個畫面看一次:數值衝過 1(約 1.37)、彈回來、再安定。
js 複製
import { Spring } from '@openav/chop' ;
const ctx = document.getElementById('c' ).getContext('2d' );
ctx.fillStyle = '#111' ; ctx.fillRect(0, 0, 400, 300);
ctx.lineWidth = 2;
const target = (t) => (t < 0.2 ? 0 : 1); // the input jumps at 0.2 s
for (const [damping, color] of [[null , '#555' ], [6, '#fa4' ], [20, '#4af' ]]) {
const s = new Spring({ stiffness: 100, damping: damping ?? 6 });
ctx.strokeStyle = color; ctx.beginPath();
for (let k = 0; k <= 180; k++) {
const t = k / 60;
const y = damping === null ? target(t) : s.step(target(t), k ? 1 / 60 : 0);
ctx.lineTo(20 + t * 120, 250 - y * 150);
}
ctx.stroke();
}
module 兩個彈簧追同一個跳躍(灰):damping 6(橘)衝過頭再晃幾下,damping 20(藍)直接滑到目標。
相關 springStep() 質量為 1 的阻尼彈簧朝 target 被拉的一個精確步驟,回傳 [位置, 速度]。 spring() 像彈簧上的重物那樣跟上 value:會衝過頭、彈幾下、然後穩住(TouchDesigner 的 Spring CHOP)。 Lag 輸入比輸出高的時候用一種速度跟上去,比輸出低的時候用另一種速度,就是 TouchDesigner 的 Lag CHOP。 approach() 一階低通跟隨的一步:把 current 往 target 推近,經過 seconds 秒走完 63% 的差距。 import { Spring } from '@openav/chop'; packages/chop/index.js 第 149 行
springStep()#
函式 @openav/chop springStep(x, v, target, stiffness, damping, dt)
質量為 1 的阻尼彈簧朝 target 被拉的一個精確步驟,回傳 [位置, 速度]。
對 dt 這段時間內 target 不動的情況,精確解 x'' = −stiffness·(x − target) − damping·x'。欠阻尼(會彈)、臨界阻尼、過阻尼(慢慢爬)三種情況都處理,而且 dt 再大也不會發散,所以草圖卡了一整秒,彈簧也只是落在目標上。
damping = 2 * Math.sqrt(stiffness) 是剛好不再彈的臨界點:stiffness 是 100 的話,就是 damping 20。沒有彈簧(stiffness 為 0)的時候只剩阻尼作用,速度慢慢消失。
參數 名稱 型別 說明 xNumber現在的位置。 vNumber現在的速度,單位是每秒多少單位。 targetNumber彈簧另一端固定的位置。 stiffnessNumber拉得多用力。負數當作 0。 dampingNumber摩擦力多大。負數當作 0。 dtNumber要前進幾秒。0 或更小(或 NaN)原樣回傳 [x, v]。
回傳 [Number, Number] 新的位置與速度。
注意 進階用法。Spring 類別會替你保管位置與速度,並呼叫這個函式。 範例
js 複製
import { springStep } from '@openav/chop' ;
let x = 0, v = 0;
const seen = [];
for (let frame = 0; frame < 8; frame++) {
[x, v] = springStep(x, v, 1, 100, 6, 1 / 60); // pulled toward 1
seen.push(x.toFixed(2));
}
document.getElementById('app' ).textContent = seen.join(' ' );
module 從 0 放開、朝 1 拉的彈簧的八個畫面:愈來愈快(0.01、0.05、0.11 …)。
js 複製
import { springStep } from '@openav/chop' ;
const [x, v] = springStep(0, 0, 1, 100, 6, 10); // a ten-second step in one go
document.getElementById('app' ).textContent = `after 10 s: position ${x.toFixed(3)}, velocity ${v.toFixed(3)}` ;
module 一次走十秒也只是落在目標上(位置 1.000、速度 0.000),不會發散。
相關 Spring 像吊在彈簧上的重物一樣追著輸入:會衝過頭、彈回來、再安定下來,就是輸入當作位置的 Spring CHOP。 spring() 像彈簧上的重物那樣跟上 value:會衝過頭、彈幾下、然後穩住(TouchDesigner 的 Spring CHOP)。 approach() 一階低通跟隨的一步:把 current 往 target 推近,經過 seconds 秒走完 63% 的差距。 import { springStep } from '@openav/chop'; packages/chop/index.js 第 116 行
TD_LAG_RATIO#
常數 @openav/chop TD_LAG_RATIO = 2.302585092994046
Math.LN10,約 2.303:一個 lag 走完 90% 要幾個時間常數,也就是 TouchDesigner 的 Lag 秒數的意思。
TouchDesigner 的 Lag CHOP 把秒數定義成大約是輸出追上 90% 變化所花的時間。一階低通要走 ln 10 個時間常數才到 90%,所以 seconds / TD_LAG_RATIO 就是該交給 approach() 的時間常數。Lag 做的正是這件事,所以 p5 草圖裡的 lag(x, 0.1) 動起來就像把 Lag CHOP 設成 0.1。
只有在兩種秒數混著用的時候才會用到它:時間常數 tau 等於 tdSeconds / TD_LAG_RATIO,tdSeconds 等於 tau * TD_LAG_RATIO。envelope() 的 attack 和 decay 是單純的長度,不用換算。
型別 Number就是 Math.LN10,2.302585092994046。固定的數字,不會變。
範例
js 複製
import { approach, TD_LAG_RATIO } from '@openav/chop' ;
const tdSeconds = 0.5; // as you would type it into a TouchDesigner Lag CHOP
const tau = tdSeconds / TD_LAG_RATIO; // the time constant approach() wants
const reached = approach(0, 1, tau, tdSeconds);
document.getElementById('app' ).textContent =
`tau = ${tau.toFixed(3)} s, after ${tdSeconds} s the value is ${reached.toFixed(2)}` ;
module 印出「tau = 0.217 s, after 0.5 s the value is 0.90」:Lag CHOP 的 0.5 秒就是走完 90%。
相關 approach() 一階低通跟隨的一步:把 current 往 target 推近,經過 seconds 秒走完 63% 的差距。 Lag 輸入比輸出高的時候用一種速度跟上去,比輸出低的時候用另一種速度,就是 TouchDesigner 的 Lag CHOP。 lag() 讓數值平順地跟上 value,不要跳:上升花 up 秒,下降花 down 秒(TouchDesigner 的 Lag CHOP)。 import { TD_LAG_RATIO } from '@openav/chop'; packages/chop/index.js 第 29 行
new Trail({ seconds = 2, rate = 60 } = {})
把輸入最近幾秒的變化記成一串數值,最舊的在前,固定每秒 60 個,就是 Trail CHOP。
trail.step(value, dt) 回傳一個陣列:seconds * rate 個過去的值,最後接上這一步的輸入,所以長度是 seconds * rate + 1(預設是 121)。把它橫跨畫布畫成一條線,就是這個數值的示波器。
長度從第一步就固定,用第一個值填滿,所以草圖從第一個畫面就能畫出整條線。步與步之間會內插,所以 30 fps 和 144 fps 得到同一條線。如果有一段空檔比整個視窗還長(例如分頁被藏起來),就用新的值填滿重新開始。
在 p5 草圖裡請用 trail() 和 drawTrail() 。
呼叫之前
不用載入任何東西。把距離上一個畫面的秒數當作 dt 傳進來。不是數字的值不會改動歷史,只是回傳目前的清單。
參數 名稱 型別 說明 secondsNumber要保留幾秒的歷史。0 或更小的話改用 2。 可省略 預設 2 rateNumber清單裡每秒幾個值。0 或更小的話改用 60。 可省略 預設 60
成員 名稱 說明 sizegetter 保留幾個過去的值:seconds * rate 四捨五入後,限制在 2 到 36000 之間。step() 回傳的清單比它多一個。 ordered()method ordered():儲存的過去的值,最舊的在前,不含最新的輸入(第一次 step 之前是空陣列)。step(value, dt, seconds = this.seconds)method step(value, dt, seconds?):收下這個畫面的 value 並回傳清單。給不同的 seconds 會調整視窗大小,並保留最新的值。values()method values():再取一次清單,最舊的在前、最新的輸入在最後。第一次 step 之前是空陣列。reset()method reset():清掉歷史。下一次 step 會再用它的值填滿清單。回傳 Trail 本身。
範例
js 複製
import { Trail } from '@openav/chop' ;
const trail = new Trail({ seconds: 0.1 }); // 6 values a tenth of a second
let list;
for (let i = 0; i < 10; i++) list = trail.step(i, 1 / 60);
document.getElementById('app' ).textContent =
`${list.length} values: ${list.join(' ')}` ;
module 印出「7 values: 4 5 6 7 8 9 9」:輸入最近的六步,最後再接一次最新的輸入。
js 複製
import { Trail } from '@openav/chop' ;
const ctx = document.getElementById('c' ).getContext('2d' );
const trail = new Trail({ seconds: 3 });
let last = performance.now();
function frame(now) {
const dt = (now - last) / 1000; last = now;
const list = trail.step(0.5 + 0.5 * Math.sin(now / 400), dt); // a slow sine, 0..1
ctx.fillStyle = '#111' ; ctx.fillRect(0, 0, 400, 300);
ctx.strokeStyle = '#4af' ; ctx.beginPath();
list.forEach((v, i) => ctx.lineTo((i / (list.length - 1)) * 400, 260 - v * 220));
ctx.stroke();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
module 一個正弦波的示波器,從畫布上捲過去:最新的值在右邊緣,後面是三秒的歷史(過滿三秒之前,左邊是平的)。
相關 trail() value 最近幾秒的歷史,做成一個陣列,每秒 60 個值,最舊的在前、最新的在最後(TouchDesigner 的 Trail CHOP)。drawTrail() 在畫布上畫一條穿過 values 的線,所以它會在作品裡、也會被錄進影片:最舊的在 x,lo 在方框底,hi 在方框頂。 scope() 把 value 顯示在網頁角落的小示波器上,並原樣還給你,所以可以包住任何運算式來觀察它。 Slope 輸入變化得多快,單位是每秒多少,第一步是 0。就是 Slope CHOP。 import { Trail } from '@openav/chop'; packages/chop/index.js 第 169 行
wrapCount()#
函式 @openav/chop wrapCount(total, limit)
把計數循環到 0 … 上限 − 1:wrapCount(5, 4) 是 1。上限是 0 或更小就維持原樣。
Count 使用的取餘數,並且對負數也安全:wrapCount(-1, 4) 是 3,而 -1 % 4 是 -1。拿來處理任何要循環的東西也很方便,例如步序編號:wrapCount(frameCount, 16)。
參數 名稱 型別 說明 totalNumber目前累計的次數。 limitNumber要循環幾個值。小數會無條件捨去。0、負數或沒給,就原樣回傳 total。
回傳 Number 循環到 0 … limit − 1 的 total(就算 total 是負的,結果也不會是負的)。
範例
js 複製
import { wrapCount } from '@openav/chop' ;
const row = [0, 3, 4, 5, 9, -1].map((n) => wrapCount(n, 4));
document.getElementById('app' ).textContent =
`0 3 4 5 9 -1 in a loop of 4: ${row.join(' ')} no limit: ${wrapCount(5, 0)}` ;
module 印出 0 3 0 1 1 3。沒有上限的話則是 5。
相關 Count 數輸入打開了幾次(true,或超過 threshold 的數字),給上限就會在 0 … 上限 − 1 之間循環。就是 Count CHOP。 count() 數 on 打開過幾次,或某個事件(像 'kick')發生過幾次。給了上限就會循環:0、1、…、上限 − 1(TouchDesigner 的 Count CHOP)。 import { wrapCount } from '@openav/chop'; packages/chop/index.js 第 237 行
錄影與錄音#
startRecording():畫布和聲音錄成一支影片;@openav/record。 49
oav-p5
recordingIsOn
現在有沒有在錄影。
用它把東西留在影片之外:if (!recordingIsOn) text('press r to record', 20, 30);,因為畫布上的所有東西都會被錄進去。也可以用在開關鍵上:recordingIsOn ? stopRecording() : startRecording()。它只在錄影開始和結束的時候改變,讀它不會啟動任何東西。想知道其他階段(starting、saving),請讀 OAVp5.record.state 。
型別 Boolean錄影進行中是 true。一開始、錄影程式還在載入、從呼叫 stopRecording() 的那一刻起(檔案這時還在製作)、以及錄完之後,都是 false。
注意 如果草圖本來就宣告了自己的 recordingIsOn 變數,就用你的:oav-p5 不動這個名稱,並用一張卡片提醒你改讀 OAVp5.record.state === 'on'。 範例
function setup() {
createCanvas(400, 300);
textSize(16);
}
function keyPressed() {
if (key === 'r' ) recordingIsOn ? stopRecording() : startRecording();
}
function draw() {
background(20);
fill(255, 120, 90);
circle(200 + sin(frameCount * 0.05) * 120, 150, 50);
if (!recordingIsOn) { // the guide never reaches the video
fill(255);
text('r: start / stop recording' , 20, 30);
}
}
按 r 之前畫布上有說明文字,錄影的時候消失,所以影片裡只有那個圓。
p5/oav-p5.js 第 317 行
recordingSaved()#
事件 oav-p5 function recordingSaved(info) {}
一個由你定義的事件函式:錄影存好、檢查完之後呼叫它,帶著一個說明這支檔案的 info 物件。
定義 function recordingSaved(info) { … },就知道檔案什麼時候好了:stopRecording() 或 seconds 時間到之後會走到這裡。那時檔案已經交給瀏覽器下載了。同一份 info 也會交給 stopRecording() 回傳的 promise 和 OAVp5.record.on('saved', fn),並且留在 OAVp5.record.last 。
info.url 可以當成檔案的連結用(createA(info.url, 'play') 或 <video>),直到下一支錄好之後一分鐘為止。warnings 是錄完之後卡片用文字說的那些事,空的清單表示沒有哪裡不對。
參數 名稱 型別 說明 infoObjectname(檔名)、format('mp4' 或 'webm')、type(精確的 MIME 類型)、seconds(長度,精確到 0.1 秒)、bytes、width 和 height(MP4 會是偶數)、fps、audio(影片有沒有音軌)、heard(有沒有真的出過聲)、sources(錄進去的聲音來源:'sound'、'drums'、'mic'、'p5sound')、warnings(看起來哪裡不對:'recEmpty'、'recWebm'、'recNoAudio'、'recSilent'、'recBlack',通常是空的)、blob(檔案本身)、url(blob 的位址)。
範例
let saved = '' ;
function setup() {
createCanvas(400, 300);
}
function keyPressed() {
if (key === 'r' ) startRecording(3);
}
function recordingSaved(info) {
saved = info.name + ' ' + info.format + ' ' + info.seconds + ' s ' + round(info.bytes / 1024) + ' KB' ;
}
function draw() {
background(20);
circle(200 + sin(frameCount * 0.05) * 120, 150, 50);
if (!recordingIsOn) {
fill(255);
text(saved || 'press r to record 3 seconds' , 20, 30);
}
}
按 r:三秒後檔案下載完成,檔名、格式、長度和大小會顯示在畫布上。
let note = '' ;
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 120);
}
function keyPressed() {
if (key === 'r' ) startRecording(4);
}
function recordingSaved(info) {
note = info.heard ? 'with sound: ' + info.sources.join(', ' ) : 'silent: click the page before you record' ;
if (info.warnings.length) note += ' (' + info.warnings.join(', ' ) + ')' ;
}
function draw() {
background(20);
fill(255);
if (!recordingIsOn) text(note || 'click once, then press r' , 20, 30);
}
(先在頁面上點一下)再按 r:文字會說影片有沒有聲音、來自哪些來源,以及有沒有哪裡不對。
p5/oav-p5.js 第 1911 行
startRecording()#
函式 oav-p5 startRecording([o])同一條也包含: OAVp5.record.start
把畫布和草圖發出的聲音錄成一支影片檔,並回傳一個 promise,告訴你錄影有沒有開始。
從按鍵或點擊啟動:if (key === 'r') startRecording();。進影片的只有畫布本身(用畫布自己的像素大小,視網膜螢幕會是兩倍),加上你的草圖發出的所有聲音:startSound() 的樂器、鼓機、打擊墊、開著的麥克風、p5.sound 1.x,混成一條音軌,喇叭照常播放。🎹、鼓機格子和提示卡片是頁面的一部分,不在畫布上,所以不會出現在影片裡。錄到一半才開起來的聲音也會加入。
瀏覽器寫得出 MP4 的話,檔案就是 MP4(H.264 + AAC,Instagram、YouTube 和所有剪輯軟體都吃),不然就是 WebM,這時會有卡片說 Instagram 不收 WebM。檔名像 oav-20261015-183005.mp4,有給 name 的話會放在中間。錄完會自動下載,在能分享檔案的手機上,卡片會出現「分享」按鈕。呼叫 stopRecording() 之後,或 seconds 時間到了,會呼叫 recordingSaved(info) 並把細節交給它。沒給 seconds 的話,錄到最長長度就會自己停(預設 300 秒,載入選項 reclimit 可以改)。
沒給 bitrate 的話,畫質跟著畫布大小走:每個像素、每一格 0.16 位元,限制在每秒 4 到 16 百萬位元之間(每秒超過 30 格時是 24)。已經在錄的時候再呼叫 startRecording(),不會開新的一支。每支錄完之後,卡片會說錄了什麼,有地方不對的話還會說是什麼、為什麼:有聲音開著、卻沒有任何東西進到音軌、WebM、幾乎是空的檔案、畫布明明不是黑的、影片卻是黑的。
呼叫之前
瀏覽器要有 MediaRecorder,沒有的話會跳出卡片說明,promise 也會 resolve 成 false。畫面馬上就錄,但頁面要有一次點擊或按鍵之後才能發聲,所以在第一次點擊之前開始的錄影,一開始是沒有聲音可錄的。畫布上如果有別的網站來的圖片(沒有 CORS),就錄不了,卡片會告訴你。
參數 名稱 型別 說明 oNumber | Object給數字就是秒數:startRecording(10) 錄十秒後自己停。給物件的話,可以放 seconds(同樣是秒數,不會超過一支影片的長度上限,預設 300)、fps(每秒幾格,最多 60,預設 30)、mic(false 就不把麥克風錄進影片)、name(檔名裡的一個詞)、canvas(要錄的畫布:<canvas>、p5 畫布或 createGraphics() 的緩衝區,預設是頁面上最大的那張 p5 畫布)和 bitrate(每秒幾位元)。不傳就一直錄到呼叫 stopRecording() 。 可省略
回傳 Promise<Boolean> 錄影開始之後 resolve 成 true(已經在錄的話也是 true)。載入選項 record 是 off、找不到要錄的畫布、這個瀏覽器沒有 MediaRecorder、或是啟動失敗的時候,resolve 成 false,並且會有一張卡片說明原因。
注意 在 🎹 面板裡,⏺ 不用寫程式就能開始和停止錄影,📷 把畫布存成 PNG(見 OAVp5.record.shot() )。錄影的時候,🎹 按鈕上會有紅點和時間。載入選項 record (on、nomic 或 off)、recfps、reclimit、title 可以設定預設值。 麥克風開著的時候會錄進影片:請戴耳機,不然喇叭的聲音會被錄兩次。p5 2.x 用的 p5.sound 把每個聲音直接接到喇叭,沒辦法錄(卡片會說明),請改用 p5 1.11,或用 startSound() 和 startDrums() 。草圖自己用 Tone.js 或 Web Audio 程式發出的聲音,同樣不會被錄進去。 畫在畫布上的東西都會進影片,所以提示文字只在 recordingIsOn 是 false 的時候畫。 範例
function setup() {
createCanvas(400, 300);
}
function keyPressed() {
if (key === 'r' ) startRecording(5); // five seconds, then it stops and downloads
}
function draw() {
background(20);
circle(200 + sin(frameCount * 0.05) * 120, 150, 50);
if (!recordingIsOn) {
fill(255);
text('press r to record 5 seconds' , 20, 30); // not drawn while recording
}
}
按 r:五秒後會下載一支圓在移動的 MP4,影片裡沒有那行提示文字。
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 110);
startSound('synth/pluck' );
}
function keyPressed() {
if (key === 'r' ) {
if (recordingIsOn) stopRecording();
else startRecording({ name: 'my piece' , fps: 60 });
}
if (key === 'a' ) midi.play(60, 0.8);
}
function kickDetected(level) {
background(255 * level);
}
function draw() {
background(20);
if (!recordingIsOn) {
fill(255);
text('click once, then: a plays a note, r starts and stops a recording' , 20, 30);
}
}
先點一下讓瀏覽器允許出聲,再按 r 開始、再按 r 停止:檔案裡有畫面、鼓聲和你彈的音。
let layer;
function setup() {
createCanvas(400, 300);
layer = createGraphics(200, 200);
}
function keyPressed() {
if (key === 'g' ) startRecording({ canvas: layer, seconds: 3 }); // record the buffer, not the main canvas
}
function draw() {
background(20);
layer.background(40);
layer.circle(100 + sin(frameCount * 0.1) * 60, 100, 40);
image(layer, 100, 50);
fill(255);
text('press g to record the small square only (3 s)' , 20, 30);
}
按 g:影片是 200 × 200,只有那個緩衝區,不是 400 × 300 的整張畫布。
p5/oav-p5.js 第 2306 行
stopRecording()#
函式 oav-p5 stopRecording()同一條也包含: OAVp5.record.stop
停止錄影,影片完成後會下載,並交給 recordingSaved(info) 和回傳的 promise。
用來結束沒有給 seconds 的錄影,或是提早結束。recordingIsOn 馬上變成 false,檔案這時還在製作(OAVp5.record.state 在那之前是 'saving'),所以要用 promise 或 recordingSaved() 才知道檔案好了。兩者都在不到一秒之後,很長的一支會再久一點。
回傳 Promise<Object | null> 檔案存好、檢查完之後,resolve 成跟 recordingSaved(info) 拿到的同一份 info。沒有在錄、或錄影還在啟動中(那時還沒錄到東西)的時候,馬上 resolve 成 null。
範例
let message = 'press r to record, r again to stop' ;
function setup() {
createCanvas(400, 300);
}
function keyPressed() {
if (key !== 'r' ) return ;
if (recordingIsOn) {
stopRecording().then((info) => {
if (info) message = 'saved ' + info.name; // the file is ready
});
} else {
startRecording();
}
}
function draw() {
background(20);
circle(200 + sin(frameCount * 0.05) * 120, 150, 50);
if (!recordingIsOn) {
fill(255);
text(message, 20, 30);
}
}
按 r 開始、再按 r 停止:檔案好了之後,文字會顯示它的檔名。
p5/oav-p5.js 第 2307 行
OAVp5.record
錄影相關名稱背後的物件:狀態、時間、最後一支檔案、這個瀏覽器能寫的格式,以及存圖的函式。
OAVp5.record(也可以用 midi.record 取得)是 startRecording() 、stopRecording() 和 recordingIsOn 的底層。裡面有錄影的 state 、已經錄了多少 seconds 、last 最後一支檔案的 info、這一支裡有哪些聲音來源 sources ,以及生效的 fps 和 limit 。formats() 說明瀏覽器能寫什麼格式,shot() 把畫布存成 PNG,on() 監聽 'start' 和 'saved'。start 和 stop 跟那兩個全域函式一模一樣。
注意 一般草圖用全域名稱就夠了。這個物件是給工具、主控台和除錯用的。 範例
function setup() {
createCanvas(400, 300);
textSize(14);
}
function draw() {
background(20);
fill(255);
const r = OAVp5.record;
text('state: ' + r.state, 20, 30);
text('seconds: ' + nf(r.seconds, 1, 1), 20, 60);
text('limit: ' + r.limit + ' s at ' + r.fps + ' fps' , 20, 90);
}
錄影物件的三個欄位,開始錄影之前狀態一直是 "off"(可以按 🎹 面板裡的 ⏺ 試試)。
p5/oav-p5.js 第 1906 行
OAVp5.record.formats()
告訴你這個瀏覽器能錄什麼:{ recorder, mp4, webm, mime }。
在你要提供錄影按鈕之前用它,或是告訴使用的人會拿到哪種檔案:MP4 可以直接上傳 Instagram,也能放進所有剪輯軟體,WebM 不行。每次呼叫都會重新問一次瀏覽器。
回傳 Object recorder 表示瀏覽器到底有沒有 MediaRecorder,mp4 和 webm 表示能不能寫那種格式,mime 是錄影最先要求的 MIME 類型(可以的話是 MP4),都不行的話是 ''。
範例
function setup() {
createCanvas(400, 300);
textSize(14);
}
function draw() {
background(20);
fill(255);
const f = OAVp5.record.formats();
text('recorder: ' + f.recorder, 20, 30);
text('mp4: ' + f.mp4 + ' webm: ' + f.webm, 20, 60);
text('asks for: ' + (f.mime || 'nothing' ), 20, 90);
}
顯示這個瀏覽器能寫的格式:Chrome 126 之後的版本和 Safari 會顯示 mp4 為 true。
p5/oav-p5.js 第 1895 行
OAVp5.record.fps#
變數 oav-p5 OAVp5.record.fps同一條也包含: OAVp5.record.limit
載入選項 recfps 和 reclimit 設定的錄影格率(fps)和一支影片最長的秒數(limit)。
它們是拿來讀的:OAVp5.record.limit 讓草圖知道一支影片最長能錄多久。對它們賦值沒有作用,因為錄影讀的是載入選項,不是這兩個值。想用別的格率錄一次,就傳 startRecording({ fps: 60 }),要改長度上限,在 oav-p5 載入之前設定 reclimit 選項(window.OAV_P5 = { reclimit: 600 })。
型別 Number
範圍 fps 1..60, limit 1 …fps 預設是 30,limit 預設是 300,除非載入選項另外設定(fps 會被限制在 1 到 60 之間)。
範例
function setup() {
createCanvas(400, 300);
textSize(14);
}
function draw() {
background(20);
fill(255);
text('a take is ' + OAVp5.record.fps + ' frames a second' , 20, 30);
text('and at most ' + OAVp5.record.limit + ' seconds (' + OAVp5.record.limit / 60 + ' minutes)' , 20, 60);
}
印出兩個預設值:每秒 30 格、最長 300 秒。
p5/oav-p5.js 第 1887 行
OAVp5.record.last#
變數 oav-p5 OAVp5.record.last
最近一支存好的錄影的 info(跟 recordingSaved 拿到的是同一個物件),第一支之前是 null。
當你沒有在 recordingSaved() 裡把 info 存下來的時候很方便:顯示最後一支的檔名,或把 OAVp5.record.last.url 放進連結。這個位址會一直有效,直到下一支存好之後一分鐘。
型別 Object | null每存好一支就換成新的。欄位見 recordingSaved() 。
範例
function setup() {
createCanvas(400, 300);
textSize(14);
}
function keyPressed() {
if (key === 'r' ) startRecording(2);
}
function draw() {
background(20);
fill(255);
const last = OAVp5.record.last;
text(last ? 'last file: ' + last.name + ' (' + last.seconds + ' s)' : 'nothing recorded yet, press r' , 20, 30);
}
按 r:兩秒後會顯示最近一支的檔名,再錄一次它就會換掉。
p5/oav-p5.js 第 1887 行
OAVp5.record.on()#
函式 oav-p5 OAVp5.record.on(name, fn)
在任何地方監聽錄影:OAVp5.record.on('saved', fn) 或 on('start', fn),並回傳一個可以取消監聽的函式。
recordingSaved() 是你定義的單一函式。on() 是給不適用的情況:好幾個監聽者、函式庫、或是想把監聽者再拿掉。概念跟 OAVp5.drums.on() 和 midi.on() 一樣。fn 裡丟出的錯誤會被印出來,其他監聽者照樣執行。
參數 名稱 型別 說明 nameString'start'(一支錄影開始了)或 'saved'(檔案存好了,跟 recordingSaved() 一樣)。其他的名字會丟出錯誤,並列出這兩個。 fnFunction'start' 拿到的是 { width, height, fps, type, seconds }(沒指定長度的話 seconds 是 0)。'saved' 拿到的是跟 recordingSaved() 一樣的 info。
回傳 Function 一個函式,呼叫它就停止監聽。
範例
let status = 'press r to record 2 seconds' ;
function setup() {
createCanvas(400, 300);
OAVp5.record.on('start' , (i) => { status = 'recording ' + i.width + 'x' + i.height + ' at ' + i.fps + ' fps' ; });
OAVp5.record.on('saved' , (i) => { status = 'saved ' + i.name; });
}
function keyPressed() {
if (key === 'r' ) startRecording(2);
}
function draw() {
background(20);
circle(200 + sin(frameCount * 0.05) * 120, 150, 50);
if (!recordingIsOn) {
fill(255);
text(status, 20, 30);
}
}
按 r:文字先回報開始錄影(大小和格率),再回報存好的檔名,這是由兩個監聽者做的。
p5/oav-p5.js 第 1897 行
OAVp5.record.seconds#
變數 oav-p5 OAVp5.record.seconds
正在錄的這一支已經錄了幾秒,錄完之後是那一支的長度。
當成跟著這一支錄影一起開始的時鐘:const t = recordingIsOn ? OAVp5.record.seconds : 0; 做出來的動畫,在影片裡一定從 0 開始。它是錄影開始之後的時間,不是草圖的時間,所以不包含 startRecording() 之前的那段。
型別 Number
範圍 0 …一開始是 0,錄影中每秒更新四次。檔案存好之後是那一支的長度,下一支開始的時候又歸零。
範例
function setup() {
createCanvas(400, 300);
}
function keyPressed() {
if (key === 'r' ) startRecording(4);
}
function draw() {
background(20);
const t = recordingIsOn ? OAVp5.record.seconds : 0; // 0 at the first frame of the video
fill(255, 160, 80);
circle(40 + t * 80, 150, 40); // slides right, 80 px a second
if (!recordingIsOn) {
fill(255);
text('press r: the video starts with the circle at the left' , 20, 30);
}
}
按 r:圓跳到最左邊,往右滑四秒,所以影片每次的開頭都一樣。
p5/oav-p5.js 第 1887 行
OAVp5.record.shot()#
函式 oav-p5 OAVp5.record.shot([o])
把畫布本身存成 PNG 圖片(不包含 🎹 和提示卡片)並下載。
就是 🎹 面板裡 📷 按鈕的函式版。檔名像 oav-20261015-183005.png(有給 name 的話會放在中間),馬上下載,在能分享檔案的手機上,會改成在卡片上出現「分享」按鈕。圖片的大小就是畫布的像素大小,所以視網膜螢幕的寬和高會是兩倍。p5 自己的 saveCanvas('name', 'png') 做的是同一件事,只是沒有卡片。
參數 名稱 型別 說明 oObject選項:canvas(要存的畫布:<canvas>、p5 畫布或 createGraphics() 的緩衝區,預設是頁面上最大的 p5 畫布)和 name(檔名裡的一個詞)。 可省略
回傳 Promise<Object | null> resolve 成 { name, blob, url, width, height, bytes },找不到畫布、或畫布讀不出來(上面有別的網站來的圖片,沒有 CORS)的時候是 null。
範例
function setup() {
createCanvas(400, 300);
}
function keyPressed() {
if (key === 'p' ) OAVp5.record.shot({ name: 'still' });
}
function draw() {
background(20);
circle(mouseX, mouseY, 60);
fill(255);
text('press p to save a PNG of the canvas' , 20, 30);
}
用滑鼠移動圓,按 p:會下載一張畫布的 PNG。
p5/oav-p5.js 第 1893 行
OAVp5.record.sources#
變數 oav-p5 OAVp5.record.sources
正在錄的這一支現在錄進了哪些聲音:由 'sound'、'drums'、'mic'、'p5sound' 組成的清單,沒在錄的時候是空的。
它是這一支已經接上的來源,不是此刻有沒有聲音聽得到。錄到一半才打開的來源,會在四分之一秒內加進清單。錄完之後,最終的清單在 recordingSaved() 的 info.sources 裡。
型別 Array<String>每次讀都是一份新的清單。'sound' 是 startSound() 的樂器,'drums' 是鼓機和打擊墊,'mic' 是麥克風,'p5sound' 是 p5.sound 1.x。
範例
function setup() {
createCanvas(400, 300);
startDrums('breakbeat' , 120);
startSound('synth/pluck' );
textSize(14);
}
function keyPressed() {
if (key === 'r' ) recordingIsOn ? stopRecording() : startRecording();
}
function draw() {
background(20);
fill(255);
text(recordingIsOn ? 'in the take: ' + OAVp5.record.sources.join(', ' ) : 'press r to start a take' , 20, 30);
}
按 r:文字列出這一支接上了什麼,這裡是鼓機和樂器。
p5/oav-p5.js 第 1903 行
OAVp5.record.state#
變數 oav-p5 OAVp5.record.state
錄影現在在哪個階段:'off'、'starting'、'on' 或 'saving'。
recordingIsOn 就是 state === 'on'。其他階段有影響的時候才需要讀它:按鈕可以顯示「啟動中……」和「儲存中……」,在檔案還在存的時候不接受新的一支。
型別 String
範圍 off | starting | on | saving一開始是 'off'。一支錄影會依序是 'starting'(錄影程式載入中,第一次才會有)、'on'(錄影中)、'saving'(檔案製作中),然後回到 'off'。
範例
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(28);
}
function keyPressed() {
if (key === 'r' && OAVp5.record.state === 'off' ) startRecording(3);
}
function draw() {
background(20);
circle(200 + sin(frameCount * 0.05) * 120, 220, 40);
fill(255);
text(OAVp5.record.state === 'off' ? 'press r' : OAVp5.record.state, width / 2, 70);
}
按 r:文字依序經過 starting、on、saving,然後回到提示,中間再按 r 沒有作用。
p5/oav-p5.js 第 1886 行
@openav/record
AUDIO_MIMES#
常數 @openav/record AUDIO_MIMES = array, 5 items
只錄聲音的檔案時,錄影器會依序嘗試的音訊 MIME 型別,最好的在前:先是 .m4a 的 AAC,再來是 Opus。
AAC 的 .m4a 檔排第一,因為它每個 DAW 和剪輯軟體都能匯入。audioOnly: true 的 Recorder 用的就是這份清單,透過 pickMime() 。
型別 String[]一份凍結的清單,有 5 個字串:audio/mp4;codecs=mp4a.40.2、audio/webm;codecs=opus、audio/ogg;codecs=opus、audio/mp4、audio/webm。
範例
js 複製
import { AUDIO_MIMES, pickMime, extFor } from '@openav/record' ;
const mime = pickMime((m) => MediaRecorder.isTypeSupported(m), { audioOnly: true });
console.log(AUDIO_MIMES[0]); // audio/mp4;codecs=mp4a.40.2
console.log(mime, extFor(mime)); // the one this browser would use, and its file extension
module 印出聲音檔最好的候選,以及這個瀏覽器實際會挑的那一個和它的副檔名。
相關 VIDEO_MIMES 錄影器會依序嘗試的影片 MIME 型別,最好的在前:先是 H.264 加 AAC 的 MP4,再來是 WebM。 pickMime() 用瀏覽器自己的檢查,從清單裡挑出第一個瀏覽器錄得了的 MIME 型別。 extFor() 給一個錄好的 MIME 型別該用的副檔名:video/mp4 是 'mp4',audio/mp4 是 'm4a',不認識的是 'bin'。 Recorder 用一個 MediaRecorder 把一張畫布連同你給它的聲音錄成一個影片檔,所以畫面和聲音保持同步。 import { AUDIO_MIMES } from '@openav/record'; packages/record/mime.js 第 22 行
AudioTap#
類別 @openav/record new AudioTap({ context = null , sources = [], captureDestination = false } = {})
把你的作品的聲音(以及你加進來的麥克風)收集成一條可以錄的音軌,不會把任何東西送到喇叭。
用麥克風錄喇叭,錄到的是房間。接上 Web Audio 的節點圖,錄到的是樂器合成出來的原樣,而且跟畫布用同一個時鐘。AudioTap 有兩條匯流排和兩個輸出。work 匯流排是作品的聲音,extra 匯流排放的是該在影片裡、卻不該在聲音檔裡的東西,例如麥克風。tap.stream 和 tap.tracks 是 work 加 extra(交給影片的 Recorder ),tap.workStream 和 tap.workTracks 只有 work(給另外存的聲音檔)。兩個都只有一條音軌,這是故意的:Chrome 只會錄一個串流的第一條音軌。
這個 tap 絕不會把東西送到喇叭,所以加麥克風不會產生回授。要錄不是你寫的草圖(p5.sound、Tone.js、Sound ),就在作品開始出聲之前 呼叫 captureDestination():從那時起,每個接到喇叭的節點也會被接進來。
呼叫之前
要有正在跑的 AudioContext。瀏覽器要點擊過才讓聲音開始,所以在點擊的處理函式裡呼叫 tap.resume()。沒有點擊的話,音軌雖然存在,裡面是靜音。
參數 名稱 型別 說明 contextAudioContext要在哪個 AudioContext 裡混音,通常是作品自己的。沒給的話會新建一個,需要瀏覽器有 Web Audio。 可省略 預設 null sourcesArray要馬上加進作品匯流排的東西:AudioNode、MediaStream 或 MediaStreamTrack(見 add())。 可省略 預設 [] captureDestinationBoolean同時馬上呼叫 captureDestination()。 可省略 預設 false
成員 名稱 說明 streamgetter Getter。作品加額外聲音混好的 MediaStream,只有一條音軌。交給影片的錄影器。 workStreamgetter Getter。只有作品的 MediaStream,一條音軌:給另外存的聲音檔。 tracksgetter Getter。stream 的音軌(一條):new Recorder({ audioTracks: tap.tracks })。 workTracksgetter Getter。workStream 的音軌(一條)。 add(source, { bus = 'work', gain = 1 } = {})method 接上一個來源:add(source, { bus = 'work', gain = 1 })。這個 tap 的 context 的節點直接接,別的 AudioContext 的節點經由 MediaStream 橋接,MediaStream 或 MediaStreamTrack 當作來源節點接進來。bus: 'extra' 只會進到影片。回傳一個把它移除的函式。給其他東西會丟出 TypeError。 captureDestination()method 從現在起,每個接到 AudioDestinationNode(喇叭)的節點,不論在哪個 context,都會被接進來,做法是掛在 AudioNode.prototype.connect 上的一個鉤子。要在作品開始出聲之前 裝好:先接上的節點看不到。重複呼叫不會加倍。回傳一個把它拆掉的函式。 addMicrophone({ deviceId = '', gain = 1, echoCancellation = false, noiseSuppression = false, autoGainControl = false } = {})method 把麥克風(或任何音訊輸入)加進影片的混音,不加進聲音檔。{ deviceId, gain = 1, echoCancellation = false, noiseSuppression = false, autoGainControl = false }:那三項預設關掉,因為它們會吃掉鋼琴的起音和房間的底噪。回傳 MediaStream 的 promise。先前加過的麥克風會被換掉。需要使用者許可。 removeMicrophone({ stop = true } = {})method 把麥克風從混音裡拿掉並停止它,除非你傳 { stop: false }。 level()method 混音現在的峰值音量,0..1:給「聲音有進到錄影裡」的指示用。 resume()method 叫醒被暫停的 context。要在點擊裡呼叫。回傳一個 promise。 dispose()method 放開 tap 建立的所有東西,包括鉤子和麥克風。context 本身不動。
範例
js 複製
import { AudioTap } from '@openav/record' ;
const tap = new AudioTap(); // makes its own AudioContext
const osc = new OscillatorNode(tap.context, { frequency: 440 });
const remove = tap.add(osc); // the work bus
osc.start();
await tap.resume(); // from a click in a real page
await new Promise((done) => setTimeout(done, 300));
console.log(tap.tracks.length, tap.workTracks.length); // 1 1
console.log(tap.level() > 0.1); // true: sound is reaching the recording
remove();
module 接上一個 440 Hz 的音,指示確認聲音有進到錄影裡。音軌已經可以交給 Recorder。
js 複製
import { AudioTap } from '@openav/record' ;
const tap = new AudioTap();
const uninstall = tap.captureDestination(); // before the work starts its sound
const ctx = new AudioContext(); // "someone else's" sketch, in its own context
const osc = new OscillatorNode(ctx, { frequency: 330 });
osc.connect(ctx.destination); // goes to the speakers, and into the tap
osc.start();
await new Promise((done) => setTimeout(done, 300));
console.log(tap.level() > 0.05); // true
uninstall();
tap.dispose();
module 另一個 AudioContext 裡的聲音,照平常接到喇叭,tap 也收得到:錄不是你寫的草圖就是這樣做。
相關 Recorder 用一個 MediaRecorder 把一張畫布連同你給它的聲音錄成一個影片檔,所以畫面和聲音保持同步。 mixTracks() 把好幾條音軌混成一條,因為 Chrome 只會錄一個串流的第一條音軌。 Compositor 每個畫面把你的作品和表演者的鏡頭畫進一張尺寸精確的畫布:這張畫布就是 Recorder 要錄的那張。 Sound 讓匯流排上每個 midi/note/on 和 midi/note/off 都透過發聲引擎聽得見,並把引擎的參數放到匯流排上,名稱是 sound/*。 startRecording() 把畫布和草圖發出的聲音錄成一支影片檔,並回傳一個 promise,告訴你錄影有沒有開始。 import { AudioTap } from '@openav/record'; packages/record/audio.js 第 44 行
cameraConstraints()#
函式 @openav/record cameraConstraints({ deviceId = '' , width = 1920, height = 1080, fps = 30, facingMode = '' } = {})
做出開鏡頭用的 getUserMedia 選項:它要求 1920 乘 1080、30 fps,但只是理想值,不是硬性要求。
尺寸和畫格率是 ideal,不是 exact:做不到 1920 乘 1080 的鏡頭應該用它做得到的規格開起來,而不是在觀眾面前丟出錯誤。它是純函式,所以你可以看 openCamera() 會要求什麼。串流裡沒有聲音:麥克風要透過 AudioTap 加。
參數 名稱 型別 說明 deviceIdString指定哪一個鏡頭,來自 listCameras() 。用 exact 要求:如果它不見了,就會開啟失敗。 可省略 預設 '' widthNumber想要的寬,用 ideal 要求。 可省略 預設 1920 heightNumber想要的高,用 ideal 要求。 可省略 預設 1080 fpsNumber想要的畫格率,用 ideal 要求。 可省略 預設 30 facingModeString手機上的 'user'(前鏡頭)或 'environment'(後鏡頭)。有給 deviceId 時會被忽略。 可省略 預設 ''
回傳 Object 一個 MediaStreamConstraints:{ video: { width: { ideal }, height: { ideal }, frameRate: { ideal }, deviceId?, facingMode? }, audio: false }。
範例
js 複製
import { cameraConstraints } from '@openav/record' ;
console.log(JSON.stringify(cameraConstraints()));
// {"video":{"width":{"ideal":1920},"height":{"ideal":1080},"frameRate":{"ideal":30}},"audio":false}
console.log(JSON.stringify(cameraConstraints({ deviceId: 'abc' , width: 1280, height: 720, fps: 60 }).video));
// {"width":{"ideal":1280},"height":{"ideal":720},"frameRate":{"ideal":60},"deviceId":{"exact":"abc"}}
module 預設的要求,以及指定某一個鏡頭、要 720p60 的要求。
相關 openCamera() 開啟鏡頭並回傳它的 MediaStream:理想是 1920 乘 1080、30 fps,不行就用鏡頭做得到的。 listCameras() 列出這台電腦的影像輸入,格式是 { deviceId, groupId, label }。 Compositor 每個畫面把你的作品和表演者的鏡頭畫進一張尺寸精確的畫布:這張畫布就是 Recorder 要錄的那張。 import { cameraConstraints } from '@openav/record'; packages/record/camera.js 第 13 行
cleanValue()#
函式 @openav/record cleanValue(v, depth = 0)
做出一份訊號值的複本,可以安全地寫成 JSON:數字捨入、陣列變普通陣列、沒有函式、也不會太深。
規則:數字捨入到小數第 6 位,NaN 和無限大變成 null。型別陣列變成普通陣列。bigint 變成數字。物件和陣列往下複製到第 4 層,再深的變成 null。函式、symbol 和 undefined 在陣列裡變成 null,在物件裡這個鍵會被丟掉。EventLog 把每個值都經過它。
參數 名稱 型別 說明 vany要整理的值。 depthNumber目前已經到第幾層。不用給,是函式呼叫自己的時候用的。 可省略 預設 0
範例
js 複製
import { cleanValue } from '@openav/record' ;
const out = cleanValue({
a: 1.23456789, list: [1, NaN, () => 1], pos: new Float32Array([0.5, 0.25]), fn() {}, big: 10n,
});
console.log(JSON.stringify(out));
// {"a":1.234568,"list":[1,null,null],"pos":[0.5,0.25],"big":10}
module 一個亂七八糟的物件進去,出來的是單純的資料:數字被捨入、函式的鍵不見了、型別陣列變成陣列。
相關 EventLog 把一次錄影過程中發生的事,例如音符和參數變化,連同時間記成一份清單,用的是跟錄影同一個時鐘。 normalizeTake() 把任何長得像 take 的東西,或它的 JSON 文字,整理成合格的 take:事件依時間排序、壞事件丟掉、沒有負數。 import { cleanValue } from '@openav/record'; packages/record/events.js 第 19 行
Compositor#
類別 @openav/record new Compositor({ size = 'vertical-1080p' , layout = 'stack' , layoutOptions = {}, getWorkCanvas = null , background = '#000' , mirrorCam = true , maxFps = 60, onDraw = null , canvas = null } = {})
每個畫面把你的作品和表演者的鏡頭畫進一張尺寸精確的畫布:這張畫布就是 Recorder 要錄的那張。
表演影片是同一個畫面裡的兩件事:你的作品和表演者。Compositor 就是做這個畫面的。它擁有一張畫布(comp.canvas),大小就是你要的檔案大小,跟你的視窗多大無關,所以在筆電上錄的直式 4K 影片真的是 4K。把這張畫布交給 Recorder 。要在頁面上預覽,就把 comp.canvas 放進 DOM,再用 CSS 縮小。
comp.start() 每個動畫畫面重畫一次(而且因為被藏起來的分頁會凍結動畫畫面,所以也用 Web Worker 的計時器畫,這樣錄影不會跟著分頁一起凍住)。comp.draw() 馬上畫一個畫面:如果你有自己的迴圈,就在作品畫完之後自己呼叫它。comp.rects 是最近一次的版面,{ work, cam },單位是輸出的像素,comp.fps 是它實際畫的頻率。
鏡頭是選用的,而且合成器不擁有它:setCamera(stream) 顯示它,setCamera(null) 放手,音軌由你自己停。鏡頭的區域也可以改放一個面板 ,也就是任何有 draw(ctx, rect, now) 的東西,例如一張正在彈的控制器的圖。鏡頭的區域會在鏡頭來之前就先留好,所以畫面不會跳動。
參數 名稱 型別 說明 sizeString | Object | Number[]輸出的尺寸,也就是檔案的尺寸。可以是 PRESETS 的 id('vertical-4k')、'1080x1920'、[w, h] 或 { w, h }。每一邊都會進位成偶數。見 resolveSize() 。 可省略 預設 'vertical-1080p' layoutString作品和鏡頭放在哪裡:stack、pip、side、work(見 LAYOUTS )。 可省略 預設 'stack' layoutOptionsObject原封不動交給 layoutRects() :fit、camFit、camAnchor、minCam、split、pip。 可省略 預設 {} getWorkCanvasFunction回傳你的作品的畫布,或 null。每個畫面都會問一次,因為 p5 草圖可能換掉畫布,WebGL 作品可能改變大小。合成器從來不擁有作品。 可省略 預設 null backgroundString所有東西後面的顏色,任何畫布能用的顏色字串。 可省略 預設 '#000' mirrorCamBoolean把鏡頭顯示成鏡子,這是表演者習慣的樣子。要跟畫在上面的手部追蹤的 mirror 保持一樣。 可省略 預設 true maxFpsNumber跑起來的時候最多這麼頻繁地畫。120 Hz 的螢幕不需要每秒合成 120 次。 可省略 預設 60 onDrawFunctiononDraw(ctx, rects, comp),作品和鏡頭畫好之後呼叫,用來在最上面再蓋一層。呼叫前後會保存和還原繪圖狀態。裡面出錯只會警告一次,不會中斷錄影。 可省略 預設 null canvasHTMLCanvasElement畫進這張畫布,不另外做新的。它的寬高會被設成輸出的尺寸。 可省略 預設 null
成員 名稱 說明 sizegetter Getter。輸出尺寸 { w, h },也就是 comp.canvas 的大小。 setSize(size)method 改變輸出尺寸(值跟 size 選項一樣)。要在兩次錄影之間做:已經在跑的錄影器會保持它開始時的尺寸。回傳合成器本身。 setLayout(layout, options)method 改變版面,如果有給 options,就合併進 layoutOptions(pip 物件也會合併)。名字不在 LAYOUTS 裡會丟出錯誤。回傳合成器本身。 videogetter Getter。鏡頭播放用的 <video> 元素,沒有鏡頭就是 null。可以拿來做另外一個監看畫面。 cameraSizegetter Getter。鏡頭開始播之後的尺寸 { w, h },否則是 null。 setCamera(src)method 顯示一個鏡頭:可以是 MediaStream(會用這裡建的一個隱藏、靜音的 <video> 播放)、<video> 或畫布,照原樣畫。null 是移除,它的音軌會繼續跑。回傳一個 promise,畫面來了就 resolve 成鏡頭尺寸 { w, h },沒有鏡頭或 5 秒內沒畫面就是 null。 panelgetter Getter。畫在鏡頭區域的面板,或 null(顯示的是鏡頭)。 setPanel(panel)method 把面板畫在鏡頭的位置:{ draw(ctx, rect, now), aspect }。draw 每個畫面都會被呼叫,繪圖範圍被裁成那個區域,rect 是輸出像素的 { x, y, w, h },aspect(寬除以高,預設 16/9)決定子母畫面角落的形狀。setPanel(null) 把鏡頭換回來,它一直都連著。合成器不會替你 dispose 面板。面板沒有 draw 會丟出錯誤。 draw()method 馬上畫一個畫面,並回傳版面 { work, cam }。如果你有自己的迴圈,就在作品渲染完之後呼叫。 drawOnCamera(fn)method 用鏡頭自己的座標畫在鏡頭上面:fn(ctx, w, h) 拿到的繪圖環境,原點移到整個鏡頭畫面落下的位置,並且裁在看得到的鏡頭區域裡,手部或姿態追蹤的 skeleton(ctx, w, h) 要的就是這個。在 onDraw 裡呼叫。沒有鏡頭,或位置上放的是面板,就什麼都不做。 start()method 每個動畫畫面重畫一次(每秒最多 maxFps 次),分頁被藏起來的時候改由 Web Worker 的計時器畫。回傳合成器本身。 stop()method 停止重畫。回傳合成器本身。 dispose()method 停止並放開鏡頭(音軌要由你自己停)。
注意 WebGL 的作品:合成器會在作品畫完的同一個動畫畫面裡複製它的畫布,在 Chrome 上這樣就行。如果看到黑的,就用 preserveDrawingBuffer: true 建立 WebGL context,或在你渲染完之後馬上呼叫 comp.draw(),並且不要呼叫 start()。 網路攝影機通常只到 1080p30,所以 4K 的 stack 版面會把鏡頭放大約 1.6 倍。上面的作品是照輸出尺寸畫的,只要作品自己的畫布至少跟它的區域一樣大,就會保持清楚。 在 p5 草圖裡用不到這個:startRecording() 會替你錄畫布和它的聲音。Compositor 是給要把作品和鏡頭一起錄的頁面用的。 範例
js 複製
import { Compositor } from '@openav/record' ;
const work = document.createElement('canvas' ); // the work: any canvas
work.width = 600; work.height = 600;
const g = work.getContext('2d' );
g.fillStyle = '#c33' ; g.fillRect(0, 0, 600, 600);
const comp = new Compositor({ size: '540x960' , layout: 'stack' , getWorkCanvas: () => work });
const rects = comp.draw(); // one frame, now
console.log(comp.size, rects.work.h, rects.cam.box.h); // { w: 540, h: 960 } 540 420
comp.canvas.style.width = '135px' ; // a preview, scaled down with CSS
document.getElementById('app' ).append(comp.canvas);
module 把一個正方形的作品畫進 540 乘 960 的畫面:作品佔上面 540 像素,下面 420 像素留給鏡頭。結果以小預覽的樣子出現在頁面上。
js 複製
import { Compositor, openCamera } from '@openav/record' ;
const work = document.getElementById('c' ); // the 400 x 300 canvas of the page
const wg = work.getContext('2d' );
const comp = new Compositor({ size: 'landscape-1080p' , layout: 'pip' , getWorkCanvas: () => work,
layoutOptions: { pip: { corner: 'br' , size: 0.25 } } });
comp.start(); // redraws every frame
const size = await comp.setCamera(await openCamera()); // the browser asks for the camera
console.log(size, comp.rects.cam.w); // { w: 1920, h: 1080 } 480 (what the camera gives may differ)
let x = 0;
(function animate() { // the work moves, the composite follows
wg.fillStyle = '#111' ; wg.fillRect(0, 0, 400, 300);
wg.fillStyle = '#ffd166' ; wg.fillRect(x = (x + 4) % 380, 120, 20, 60);
requestAnimationFrame(animate);
})();
comp.canvas.style.width = '480px' ;
document.getElementById('app' ).append(comp.canvas);
module 會動的畫布鋪滿 1080p 的畫面,鏡頭在一個角落。瀏覽器問的時候允許鏡頭。合成的結果會出現在作品下面。
js 複製
import { Compositor } from '@openav/record' ;
const work = document.getElementById('c' );
const comp = new Compositor({
size: [720, 720], layout: 'work' , getWorkCanvas: () => work,
onDraw(ctx, rects) { // an overlay, on top of everything
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)' ;
ctx.font = '32px sans-serif' ;
ctx.fillText('take 1' , rects.work.x + 20, rects.work.y + 48);
},
});
work.getContext('2d' ).fillRect(0, 0, 400, 300);
comp.draw();
document.getElementById('app' ).append(comp.canvas);
comp.canvas.style.width = '240px' ;
module 作品單獨放在正方形畫面裡,上面由 onDraw 寫了一行字。這行字是畫面的一部分,所以會被錄進影片。
相關 Recorder 用一個 MediaRecorder 把一張畫布連同你給它的聲音錄成一個影片檔,所以畫面和聲音保持同步。 layoutRects() 算出作品和鏡頭在輸出畫面的哪裡,單位是整數像素,支援四種版面。 PRESETS 合成器認得名字的輸出尺寸,從 vertical-1080p(1080 乘 1920)到 landscape-4k,每個都附建議的版面和畫格率。 openCamera() 開啟鏡頭並回傳它的 MediaStream:理想是 1920 乘 1080、30 fps,不行就用鏡頭做得到的。 AudioTap 把你的作品的聲音(以及你加進來的麥克風)收集成一條可以錄的音軌,不會把任何東西送到喇叭。 startRecording() 把畫布和草圖發出的聲音錄成一支影片檔,並回傳一個 promise,告訴你錄影有沒有開始。 import { Compositor } from '@openav/record'; packages/record/compositor.js 第 33 行
EventLog#
類別 @openav/record new EventLog({ now = defaultNow, filter = null , throttleMs = 0 } = {})
把一次錄影過程中發生的事,例如音符和參數變化,連同時間記成一份清單,用的是跟錄影同一個時鐘。
影片留下的是畫面和聲音。事件記錄留下的是這次錄影的樂譜 :每個音符、每一下墊子、以及(有節流的)手的位置,從開始錄的那一刻算起的毫秒數。有了它,一段錄影之後可以用別的尺寸重新渲染、跟更好的聲音檔重新對齊,或轉成 MIDI。用錄影器的開始時間來開始它,事件時間才會跟影片的第一個畫面對齊:log.start(recorder.t0)。
log.attach(signals) 會記下 Signals 匯流排發布的每個訊號。脈衝一定會留,連續的訊號則受 throttleMs 限制。也可以自己用 log.add(name, value) 加事件。值會被 cleanValue() 整理成單純的 JSON 資料。toJSON() 給 { format: 'openav-eventlog', version: 1, startedAt, durationMs, count, events },EventLog.fromJSON(json) 能讀回來。toTake() 把記錄轉成 MIDI 的 take,TakeRecorder 就是在 midi/* 上剛好做這件事的記錄。
屬性:events 是 [{ t, name, value }] 的清單(t 是毫秒,精確到十分之一),t0 是 t = 0 的時鐘時間,startedAt 是真實時間(ISO 文字),durationMs 在 stop() 之前是 null。
參數 名稱 型別 說明 nowFunction時鐘,單位是毫秒。預設跟 Recorder.t0 是同一個時鐘。測試時傳自己的。 可省略 預設 performance.now filterFunctionfilter(name, value, meta),回傳要不要留下這個事件。add() 也適用。 可省略 預設 null throttleMsNumber對連續的訊號:每個名字在這麼多毫秒內最多留一個事件。脈衝(音符開、音符關…)永遠不會被節流。 可省略 預設 0
成員 名稱 說明 runninggetter Getter。從 start() 到 stop() 之間是 true。 start(at = this.now())method 開始一次錄影並清空記錄。at 是 t = 0 的時鐘時間(預設現在):傳 recorder.t0 就能跟影片對齊。回傳記錄本身。 stop(at = this.now())method 結束這次錄影並設定 durationMs。之後的事件會被忽略。回傳記錄本身。 add(name, value = null, { at = this.now(), pulse = true, meta } = {})method 記下一個事件:add(name, value, { at, pulse, meta }),並以 { t, name, value } 回傳,如果記錄沒在跑、被 filter 拒絕、發生在 t = 0 之前,或被節流,就回傳 null。pulse: false 會讓它受 throttleMs 限制。at 是時鐘時間(預設現在)。 mark(label, data = null)method 記下一個帶標籤的標記,像「chorus」或「第二次從這裡開始」,成為名字是 mark、值是 { label, ...data } 的事件。 attach(signals)method 記下 Signals 匯流排發布的每個訊號(透過 signals.onAny)。回傳一個再解除的函式。 detach()method 從所有接過的匯流排上解除。 between(start, end)method start <= t < end 的事件(毫秒)。toJSON()method 記錄的純資料:{ format: 'openav-eventlog', version: 1, startedAt, durationMs, count, events }。 toTake({ name = 'take', meta = {} } = {})method 把記錄轉成 MIDI 的 take { v: 1, name, createdAt, durationMs, events, meta }(見 normalizeTake() )。{ name = 'take', meta = {} }。所有事件都會保留。請先過濾成 midi/*,或改用 TakeRecorder 。 fromJSON(json)static method Static。從 toJSON() 的輸出或它的文字重建一份已經停止的記錄。不是 openav-eventlog 的話會丟出 TypeError。
範例
js 複製
import { EventLog } from '@openav/record' ;
let clock = 1000; // a clock we move by hand, in ms
const log = new EventLog({ now: () => clock });
log.start();
clock = 1100; log.add('midi/note/on' , { note: 60, vel: 0.8 });
clock = 1200; log.mark('chorus' );
clock = 1500; log.stop();
console.log(log.events.map((e) => e.t + ' ' + e.name).join(' | ' )); // 100 midi/note/on | 200 mark
console.log(log.durationMs, log.running, log.toJSON().count); // 500 false 2
console.log(log.add('late' )); // null: the take is over
module 用手動撥動的時鐘錄半秒:100 毫秒一個音符,200 毫秒一個標記。stop() 之後就不再收任何事件。
js 複製
import { EventLog } from '@openav/record' ;
import { Signals } from '@openav/core' ;
const signals = new Signals();
const log = new EventLog({ throttleMs: 100 }); // knobs: at most one event per 100 ms
log.start();
const detach = log.attach(signals);
signals.pulse('midi/note/on' , { note: 64, vel: 0.7 }); // a pulse: always kept
for (let i = 0; i < 10; i++) signals.set ('param/hue' , i / 10); // continuous: throttled
detach();
console.log(log.events.length, log.events[0].name); // 2 midi/note/on
console.log(EventLog.fromJSON(log.toJSON()).events.length); // 2
module 接上一個匯流排:音符脈衝被留下,連續訊號在幾毫秒內的十次變化只剩一個事件。記錄經過 JSON 來回之後依然完整。
相關 TakeRecorder 錄下你彈的東西:從 start() 到 stop() 聽匯流排上每個 midi/* 訊號,然後給你一個 take。 Recorder 用一個 MediaRecorder 把一張畫布連同你給它的聲音錄成一個影片檔,所以畫面和聲音保持同步。 cleanValue() 做出一份訊號值的複本,可以安全地寫成 JSON:數字捨入、陣列變普通陣列、沒有函式、也不會太深。 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 toMidiFile() 把一個 take 寫成標準 MIDI 檔(type 0),任何 DAW 都能開。 import { EventLog } from '@openav/record'; packages/record/events.js 第 38 行
extFor()#
函式 @openav/record extFor(mime)
給一個錄好的 MIME 型別該用的副檔名:video/mp4 是 'mp4',audio/mp4 是 'm4a',不認識的是 'bin'。
Recorder 把它放進結果的 ext。拿來取下載檔名:'take.' + extFor(blob.type)。
參數 名稱 型別 說明 mimeStringMIME 型別,有沒有 codecs 都行,大小寫不拘。不是文字的東西當作空字串。
回傳 String mp4、m4a(audio/mp4)、webm、ogg、mkv(video/x-matroska)或 bin。
範例
js 複製
import { extFor } from '@openav/record' ;
console.log(extFor('video/mp4;codecs=avc1.640033,mp4a.40.2' )); // mp4
console.log(extFor('audio/mp4' ), extFor('audio/webm' )); // m4a webm
console.log(extFor('video/x-matroska' ), extFor('text/plain' )); // mkv bin
module 五個 MIME 型別,和各自拿到的副檔名。
相關 pickMime() 用瀏覽器自己的檢查,從清單裡挑出第一個瀏覽器錄得了的 MIME 型別。 Recorder 用一個 MediaRecorder 把一張畫布連同你給它的聲音錄成一個影片檔,所以畫面和聲音保持同步。 VIDEO_MIMES 錄影器會依序嘗試的影片 MIME 型別,最好的在前:先是 H.264 加 AAC 的 MP4,再來是 WebM。 AUDIO_MIMES 只錄聲音的檔案時,錄影器會依序嘗試的音訊 MIME 型別,最好的在前:先是 .m4a 的 AAC,再來是 Opus。 import { extFor } from '@openav/record'; packages/record/mime.js 第 57 行
framePoint()#
函式 @openav/record framePoint(camRect, u, v)
把鏡頭自己的 0..1 座標裡的一個點,像手的關節點,換成輸出畫面裡的像素。
手部或姿態追蹤給的關節點是鏡頭畫面的比例。要在輸出上、關節點所在的位置畫東西,需要的是輸出畫面的像素,而鏡頭可能被放大和裁切。framePoint() 就是做這件事。不管鏡頭有沒有被裁掉一部分,它給的點都是對的,因為它用的是 frame,也就是整個鏡頭畫面放進輸出之後的位置。追蹤器和合成器要用同樣的 mirror。
參數 名稱 型別 說明 camRectObject | nulllayoutRects() 的 cam,或 compositor.rects 的 cam。 uNumber橫跨鏡頭畫面,0 是左、1 是右,用顯示出來的方向(有 mirrorCam 就是鏡像後的)。 vNumber沿著鏡頭畫面往下,0 是上、1 是下。
回傳 Object | null 輸出像素的 { x, y },沒有鏡頭(或還不知道它的尺寸)就是 null。
範例
js 複製
import { layoutRects, framePoint } from '@openav/record' ;
const r = layoutRects('stack' , { w: 1080, h: 1920 }, { workW: 1000, workH: 1000, camW: 1920, camH: 1080 });
console.log(framePoint(r.cam, 0.5, 0.5)); // { x: 540, y: 1500 }: the middle of the camera
console.log(framePoint(r.cam, 0, 0)); // { x: -206.67, y: 1080 }: the left edge is cropped away, off the frame
console.log(framePoint(null , 0.5, 0.5)); // null: no camera
module 鏡頭的中心落在它的區域中間。鏡頭的左邊緣在畫面外面,因為 16:9 的畫面為了塞進去被裁掉了。
import { framePoint } from '@openav/record'; packages/record/layout.js 第 219 行
fromMidiFile()#
函式 @openav/record fromMidiFile(bytes, { name, families = 'both' } = {})
把標準 MIDI 檔讀成 take,裡面的事件就是鍵盤彈這個檔案時會發布的訊號。
它讀 type 0 和 type 1 的檔案(音軌會合併)、running status、速度變化,和 SMPTE 時間單位。系統專用訊息會被略過。不是 MIDI 的檔案會丟出 TypeError(fromMidiFile: not a MIDI file (no MThd)),中途結束的檔案會丟出 RangeError。用 TakePlayer 把結果播進演出:它驅動作品的方式,就像有人在鍵盤上彈這個檔案。
參數 名稱 型別 說明 bytesUint8Array | ArrayBuffer | Number[].mid 檔的位元組,例如 await file.arrayBuffer()。 nameStringtake 的名字。沒給的話用檔案裡的音軌名稱,再沒有就是 'take'。 可省略 families'both' | 'legacy' | 'channel'要產生哪幾種訊號寫法,跟 signalsOfMidi() 一樣。預設兩種都有,就像硬體。 可省略 預設 'both'
回傳 Object 一個 take。meta.bpm 是檔案裡第一個速度(SMPTE 時間的檔案不設)。
範例
js 複製
import { toMidiFile, fromMidiFile } from '@openav/record' ;
const take = { v: 1, name: 'riff' , durationMs: 1500, meta: { bpm: 120 }, events: [
{ t: 500, name: 'midi/note/on' , value: { note: 60, vel: 0.8, ch: 1 } },
{ t: 900, name: 'midi/note/off' , value: { note: 60, ch: 1 } },
] };
const back = fromMidiFile(toMidiFile(take));
console.log(back.name, back.durationMs, back.meta.bpm, back.events.length); // riff 1500 120 4
console.log(fromMidiFile(toMidiFile(take), { families: 'legacy' }).events.map((e) => e.name));
// ['midi/note/on', 'midi/note/off']
try { fromMidiFile(new Uint8Array([1, 2, 3, 4, 5])); } catch (e) { console.log(e.message); }
// fromMidiFile: not a MIDI file (no MThd)
module 來回一趟:兩個音符變回四個事件(兩種寫法),用 families: 'legacy' 則是兩個。不是 MIDI 檔的位元組會被拒絕。
import { fromMidiFile } from '@openav/record'; packages/record/smf.js 第 85 行
layoutRects()#
函式 @openav/record layoutRects(layout, out, src = {}, opts = {})
算出作品和鏡頭在輸出畫面的哪裡,單位是整數像素,支援四種版面。
這是純算術:沒有 DOM、沒有時鐘。Compositor 在尺寸改變時就呼叫它。1080 乘 1920 的畫面放一個正方形的作品、用 stack,會剛好切成 1080 乘 1080 和 1080 乘 840,中間沒有縫。鏡頭的裁切是用顯示出來的 方向表示的,所以開了鏡像之後,x: 0.2 的錨點仍然是「保留你看到的畫面的左邊」。
stack 把作品橫跨整個寬度放在上面,鏡頭在下面。如果這樣留給鏡頭的高度不到 minCam(正方形或橫式畫面),作品就只佔高度的 split,並且在裡面留黑邊。side 是同樣的做法轉九十度。pip 讓作品鋪滿畫面,把鏡頭放在角落當圓角小視窗。work 只有作品。
參數 名稱 型別 說明 layoutStringLAYOUTS 其中之一。其他名字會丟出 TypeError。 outObject輸出尺寸 { w, h },單位是像素。必須是正數,否則會丟出錯誤。 srcObject來源的尺寸:{ workW, workH, camW, camH }。沒給或是 0 就表示不知道。不知道尺寸的作品當作正方形。鏡頭尺寸不知道時,鏡頭的區域還是會先留好,crop: null、frame: null,所以鏡頭來的時候畫面不會跳動。 可省略 預設 {} optsObject選項:fit(作品,預設 'contain',或 'cover')、camFit(預設 'cover',或 'contain')、camAnchor({ x, y },0..1,預設在中間:滿版裁切時保留哪一部分,是你看到的畫面裡的位置)、minCam(0.2:stack 裡鏡頭至少要留的高度比例,side 裡是寬度比例)、split(0.6:留給鏡頭的比 minCam 還少時,作品佔的比例)、pip({ corner: 'br' | 'bl' | 'tr' | 'tl', size: 0.3, margin: 0.03, radius: 0.08, aspect: 0 }:小視窗寬度佔畫面的比例、離角落的距離佔短邊的比例、圓角半徑佔小視窗短邊的比例,以及形狀,0 表示用鏡頭自己的比例)。 可省略 預設 {}
回傳 Object { layout, out, work, cam }。work 和 cam 是輸出像素的 { x, y, w, h, crop, frame, box }(全是整數),cam 還多一個 radius。crop 是來源被畫出來的那一塊(來源像素,可以有小數),整張都畫就是 null。frame 是整個來源畫面會落在哪裡,被裁掉的部分在矩形外面,疊圖需要它。box 是它被放進的區域。work 版面的 cam 是 null。
範例
js 複製
import { layoutRects } from '@openav/record' ;
const r = layoutRects('stack' , { w: 1080, h: 1920 }, { workW: 1000, workH: 1000, camW: 1920, camH: 1080 });
console.log(r.work.x, r.work.y, r.work.w, r.work.h); // 0 0 1080 1080
console.log(r.cam.x, r.cam.y, r.cam.w, r.cam.h); // 0 1080 1080 840
console.log(r.cam.crop); // the middle of the 16:9 camera that fills 1080x840
module 直式畫面裡放正方形的作品和 16:9 的鏡頭:作品是上面 1080 像素,鏡頭用中間的裁切填滿它下面的 840。
js 複製
import { layoutRects } from '@openav/record' ;
const pip = layoutRects('pip' , { w: 1920, h: 1080 }, { workW: 1920, workH: 1080, camW: 1280, camH: 720 },
{ pip: { corner: 'tl' , size: 0.25 } });
console.log(pip.cam.x, pip.cam.y, pip.cam.w, pip.cam.h, pip.cam.radius); // 32 32 480 270 22
const noCam = layoutRects('stack' , { w: 1080, h: 1920 }, { workW: 1000, workH: 1000 });
console.log(noCam.cam.h, noCam.cam.crop, noCam.cam.frame); // 840 null null: the area is reserved
try { layoutRects('grid' , { w: 10, h: 10 }); } catch (e) { console.log(e.message); }
// layoutRects: unknown layout "grid" (stack, pip, side, work)
module 左上角的子母畫面、鏡頭還不存在時先留好的鏡頭區域,以及不存在的版面會得到的錯誤。
相關 LAYOUTS 四種版面的名字,照選單的順序:'stack'、'pip'、'side'、'work'。 framePoint() 把鏡頭自己的 0..1 座標裡的一個點,像手的關節點,換成輸出畫面裡的像素。 Compositor 每個畫面把你的作品和表演者的鏡頭畫進一張尺寸精確的畫布:這張畫布就是 Recorder 要錄的那張。 resolveSize() 把任何寫尺寸的方式,像 'vertical-4k'、'1080x1920' 或 [w, h],變成 { w, h },並進位成偶數。 import { layoutRects } from '@openav/record'; packages/record/layout.js 第 163 行
LAYOUTS#
常數 @openav/record LAYOUTS = array, 4 items
四種版面的名字,照選單的順序:'stack'、'pip'、'side'、'work'。
stack:作品橫跨整個寬度放在上面,鏡頭填滿下面剩下的(給直式影片:Reels、Shorts、TikTok)。pip:作品鋪滿畫面,鏡頭是角落的圓角小視窗(給正方形畫面,或需要整個畫面的作品)。side:作品佔滿左邊的整個高度,鏡頭填滿右邊(橫式)。work:只有作品。確切的位置由 layoutRects() 算出,Compositor 的 layout 選項收的就是這幾個名字,其他名字會丟出錯誤。
型別 String[]['stack', 'pip', 'side', 'work'],一份約定不要修改的清單。
範例
js 複製
import { LAYOUTS, layoutRects } from '@openav/record' ;
console.log(LAYOUTS.join(' ' )); // stack pip side work
for (const name of LAYOUTS) {
const r = layoutRects(name, { w: 1080, h: 1920 }, { workW: 1000, workH: 1000, camW: 1920, camH: 1080 });
console.log(name, r.work.w + 'x' + r.work.h, r.cam ? r.cam.w + 'x' + r.cam.h : 'no camera' );
}
// stack 1080x1080 1080x840
// pip 1080x1080 324x182
// side 648x648 432x1920 (a vertical frame is too narrow for side: the work gets 60% of the width)
// work 1080x1080 no camera
module 在同一個直式畫面上,用正方形的作品和 16:9 的鏡頭跑過四種版面,印出每個區域的大小。
相關 layoutRects() 算出作品和鏡頭在輸出畫面的哪裡,單位是整數像素,支援四種版面。 Compositor 每個畫面把你的作品和表演者的鏡頭畫進一張尺寸精確的畫布:這張畫布就是 Recorder 要錄的那張。 PRESETS 合成器認得名字的輸出尺寸,從 vertical-1080p(1080 乘 1920)到 landscape-4k,每個都附建議的版面和畫格率。 import { LAYOUTS } from '@openav/record'; packages/record/layout.js 第 14 行
listCameras()#
函式 @openav/record listCameras()
列出這台電腦的影像輸入,格式是 { deviceId, groupId, label }。
瀏覽器在頁面拿到過一次鏡頭權限之前,會隱藏名字(有時連 id 也是),所以在 openCamera() 之後再呼叫一次,才會拿到真正的名字。需要 https 或 localhost:其他地方沒有 navigator.mediaDevices,promise 會被 reject。
回傳 Promise<Object[]> 一個 promise,內容是 { deviceId, groupId, label } 的清單。沒有名字的裝置會依位置叫作 Camera 1、Camera 2…。
範例
js 複製
import { listCameras, openCamera } from '@openav/record' ;
console.log((await listCameras()).length > 0); // true; the labels are generic until permission is given
const stream = await openCamera(); // the browser asks for the camera
const cams = await listCameras(); // now with their real names
console.log(cams.map((c) => c.label).join(', ' ));
stream.getTracks().forEach((t) => t.stop());
module 在拿到權限之前和之後各列一次鏡頭。第二次的清單有真正的名字。用完之後要把音軌停掉。
import { listCameras } from '@openav/record'; packages/record/camera.js 第 36 行
listMicrophones()#
函式 @openav/record listMicrophones()
列出這台電腦的音訊輸入,格式是 { deviceId, groupId, label }。
跟 listCameras() 一樣,只是列的是音訊,名字的規則也一樣:頁面拿到過一次麥克風權限之前,名字都是通用的。把 deviceId 交給 AudioTap.addMicrophone() 就能使用其中一個。
回傳 Promise<Object[]> 一個 promise,內容是 { deviceId, groupId, label } 的清單。沒有名字的裝置會依位置叫作 Microphone 1、Microphone 2…。
範例
js 複製
import { listMicrophones } from '@openav/record' ;
const mics = await listMicrophones();
console.log(mics.length > 0, Object.keys(mics[0]).join(',' )); // true deviceId,groupId,label
module 列出音訊輸入,並顯示其中一項的形狀。
相關 listCameras() 列出這台電腦的影像輸入,格式是 { deviceId, groupId, label }。 AudioTap 把你的作品的聲音(以及你加進來的麥克風)收集成一條可以錄的音軌,不會把任何東西送到喇叭。 openCamera() 開啟鏡頭並回傳它的 MediaStream:理想是 1920 乘 1080、30 fps,不行就用鏡頭做得到的。 import { listMicrophones } from '@openav/record'; packages/record/camera.js 第 39 行
midiEventsOf()#
函式 @openav/record midiEventsOf(take)
把一個 take 讀成單純的 MIDI 事件(音符開關、控制器、彎音),只選一種名稱寫法,所以不會重複算。
在硬體上錄的 take,同一個音會用好幾個名字記好幾次。這裡替音符選一種寫法:有 midi/note/* 就用它,沒有就用 midi/ch/*,再沒有就用某一個裝置的 midi/<device>/note/*,最後是 midi/virtual 的原始位元組。控制器和彎音:有 midi/ch/* 就用,沒有就用傳統名稱(當作頻道 1),再沒有就用某一個裝置的。toMidiFile() 和 takeStats() 都是建立在它上面。
回傳 Object[] @openav/midi 的格式:{ t, type: 'noteon', ch, note, vel }、noteoff、{ t, type: 'cc', ch, cc, value }、{ t, type: 'pitchbend', ch, value }(0..16383)、chanat、polyat。vel 和 value 是 0..127。
範例
js 複製
import { midiEventsOf } from '@openav/record' ;
const take = { v: 1, name: 'x' , durationMs: 600, events: [
{ t: 100, name: 'midi/note/on' , value: { note: 60, vel: 0.8, ch: 1 } },
{ t: 100, name: 'midi/ch/1/note/60' , value: 0.8 }, // the same key, spelled the other way
{ t: 400, name: 'midi/note/off' , value: { note: 60, ch: 1 } },
{ t: 400, name: 'midi/ch/1/note/60' , value: 0 },
] };
console.log(midiEventsOf(take));
// [{ t: 100, type: 'noteon', ch: 1, note: 60, vel: 102 }, { t: 400, type: 'noteoff', ch: 1, note: 60, vel: 0 }]
module 四個錄下來的事件其實是一個鍵按下又放開,寫了兩次。結果只有兩個 MIDI 事件。
import { midiEventsOf } from '@openav/record'; packages/record/take.js 第 144 行
mixTracks()#
函式 @openav/record mixTracks(tracks)
把好幾條音軌混成一條,因為 Chrome 只會錄一個串流的第一條音軌。
你給 Recorder 超過一條活著的音軌時,它會自己呼叫這個,並在 stop() 時關掉混音。想把那一條音軌拿去做別的事時,才自己呼叫。它會建一個 AudioContext,所以頁面需要先被點過一次。如果聲音是來自 Web Audio 節點,用 AudioTap 比較好:它直接混,不用繞一圈串流。
參數 名稱 型別 說明 tracksMediaStreamTrack[]要混的音軌。
回傳 Object { track, close() }:混好的那一條 MediaStreamTrack,以及關掉混音用的 AudioContext 的函式。
範例
js 複製
import { AudioTap, mixTracks } from '@openav/record' ;
const tap = new AudioTap();
const mix = mixTracks([tap.tracks[0], tap.workTracks[0]]); // two tracks in, one comes out
console.log(mix.track.kind, mix.track.readyState); // audio live
mix.close(); // closes the mixing context
tap.dispose();
module 兩條音軌進去,出來一條活的音軌。close() 把混音器關掉。
相關 Recorder 用一個 MediaRecorder 把一張畫布連同你給它的聲音錄成一個影片檔,所以畫面和聲音保持同步。 AudioTap 把你的作品的聲音(以及你加進來的麥克風)收集成一條可以錄的音軌,不會把任何東西送到喇叭。 import { mixTracks } from '@openav/record'; packages/record/recorder.js 第 23 行
normalizeTake()#
函式 @openav/record normalizeTake(input)
把任何長得像 take 的東西,或它的 JSON 文字,整理成合格的 take:事件依時間排序、壞事件丟掉、沒有負數。
take 就是過濾成 midi/* 的 EventLog 記下的東西,是單純的資料:t 是從開始算起的毫秒數,name 是訊號名稱,像 midi/note/on,value 是它的內容,meta 放自由欄位,像 { bpm, device, notes }。normalizeTake() 讓任何輸入都變安全:不是物件、沒有名字、t 不是數字的事件會被丟掉,t 捨入到十分之一毫秒並限制在 0 以上,事件依 t 排序(時間一樣的保持原來的順序),name 預設是 'take',durationMs 至少是最後一個事件的時間。回傳的是新物件。
這個套件裡每個收 take 的函式,都會先把它送進這裡處理,所以你可以直接傳原始資料。
參數 名稱 型別 說明 inputObject | String一個 take,或它的 JSON 文字。字串會用 JSON.parse 解析。不是物件的東西會丟出 TypeError。
回傳 Object 一個新的 take:{ v: 1, name, createdAt, durationMs, events: [{ t, name, value }], meta }。
範例
js 複製
import { normalizeTake } from '@openav/record' ;
const take = normalizeTake({
events: [{ t: 30, name: 'b' }, { t: 10, name: 'a' , value: 1 }, { t: 'x' , name: 'c' }, { name: 'd' }, null ],
durationMs: 5,
});
console.log(take.events.map((e) => e.t + ':' + e.name).join(' ' )); // 10:a 30:b
console.log(take.durationMs, take.name, take.v); // 30 take 1
module 五個事件裡三個是垃圾,會被丟掉。剩下的排好序,長度也被拉長到涵蓋最後一個事件。
import { normalizeTake } from '@openav/record'; packages/record/take.js 第 30 行
noteOf()#
函式 @openav/record noteOf(ev)
告訴你一個錄下來的事件是不是鍵按下去或放開,不管它用哪一種寫法,以及是哪個頻道的哪個音。
硬體鍵盤會把同一個音符用好幾種名字發布,這個函式全部認得:midi/note/on 和 midi/note/off(寫法 legacy,內容是 { note, vel, ch })、midi/ch/<ch>/note/<note>(寫法 channel,是數字,0 表示放開)、midi/<device>/note/on|off(寫法 slug:<device>),以及 midi/virtual 上的原始位元組(寫法 virtual,{ data: [status, note, vel] })。原始位元組裡力度是 0 的按下算放開。頻道是 0 的(螢幕鋼琴會這樣)當作頻道 1。
參數 名稱 型別 說明 evObjecttake 的一個事件 { name, value }。
回傳 Object | null { key, on, note, ch, family, vel },不是音符事件就是 null。key 是 '<ch>:<note>',on 是 true 表示按下,family 說明它用哪一種寫法,vel 是 0..127。
範例
js 複製
import { noteOf } from '@openav/record' ;
console.log(noteOf({ name: 'midi/note/on' , value: { note: 60, vel: 0.8, ch: 1 } }));
// { key: '1:60', on: true, note: 60, ch: 1, family: 'legacy', vel: 102 }
console.log(noteOf({ name: 'midi/ch/1/note/60' , value: 0 }).on); // false
console.log(noteOf({ name: 'midi/virtual' , value: { data: [0x90, 60, 100] } }).on); // true
console.log(noteOf({ name: 'midi/cc/1' , value: 1 })); // null
module 同一個鍵的三種寫法,和一個不是音符的控制器。
相關 noteOffFor() 給出能放開一個正在響的音的事件,用的是它當初被彈出來的那種寫法。 midiEventsOf() 把一個 take 讀成單純的 MIDI 事件(音符開關、控制器、彎音),只選一種名稱寫法,所以不會重複算。 TakePlayer 把一個 take 在對的時間播回匯流排,讓作品、它的聲音和和弦偵測器分不出這是播放還是你在彈。 TakeRecorder 錄下你彈的東西:從 start() 到 stop() 聽匯流排上每個 midi/* 訊號,然後給你一個 take。 import { noteOf } from '@openav/record'; packages/record/take.js 第 99 行
noteOffFor()#
函式 @openav/record noteOffFor(n)
給出能放開一個正在響的音的事件,用的是它當初被彈出來的那種寫法。
TakePlayer 和 TakeRecorder 就是這樣確保沒有音一直響著:legacy 是 midi/note/off,channel 是值為 0 的 midi/ch/<ch>/note/<note>,virtual 是原始的放開位元組,裝置寫法則是 midi/<device>/note/off。
參數 名稱 型別 說明 nObjectnoteOf() 的結果:{ note, ch, family }。
回傳 Object[] { name, value } 事件的清單(一個,不認得的寫法則是空的)。
範例
js 複製
import { noteOf, noteOffFor } from '@openav/record' ;
const down = noteOf({ name: 'midi/ch/3/note/60' , value: 0.5 });
console.log(noteOffFor(down)); // [{ name: 'midi/ch/3/note/60', value: 0 }]
const legacy = noteOf({ name: 'midi/note/on' , value: { note: 60, vel: 0.8, ch: 1 } });
console.log(noteOffFor(legacy)); // [{ name: 'midi/note/off', value: { note: 60, ch: 1 } }]
module 同一個鍵怎麼放開要看它當初怎麼彈:每頻道的寫法用 0 放開,傳統的用 note-off。
相關 noteOf() 告訴你一個錄下來的事件是不是鍵按下去或放開,不管它用哪一種寫法,以及是哪個頻道的哪個音。 TakePlayer 把一個 take 在對的時間播回匯流排,讓作品、它的聲音和和弦偵測器分不出這是播放還是你在彈。 TakeRecorder 錄下你彈的東西:從 start() 到 stop() 聽匯流排上每個 midi/* 訊號,然後給你一個 take。 import { noteOffFor } from '@openav/record'; packages/record/take.js 第 126 行
openCamera()#
函式 @openav/record openCamera(opts = {})
開啟鏡頭並回傳它的 MediaStream:理想是 1920 乘 1080、30 fps,不行就用鏡頭做得到的。
把串流交給 Compositor.setCamera() 。串流由呼叫的人擁有:用完要自己把音軌停掉,合成器不會做。網路攝影機通常最多到 1080p30,所以 4K 的畫面會把鏡頭放大,上面的作品仍然清楚。需要 https 或 localhost,以及使用者許可。
參數 名稱 型別 說明 optsObjectcameraConstraints() 的選項:{ deviceId, width, height, fps, facingMode }。 可省略 預設 {}
回傳 Promise<MediaStream> 串流的 promise,有一條影像軌,沒有聲音。權限被拒或沒有鏡頭時會用瀏覽器的錯誤 reject。
範例
js 複製
import { openCamera } from '@openav/record' ;
const stream = await openCamera(); // the browser asks for the camera
const video = document.createElement('video' );
video.muted = true ; video.srcObject = stream; await video.play();
console.log(video.videoWidth > 0); // true once frames arrive
document.getElementById('app' ).append(video);
video.style.width = '240px' ;
module 允許鏡頭之後,頁面上會出現一個小小的即時畫面。
import { openCamera } from '@openav/record'; packages/record/camera.js 第 47 行
pickMime()#
函式 @openav/record pickMime(isTypeSupported, { audioOnly = false , prefer = null , candidates = null } = {})
用瀏覽器自己的檢查,從清單裡挑出第一個瀏覽器錄得了的 MIME 型別。
Recorder 會替你呼叫它。它把瀏覽器的檢查當成參數傳進來,所以是純函式,可以像下面的範例那樣用假的檢查來試。
參數 名稱 型別 說明 isTypeSupportedFunction瀏覽器的檢查函式,例如 (m) => MediaRecorder.isTypeSupported(m)。給的不是函式就回傳 ''。丟出錯誤的檢查,對那個型別算「不行」。 audioOnlyBoolean用 AUDIO_MIMES 而不是 VIDEO_MIMES 。 可省略 預設 false prefer'mp4' | 'webm'把這種容器移到最前面,兩組各自的內部順序不變。 可省略 預設 null candidatesString[]用你自己的清單,取代內建的。 可省略 預設 null
回傳 String 第一個支援的型別,都不符合就回傳 ''(這時就讓 MediaRecorder 用它的預設)。
範例
js 複製
import { pickMime } from '@openav/record' ;
const pretend = (...ok) => (mime) => ok.includes(mime); // a fake browser that supports only these
const both = pretend('video/webm;codecs=vp9,opus' , 'video/mp4;codecs=avc1.640033,mp4a.40.2' );
console.log(pickMime(both)); // video/mp4;codecs=avc1.640033,mp4a.40.2
console.log(pickMime(both, { prefer: 'webm' })); // video/webm;codecs=vp9,opus
console.log(pickMime(pretend('audio/webm;codecs=opus' ), { audioOnly: true })); // audio/webm;codecs=opus
console.log(JSON.stringify(pickMime(() => false ))); // ""
module 四個假的瀏覽器:兩種都支援的、同一個但要求優先 WebM、只錄得了 Opus 的,和什麼都錄不了的。
相關 VIDEO_MIMES 錄影器會依序嘗試的影片 MIME 型別,最好的在前:先是 H.264 加 AAC 的 MP4,再來是 WebM。 AUDIO_MIMES 只錄聲音的檔案時,錄影器會依序嘗試的音訊 MIME 型別,最好的在前:先是 .m4a 的 AAC,再來是 Opus。 extFor() 給一個錄好的 MIME 型別該用的副檔名:video/mp4 是 'mp4',audio/mp4 是 'm4a',不認識的是 'bin'。 Recorder 用一個 MediaRecorder 把一張畫布連同你給它的聲音錄成一個影片檔,所以畫面和聲音保持同步。 import { pickMime } from '@openav/record'; packages/record/mime.js 第 42 行
presetById()#
函式 @openav/record presetById(id)
用 id 找出一個尺寸預設:presetById('vertical-4k')。
拿來為尺寸選單填上 PRESETS 的內容,或在建 Compositor 之前讀一個尺寸建議的畫格率和版面。
參數 名稱 型別 說明 idString預設的 id,例如 'square-1080'。
回傳 Object | null 那個預設 { id, group, w, h, label, labelZh, layout, fps },沒有這個 id 就是 null。
範例
js 複製
import { presetById } from '@openav/record' ;
const p = presetById('vertical-4k' );
console.log(p.w, p.h, p.fps, p.layout); // 2160 3840 30 stack
console.log(presetById('nope' )); // null
module 印出一個預設的各個數字,並顯示不認識的 id 會得到 null。
相關 PRESETS 合成器認得名字的輸出尺寸,從 vertical-1080p(1080 乘 1920)到 landscape-4k,每個都附建議的版面和畫格率。 resolveSize() 把任何寫尺寸的方式,像 'vertical-4k'、'1080x1920' 或 [w, h],變成 { w, h },並進位成偶數。 import { presetById } from '@openav/record'; packages/record/layout.js 第 69 行
PRESETS#
常數 @openav/record PRESETS = array, 11 items
合成器認得名字的輸出尺寸,從 vertical-1080p(1080 乘 1920)到 landscape-4k,每個都附建議的版面和畫格率。
text
id 尺寸 fps 版面
vertical-1080p 1080x1920 60 stack
vertical-4x5 1440x1800 60 stack
vertical-2.7k 1520x2704 30 stack
vertical-4k 2160x3840 30 stack
landscape-1080p 1920x1080 60 side
landscape-16x10 1920x1200 60 side
landscape-2.7k 2704x1520 30 side
landscape-4k 3840x2160 30 side
square-1080 1080x1080 60 pip
square-2048 2048x2048 30 pip
square-2160 2160x2160 30 pip每一邊都是偶數,因為 H.264 和 VP9 用 4:2:0 的色彩編碼,需要偶數的尺寸。可以把 id 當作 Compositor 的 size。用 presetById() 查某一個。
型別 Object[]一份凍結的清單,有 11 個預設。每個是 { id, group, w, h, label, labelZh, layout, fps }:group 是 'vertical'、'landscape' 或 'square',label 和 labelZh 是給選單用的名字,layout 是這種形狀建議的版面,fps 是 recommendedFps() 。
範例
js 複製
import { PRESETS } from '@openav/record' ;
console.log(PRESETS.length, PRESETS[0].id); // 11 vertical-1080p
const big = PRESETS.filter((p) => p.w * p.h > 2.6e6).map((p) => p.id + ' @' + p.fps);
console.log(big.join(', ' ));
// vertical-2.7k @30, vertical-4k @30, landscape-2.7k @30, landscape-4k @30, square-2048 @30, square-2160 @30
module 列出大於約 260 萬像素的預設:它們全都用每秒 30 個畫面來錄。
import { PRESETS } from '@openav/record'; packages/record/layout.js 第 54 行
quantizeTake()#
函式 @openav/record quantizeTake(take, { bpm, grid = 4, strength = 1, offsetMs = 0 } = {})
把一個 take 的按鍵對齊到節奏格子上,讓不太準的演奏落在拍點上。
每次按下的鍵會移到最近的格線上(格線之間隔 60000 / bpm / grid 毫秒)。音符放開的時間跟著一起移,所以每個音的長度不變。不是音符的東西(控制器、彎音)留在原處。同一個音符用兩種寫法同時記錄的(midi/note/on 和 midi/ch/1/note/60),兩邊移動的量一樣。
參數 名稱 型別 說明 takeObject這個 take。 bpmNumber速度。沒給的話用 take.meta.bpm,再沒有就是 120。 可省略 gridNumber每一拍幾格:4 是十六分音符,2 是八分音符,1 是四分音符。 可省略 預設 4 strengthNumber移動多少,0..1。1 是移到定位,0.5 是移一半,0 是什麼都不變。 可省略 預設 1 offsetMsNumber把格子往後移這麼多毫秒,給不是從拍點開始的 take 用。 可省略 預設 0
範例
js 複製
import { quantizeTake } from '@openav/record' ;
const on = (t, note) => ({ t, name: 'midi/note/on' , value: { note, vel: 0.8, ch: 1 } });
const off = (t, note) => ({ t, name: 'midi/note/off' , value: { note, ch: 1 } });
const take = { v: 1, name: 'loose' , durationMs: 1500, meta: { bpm: 120 },
events: [on(510, 60), off(900, 60), on(1010, 64), off(1400, 64)] };
const q = quantizeTake(take, { grid: 4 }); // sixteenths at 120 bpm: a grid line every 125 ms
console.log(q.events.map((e) => e.t)); // [500, 890, 1000, 1390]
console.log(quantizeTake(take, { grid: 4, strength: 0.5 }).events.map((e) => e.t)); // [505, 895, 1005, 1395]
module 兩個稍微偏離拍點的音對齊到 500 和 1000 毫秒,每個放開的時間跟著它的音一起移。強度一半的話,它們只移一半。
import { quantizeTake } from '@openav/record'; packages/record/take.js 第 259 行
recommendedBitrate()#
函式 @openav/record recommendedBitrate(w, h, fps = recommendedFps(w, h))
能讓快速的生成式細節保持乾淨的影像位元率:每個像素每個畫面 0.12 位元,限制在 4 到 80 Mb/s。
粒子和細線是位元率太低時最先被糊掉的東西,所以這個數字刻意比串流的位元率高:這個檔案是要再剪輯、再編碼的母帶。它是錄畫布的 Recorder 的 videoBitsPerSecond 預設值。
參數 名稱 型別 說明 wNumber寬,單位是像素。 hNumber高,單位是像素。 fpsNumber畫格率。沒給的話,用這個尺寸的 recommendedFps() 。 可省略 預設 recommendedFps(w, h)
回傳 Number 每秒位元數,四捨五入到 100 000。1080 乘 1920、60 fps 是 14 900 000,4K、30 fps 是 29 900 000。
範例
js 複製
import { recommendedBitrate } from '@openav/record' ;
const mbps = (w, h, fps) => (recommendedBitrate(w, h, fps) / 1e6).toFixed(1) + ' Mb/s' ;
console.log(mbps(1080, 1920)); // 14.9 Mb/s
console.log(mbps(2160, 3840)); // 29.9 Mb/s
console.log(mbps(320, 240)); // 4.0 Mb/s, the floor
console.log(mbps(3840, 2160, 60)); // 59.7 Mb/s
module 幾種尺寸的位元率,單位是每秒百萬位元:很小的畫面會停在下限 4,4K 要 60 fps 的話數字會加倍。
import { recommendedBitrate } from '@openav/record'; packages/record/layout.js 第 37 行
recommendedFps()#
函式 @openav/record recommendedFps(w, h)
一個畫面尺寸該用什麼畫格率來錄:到約 260 萬像素是 60,再大就是 30。
60 fps 可以撐到約 1080 乘 1920(以及 1440 乘 1800、1920 乘 1200)。4K 的畫布用 60 的話,合成器和編碼器每秒要處理 5 億個像素,在筆電上表演到一半會掉格,而且相機拍 4K 本來也是 30。把結果當作 fps 交給 Recorder 。
參數 名稱 型別 說明 wNumber寬,單位是像素。 hNumber高,單位是像素。
回傳 30 | 60 w × h 不超過 2 600 000 像素是 60,否則是 30。
範例
js 複製
import { recommendedFps } from '@openav/record' ;
console.log(recommendedFps(1080, 1920)); // 60
console.log(recommendedFps(2160, 3840)); // 30
console.log(recommendedFps(1920, 1200)); // 60
module 印出 1080p 直式、4K 直式和 16:10 橫式畫面的畫格率。
相關 recommendedBitrate() 能讓快速的生成式細節保持乾淨的影像位元率:每個像素每個畫面 0.12 位元,限制在 4 到 80 Mb/s。 Recorder 用一個 MediaRecorder 把一張畫布連同你給它的聲音錄成一個影片檔,所以畫面和聲音保持同步。 PRESETS 合成器認得名字的輸出尺寸,從 vertical-1080p(1080 乘 1920)到 landscape-4k,每個都附建議的版面和畫格率。 import { recommendedFps } from '@openav/record'; packages/record/layout.js 第 25 行
Recorder#
類別 @openav/record new Recorder({ canvas = null , stream = null , fps = 60, audioTracks = [], audioOnly = false , videoBitsPerSecond, audioBitsPerSecond = 192000, mimeType, prefer = null } = {})
用一個 MediaRecorder 把一張畫布連同你給它的聲音錄成一個影片檔,所以畫面和聲音保持同步。
new Recorder({ canvas, audioTracks }),然後 start(),之後 await stop()。畫布的影像軌和聲音軌進的是同一個 MediaRecorder,它用同一個時鐘替兩者打時間戳記,所以事後不用對齊。stop() 在最後一塊資料到了之後 resolve,內容是 { mimeType, ext, bytes, chunks, durationMs, width, height, fps, audio, blob, error }:blob 就是檔案,ext 是該用的副檔名(mp4、webm、m4a…),audio 是有沒有聲音。把 blob 交給 URL.createObjectURL() 就能做下載連結。
拿到什麼檔案看瀏覽器:能錄 MP4(H.264 和 AAC)就錄 MP4,不行就錄 WebM。Chrome 錄出來的 WebM 檔頭沒有長度,播放器沒問題,剪輯軟體可能需要 ffmpeg -i in.webm -c copy out.webm。這個檔案是拿來剪輯的母帶,所以預設的位元率偏高。
要讓很長的一段錄影不佔著分頁的記憶體,就在 start() 傳 ondata(blob, seq):每一塊資料(每隔 timeslice 毫秒)會依序交給你,你回傳的 promise 會在下一塊、以及 stop() resolve 之前被等待,所以上傳的順序不會亂。有給 ondata 的話資料塊不會保留,blob 是 null,除非你傳 keep: true。要讓一段錄影跟 EventLog 對齊,就用 recorder.t0 來開始事件記錄,它是開始錄影那一刻的 performance.now()。
呼叫之前
需要有 MediaRecorder 的瀏覽器(Recorder.supported 會告訴你),也要有畫布或串流,或 audioOnly: true:否則建構時會丟出 TypeError。混好幾條音軌會建一個 AudioContext,頁面需要先被點過一次。串流裡有鏡頭或麥克風的話,一樣需要使用者許可。
參數 名稱 型別 說明 canvasHTMLCanvasElement要錄什麼,通常是 compositor.canvas。 可省略 預設 null streamMediaStream要錄的現成影像串流,用來取代畫布。 可省略 預設 null fpsNumber畫布的擷取頻率。用 recommendedFps(w, h) :2.7K 和 4K 是 30。 可省略 預設 60 audioTracksMediaStreamTrack[]聲音,例如 AudioTap 的 tap.tracks。給好幾條會混成一條,因為 Chrome 只會錄一個串流的第一條音軌。 可省略 預設 [] audioOnlyBoolean只錄音軌:把聲音另外存成一個檔。這時不需要 canvas 和 stream。 可省略 預設 false videoBitsPerSecondNumber影像位元率。預設是依畫布尺寸和 fps 算出的 recommendedBitrate() (沒有畫布的話是 12 Mb/s)。 可省略 audioBitsPerSecondNumber聲音位元率。 可省略 預設 192000 mimeTypeString強制用某一個 MIME 型別。預設是瀏覽器支援的第一個 VIDEO_MIMES 或 AUDIO_MIMES ,見 pickMime() 。 可省略 prefer'mp4' | 'webm'挑 MIME 型別的時候先試這種容器。 可省略 預設 null
成員 名稱 說明 stategetter Getter。'inactive'、'recording' 或 'paused'。 recordinggetter Getter。state 是不是 'recording'。 elapsedgetter Getter。錄影中是從 start() 算起的毫秒數,停止之後是這段錄影的長度,開始之前是 0。 bytesgetter Getter。到目前為止交出來的位元組數。有些瀏覽器只在停止時才交資料,所以很短的錄影過程中它可能一直是 0。 start({ timeslice = 1000, ondata = null, keep } = {})method 開始錄影並回傳錄影器。{ timeslice = 1000, ondata, keep }:每隔多久(毫秒)交一塊資料、每一塊要呼叫的函式,以及要不要留下資料塊做成 blob。對已經在錄的錄影器再開始不會有作用。瀏覽器沒有 MediaRecorder 會丟出錯誤。 stop()method 停止,等最後一塊資料和每個 ondata 的 promise,停掉它自己建的畫布影像軌,並回傳結果物件的 promise。再呼叫一次會回傳同一個結果。從沒開始過就呼叫會丟出錯誤。 supportedstatic property Static getter。這個瀏覽器能不能錄畫布:有 MediaRecorder 和 canvas.captureStream 就是 true。
範例
js 複製
import { Recorder } from '@openav/record' ;
const canvas = document.getElementById('c' );
const g = canvas.getContext('2d' );
let x = 0;
(function animate() { // something moving to record
g.fillStyle = '#111' ; g.fillRect(0, 0, 400, 300);
g.fillStyle = '#ffd166' ; g.fillRect(x = (x + 5) % 380, 120, 20, 60);
requestAnimationFrame(animate);
})();
const rec = new Recorder({ canvas, fps: 30 });
rec.start();
await new Promise((done) => setTimeout(done, 1000)); // record for one second
const take = await rec.stop();
console.log(take.ext, take.width, take.height, take.fps, take.audio, take.blob.size > 0);
// mp4 400 300 30 false true (webm where the browser cannot write mp4)
const a = document.getElementById('app' ).appendChild(document.createElement('a' ));
a.href = URL.createObjectURL(take.blob); a.download = 'take.' + take.ext; a.textContent = 'Download' ;
module 錄一秒鐘會動的方塊,印出這個檔案是什麼。頁面上出現一個下載連結。
js 複製
import { Recorder, AudioTap } from '@openav/record' ;
const tap = new AudioTap(); // the sound that goes into the file
const osc = new OscillatorNode(tap.context, { frequency: 220 });
tap.add(osc); osc.start();
await tap.resume(); // from a click, in a real page
const video = new Recorder({ canvas: document.getElementById('c' ), fps: 30, audioTracks: tap.tracks });
const sound = new Recorder({ audioOnly: true , audioTracks: tap.workTracks }); // the sound as its own file
video.start(); sound.start();
await new Promise((done) => setTimeout(done, 1000));
const [v, a] = await Promise.all([video.stop(), sound.stop()]);
console.log(v.ext, v.audio, a.ext, a.width); // mp4 true m4a 0
module 一秒鐘的影片,裡面有一個 220 Hz 的音,同一個音另外存成一個聲音檔。影片的畫面和聲音進的是同一個錄影器。
js 複製
import { Recorder } from '@openav/record' ;
const chunks = [];
const rec = new Recorder({ canvas: document.getElementById('c' ), fps: 30 });
rec.start({
timeslice: 250,
ondata: async (blob, seq) => { chunks.push(blob.size); }, // send it somewhere here; the next chunk waits
});
await new Promise((done) => setTimeout(done, 1000));
const take = await rec.stop();
console.log(take.blob, take.chunks === chunks.length, take.bytes === chunks.reduce((a, b) => a + b, 0)); // null true true
module 有給 ondata 的話,資料塊會依序一塊一塊交給你,結果裡沒有 blob:長時間的錄影就是這樣不佔分頁的記憶體。
待確認
錄影進行中資料會不會陸續送來,要看容器和瀏覽器。在 headless Chromium 錄 MP4 的時候,一秒鐘的錄影、timeslice 設 200,bytes 一直是 0,資料塊是在 stop() 時才來。其他瀏覽器和 WebM 沒有試過。
import { Recorder } from '@openav/record'; packages/record/recorder.js 第 32 行
resolveSize()#
函式 @openav/record resolveSize(size)
把任何寫尺寸的方式,像 'vertical-4k'、'1080x1920' 或 [w, h],變成 { w, h },並進位成偶數。
Compositor 的 size 和 setSize() 用的就是它。讀不懂的尺寸('wat'、負數、少一邊)會丟出 TypeError:record: unknown size "wat"。尺寸要偶數,是因為 H.264 和 VP9 用 4:2:0 的色彩編碼。
參數 名稱 型別 說明 sizeString | Number[] | ObjectPRESETS 的 id、'WxH'(也可以用 × 或 *)、[w, h] 或 { w, h }。
回傳 Object { w, h },整數,各自進位到下一個偶數。
範例
js 複製
import { resolveSize } from '@openav/record' ;
console.log(resolveSize('vertical-4k' )); // { w: 2160, h: 3840 }
console.log(resolveSize('1081x1921' )); // { w: 1082, h: 1922 }
console.log(resolveSize([1000, 563])); // { w: 1000, h: 564 }
try { resolveSize('wat' ); } catch (e) { console.log(e.message); } // record: unknown size "wat"
module 四種寫尺寸的方式:預設、文字、陣列,和一個會丟出錯誤的寫法。奇數的邊會變成偶數。
相關 PRESETS 合成器認得名字的輸出尺寸,從 vertical-1080p(1080 乘 1920)到 landscape-4k,每個都附建議的版面和畫格率。 presetById() 用 id 找出一個尺寸預設:presetById('vertical-4k')。 Compositor 每個畫面把你的作品和表演者的鏡頭畫進一張尺寸精確的畫布:這張畫布就是 Recorder 要錄的那張。 layoutRects() 算出作品和鏡頭在輸出畫面的哪裡,單位是整數像素,支援四種版面。 import { resolveSize } from '@openav/record'; packages/record/layout.js 第 79 行
signalsOfMidi()#
函式 @openav/record signalsOfMidi(ev, { families = 'both' , device = null } = {})
把一個 MIDI 事件轉成鍵盤彈它時會發布的訊號事件,讓一個檔案能像現場演奏那樣驅動作品。
fromMidiFile() 用它把 .mid 檔做成 take:檔案裡每個事件都變成鍵盤本來會發布的訊號,這樣 TakePlayer 就能把它播進演出裡。如果你有別處來的 MIDI 事件,想放到 Signals 匯流排上,就自己呼叫它。
參數 名稱 型別 說明 evObjectMIDI 事件 { type, ch, note?, vel?, cc?, value? }。type 是 noteon、noteoff、cc、pitchbend(value 是 0..16383)、chanat 或 polyat,力度和數值是 0..127。 families'both' | 'legacy' | 'channel'要做哪幾種寫法:傳統名稱(midi/note/on、midi/cc/64、midi/bend)、每頻道的(midi/ch/1/note/60、midi/ch/1/cc/64…),或兩種都做,就像硬體一樣。 可省略 預設 'both' deviceString要放進傳統音符事件的裝置名稱。 可省略 預設 null
回傳 Object[] { name, value } 的清單。音符力度變成 0..1(傳統寫法有 vel 和 velocity),控制器是 0..1,彎音是 -1..1。chanat 和 polyat 只有每頻道的寫法。
範例
js 複製
import { signalsOfMidi } from '@openav/record' ;
const ev = { type: 'noteon' , ch: 1, note: 60, vel: 100 };
console.log(signalsOfMidi(ev));
// [{ name: 'midi/note/on', value: { note: 60, vel: 0.787402, velocity: 100, ch: 1 } },
// { name: 'midi/ch/1/note/60', value: 0.787402 }]
console.log(signalsOfMidi(ev, { families: 'channel' })); // [{ name: 'midi/ch/1/note/60', value: 0.787402 }]
console.log(signalsOfMidi({ type: 'cc' , ch: 1, cc: 64, value: 127 }, { families: 'legacy' }));
// [{ name: 'midi/cc/64', value: 1 }]
module 一個 note-on 變成兩個訊號(兩種寫法),挑一種寫法就只有一個。踩到底的延音踏板是 1。
相關 fromMidiFile() 把標準 MIDI 檔讀成 take,裡面的事件就是鍵盤彈這個檔案時會發布的訊號。 midiEventsOf() 把一個 take 讀成單純的 MIDI 事件(音符開關、控制器、彎音),只選一種名稱寫法,所以不會重複算。 TakePlayer 把一個 take 在對的時間播回匯流排,讓作品、它的聲音和和弦偵測器分不出這是播放還是你在彈。 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 import { signalsOfMidi } from '@openav/record'; packages/record/take.js 第 189 行
TAKE_VERSION#
常數 @openav/record TAKE_VERSION = 1
take 格式的版本號,是 1。每個 take 都有 v: 1。
take 是一個普通物件 { v, name, createdAt, durationMs, events, meta },見 normalizeTake() 。v 不是這個數字時,validateTake() 會抱怨,normalizeTake() 會把它寫進去。
型別 Number版本號就是 1。
範例
js 複製
import { TAKE_VERSION, normalizeTake } from '@openav/record' ;
console.log(TAKE_VERSION); // 1
console.log(normalizeTake({ events: [] }).v); // 1
module 印出版本號,以及從空白做出來的 take 也會有它。
import { TAKE_VERSION } from '@openav/record'; packages/record/take.js 第 19 行
TakePlayer#
類別 @openav/record new TakePlayer({ signals = null , take = null , loop = true , speed = 1, yieldToLive = false , resumeAfter = 0, isLive = defaultIsLive, chase = true , clock, maxStepMs = 250, onEvent = null , onState = null , now = defaultNow } = {})
把一個 take 在對的時間播回匯流排,讓作品、它的聲音和和弦偵測器分不出這是播放還是你在彈。
給它一個匯流排和一個 take,呼叫 play(),它就會在對的時間把 take 的事件發布到匯流排上:跟鍵盤會發的訊號一樣,所以作品的畫面、它的聲音(Sound )和和弦偵測器的反應,跟面對你的雙手時完全一樣。這就是作品的預覽可以自己演奏的原因。傳 clock: 'manual' 並在你的迴圈裡呼叫 player.update(dt),就能完全自己掌控。
絕不會卡音:播放器記得它按下的每個鍵(用它按下時的寫法)和延音踏板,並在暫停、停止、跳轉和每次循環回到開頭的時候放開它們。有 yieldToLive 的話,你自己彈一個鍵,take 就會安靜下來。有 resumeAfter: 8 的話,你停手 8 秒後它會回來。它的狀態是 'playing'、'paused'、'yielded' 或 'stopped',position(毫秒)、loops、playing、yielded 是普通屬性。
參數 名稱 型別 說明 signalsSignals要發布訊號的 Signals 匯流排。它也會定義 take/playing(0 或 1)和 take/position(0..1)。 可省略 預設 null takeObject | String一個 take 或它的 JSON。之後可以用 load() 換別的。 可省略 預設 null loopBoolean到結尾就從頭再來。關掉的話,就停在結尾。 可省略 預設 true speedNumber播放速度:0.5 是一半的速度。 可省略 預設 1 yieldToLiveBoolean有人現場彈的時候就安靜下來:播放器沒送出的按鍵(硬體、螢幕鋼琴、手機)會暫停 take 並放開它的音。 可省略 預設 false resumeAfterNumber被讓位的 take 要等多少秒沒有現場演奏才恢復。0 表示維持暫停。 可省略 預設 0 isLiveFunctionisLive(name, value),什麼算現場演奏。預設是任何不是播放器送出的按鍵。旋鈕不算。 可省略 預設 any key-down chaseBooleanseek() 的時候,把那個時間點之前每個控制器的最後一個值再送一次(延音踏板、旋鈕),讓 take 處在原本該有的狀態。 可省略 預設 true clock'raf' | 'timer' | 'manual'誰來帶動時間:'raf'(requestAnimationFrame,頁面裡的預設)、'timer'(10 毫秒的計時器:對聲音比較準,背景分頁也會繼續只是比較慢),或 'manual'(你呼叫 update(dt) 或 tick(),沒有 requestAnimationFrame 的環境預設是這個)。 可省略 maxStepMsNumber一步最多往前走多久。遇到空檔(分頁凍結)的時候,take 會變慢,而不是一口氣打出一堆音。 可省略 預設 250 onEventFunctiononEvent({ t, name, value }, player),每個事件播出時都呼叫。 可省略 預設 null onStateFunctiononState(state, reason),狀態改變時呼叫:'playing'、'paused'、'yielded' 或 'stopped'。 可省略 預設 null nowFunction時鐘,單位是毫秒。 可省略 預設 performance.now
成員 名稱 說明 durationgetter Getter。載入的 take 的長度,單位是毫秒(沒有就是 0)。 progressgetter Getter。播到 take 的哪裡,0..1。 stategetter Getter。'playing'、'yielded'(讓位給現場演奏)、'paused'(開頭之後的任何位置,不循環的 take 播到結尾時也是)或 'stopped'(在開頭)。不循環的 take 播完時,onState 回呼仍然會說 'stopped',原因是 'end'。 load(take)method 換成另一個 take(會先停掉目前的並放開它的音),回到開頭。null 是清空。回傳播放器本身。 play({ from } = {})method 從目前的位置播,在結尾之後則從頭播,或用 play({ from }) 從 from 毫秒開始。沒有東西可以播(沒有 take 或是空的)就回傳 false,否則回傳 true。 pause(reason)method 停在目前的位置並放開正在響的音。回傳播放器本身。 stop()method 停止,倒回開頭並放開正在響的音。回傳播放器本身。 seek(ms)method 跳到 ms 毫秒(限制在 0 到長度之間),放開正在響的音,有 chase 的話會送出那個時間點的控制器數值。回傳播放器本身。 update(dt)method 前進 dt 秒。給 clock: 'manual' 用:在你的迴圈裡用距離上一個畫面的秒數呼叫它。 tick(nowMs = this.now())method 前進到時鐘時間 nowMs(預設 now()):raf 和 timer 時鐘呼叫的就是這個。第一次呼叫只會記下時間。 dispose()method 停止、放開每個音,並不再聽匯流排。
範例
js 複製
import { TakePlayer } from '@openav/record' ;
import { Signals } from '@openav/core' ;
const take = { v: 1, name: 'riff' , durationMs: 600, events: [
{ t: 100, name: 'midi/note/on' , value: { note: 60, vel: 0.9, ch: 1 } },
{ t: 400, name: 'midi/note/off' , value: { note: 60, ch: 1 } },
] };
const signals = new Signals();
const heard = [];
signals.on('midi/note/on' , (v) => heard.push('on ' + v.note));
signals.on('midi/note/off' , (v) => heard.push('off ' + v.note));
const player = new TakePlayer({ signals, take, loop: false , clock: 'manual' });
console.log(player.state, player.duration); // stopped 600
player.play();
for (let i = 0; i < 20; i++) player.update(0.05); // one second, in 50 ms steps
console.log(heard.join(', ' ), player.state); // on 60, off 60 paused
module 用手搖的時間把只有兩個事件的 take 播進匯流排:聽的人聽到音開始和結束,因為 loop 關著,播放器就停在 take 的結尾(state 是 'paused',position 是 600)。
js 複製
import { TakePlayer } from '@openav/record' ;
import { Signals } from '@openav/core' ;
const take = { v: 1, name: 'loop' , durationMs: 800, events: [
{ t: 0, name: 'midi/note/on' , value: { note: 64, vel: 0.8, ch: 1 } },
{ t: 600, name: 'midi/note/off' , value: { note: 64, ch: 1 } },
] };
const signals = new Signals();
const log = [];
signals.on('midi/note/on' , (v) => log.push('note ' + v.note));
const player = new TakePlayer({ signals, take, yieldToLive: true , resumeAfter: 1, clock: 'manual' ,
onState: (state, reason) => log.push(state + (reason ? ' (' + reason + ')' : '' )) });
player.play();
player.update(0.1);
signals.pulse('midi/note/on' , { note: 72, vel: 1, ch: 1 }); // someone plays live: the take gives way
log.push('live' );
for (let i = 0; i < 25; i++) player.update(0.05); // 1.25 s of silence: it comes back
console.log(log.join(' | ' ));
// playing | note 64 | note 72 | yielded (live) | live | playing (resume)
module 循環播放的 take 在有人現場按鍵時讓位,現場演奏停止一秒後又回來。log 顯示事件的順序。
相關 TakeRecorder 錄下你彈的東西:從 start() 到 stop() 聽匯流排上每個 midi/* 訊號,然後給你一個 take。 EventLog 把一次錄影過程中發生的事,例如音符和參數變化,連同時間記成一份清單,用的是跟錄影同一個時鐘。 fromMidiFile() 把標準 MIDI 檔讀成 take,裡面的事件就是鍵盤彈這個檔案時會發布的訊號。 noteOffFor() 給出能放開一個正在響的音的事件,用的是它當初被彈出來的那種寫法。 Sound 讓匯流排上每個 midi/note/on 和 midi/note/off 都透過發聲引擎聽得見,並把引擎的參數放到匯流排上,名稱是 sound/*。 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 import { TakePlayer } from '@openav/record'; packages/record/take-player.js 第 105 行
TakeRecorder#
類別 @openav/record new TakeRecorder({ signals, filter = /^midi\//, name = 'take' , meta = {}, overdub = false , throttleMs = 0, now = defaultNow } = {})
錄下你彈的東西:從 start() 到 stop() 聽匯流排上每個 midi/* 訊號,然後給你一個 take。
TakeRecorder 是一個聽匯流排上 midi/* 的 EventLog 。按下錄音:rec.start()。彈。按下停止:const take = rec.stop()。take 是單純的資料,所以可以存成 JSON、轉成 .mid(toMidiFile() ),或播回去(TakePlayer )。EventLog 的所有成員它都有(mark()、between()、toJSON()…)。
不會有卡住的音:在彈和弦的中間停止錄影,還按著的鍵(和還踩著的延音踏板)會在停止的時間,於 take 本身裡被放開。事件就是訊號被發布時的原樣,所以從硬體錄的 take 兩種名稱寫法(midi/note/on 和 midi/ch/1/note/60)和裝置名稱都在,播放時全部發布出去。把影片 Recorder 的 recorder.t0 傳給 start(),就能讓 take 跟影片對齊。
參數 名稱 型別 說明 signalsSignals要聽的 Signals 匯流排。 filterRegExp | Function哪些訊號算進 take:名稱的正規表達式,或 filter(name, value, meta)。 可省略 預設 /^midi\// nameStringtake 的名字。 可省略 預設 'take' metaObject複製進 take 的自由欄位:{ bpm, device, notes }。沒給裝置的話,會從事件裡找。 可省略 預設 {} overdubBoolean連 TakePlayer 正在播放的也錄下來。關著的時候,錄影器會忽略播放的內容。 可省略 預設 false throttleMsNumber對連續訊號(旋鈕、觸後):每個名字在這麼多毫秒內最多一個事件。 可省略 預設 0 nowFunction時鐘,單位是毫秒。 可省略 預設 performance.now
範例
js 複製
import { TakeRecorder } from '@openav/record' ;
import { Signals } from '@openav/core' ;
const signals = new Signals();
let clock = 0;
const rec = new TakeRecorder({ signals, now: () => clock, meta: { bpm: 100 } });
rec.start();
clock = 100; signals.pulse('midi/note/on' , { note: 60, vel: 0.9, ch: 1 });
clock = 400; signals.pulse('midi/note/off' , { note: 60, ch: 1 });
clock = 450; signals.pulse('midi/note/on' , { note: 62, vel: 0.5, ch: 1 }); // still held when we stop
clock = 600; signals.set ('audio/rms' , 0.2); // not a midi/* signal
const take = rec.stop();
console.log(take.events.map((e) => e.t + ' ' + e.name).join(' | ' ));
// 100 midi/note/on | 400 midi/note/off | 450 midi/note/on | 600 midi/note/off
console.log(take.durationMs, take.meta); // 600 { bpm: 100 }
module 用手動撥動的時鐘,從匯流排錄四個事件。最後一個音還按著,所以 take 在停止的時間把它放開。audio/rms 訊號不屬於這個 take。
相關 TakePlayer 把一個 take 在對的時間播回匯流排,讓作品、它的聲音和和弦偵測器分不出這是播放還是你在彈。 EventLog 把一次錄影過程中發生的事,例如音符和參數變化,連同時間記成一份清單,用的是跟錄影同一個時鐘。 toMidiFile() 把一個 take 寫成標準 MIDI 檔(type 0),任何 DAW 都能開。 trimSilence() 把一個 take 第一次按鍵之前的靜默剪掉,讓第一個音出現在你指定的時間。 Recorder 用一個 MediaRecorder 把一張畫布連同你給它的聲音錄成一個影片檔,所以畫面和聲音保持同步。 import { TakeRecorder } from '@openav/record'; packages/record/take-player.js 第 39 行
takeStats()#
函式 @openav/record takeStats(take)
數出一個 take 裡有什麼:音符數、事件數、長度、用到的頻道,以及最低和最高的音。
適合在播放或儲存一個 take 之前,顯示它是什麼。音符是透過 midiEventsOf() 數的,它只選一種名稱寫法,所以數字就是按過幾個鍵。
回傳 Object { notes, events, durationMs, channels, low, high }。notes 數的是按下的鍵,每個音只算一次,即使 take 裡有兩種寫法。channels 是排好序的 MIDI 頻道清單。low 和 high 是 MIDI 音符號碼,沒有音符的 take 是 null。
範例
js 複製
import { takeStats } from '@openav/record' ;
const take = { v: 1, name: 'riff' , durationMs: 1500, events: [
{ t: 500, name: 'midi/note/on' , value: { note: 60, vel: 0.8, ch: 1 } },
{ t: 900, name: 'midi/note/off' , value: { note: 60, ch: 1 } },
{ t: 1010, name: 'midi/note/on' , value: { note: 64, vel: 0.5, ch: 1 } },
{ t: 1400, name: 'midi/note/off' , value: { note: 64, ch: 1 } },
] };
console.log(takeStats(take)); // { notes: 2, events: 4, durationMs: 1500, channels: [1], low: 60, high: 64 }
module 兩個音符、四個事件、一個頻道,從 C4 到 E4。
相關 midiEventsOf() 把一個 take 讀成單純的 MIDI 事件(音符開關、控制器、彎音),只選一種名稱寫法,所以不會重複算。 normalizeTake() 把任何長得像 take 的東西,或它的 JSON 文字,整理成合格的 take:事件依時間排序、壞事件丟掉、沒有負數。 noteOf() 告訴你一個錄下來的事件是不是鍵按下去或放開,不管它用哪一種寫法,以及是哪個頻道的哪個音。 import { takeStats } from '@openav/record'; packages/record/take.js 第 223 行
toMidiFile()#
函式 @openav/record toMidiFile(take, { ppq = 960, bpm } = {})
把一個 take 寫成標準 MIDI 檔(type 0),任何 DAW 都能開。
一條音軌裡有音符開關(含力度)、控制器(延音踏板是 CC 64)、彎音,以及頻道和單音的觸後,各自在自己的頻道。頻道 10 還是鼓。take 的名字會變成音軌名稱,另外寫了 4/4 拍的拍號,只是讓 DAW 的格線看起來正常。不管速度是多少,時間都精確到毫秒,所以檔案播出來跟 take 一樣。只會用 take 裡的一種名稱寫法,所以不會重複(midiEventsOf() )。要存檔的話,把它包成型別是 audio/midi 的 Blob。
參數 名稱 型別 說明 takeObject這個 take。 ppqNumber每四分音符幾個 tick。 可省略 預設 960 bpmNumber寫進檔案的速度。沒給的話用 take.meta.bpm,再沒有就是 120。 可省略
範例
js 複製
import { toMidiFile, fromMidiFile } from '@openav/record' ;
const take = { v: 1, name: 'riff' , durationMs: 1500, meta: { bpm: 120 }, events: [
{ t: 500, name: 'midi/note/on' , value: { note: 60, vel: 0.8, ch: 1 } },
{ t: 900, name: 'midi/note/off' , value: { note: 60, ch: 1 } },
] };
const bytes = toMidiFile(take);
console.log(bytes.constructor.name, String.fromCharCode(...bytes.slice(0, 4))); // Uint8Array MThd
console.log(fromMidiFile(bytes).name); // riff
const a = document.getElementById('app' ).appendChild(document.createElement('a' ));
a.href = URL.createObjectURL(new Blob([bytes], { type: 'audio/midi' }));
a.download = 'riff.mid' ; a.textContent = 'Download riff.mid' ;
module 把只有兩個事件的 take 寫成 MIDI 檔,用名字確認讀得回來,並在頁面上提供下載連結。
import { toMidiFile } from '@openav/record'; packages/record/smf.js 第 46 行
trimSilence()#
函式 @openav/record trimSilence(take, { keep = 0 } = {})
把一個 take 第一次按鍵之前的靜默剪掉,讓第一個音出現在你指定的時間。
第一次按鍵之前的東西會被剪掉,但不會遺失:靜默裡設定的控制器數值,例如延音踏板或旋鈕,會保留,並移到開頭(剪掉的範圍內的會變成 t = 0),所以 take 開始時的狀態不變。如果 take 裡完全沒有按鍵,就整體移動,讓它的第一個事件落在 keep。
參數 名稱 型別 說明 takeObject這個 take。 keepNumber第一次按鍵之前要保留幾毫秒的空白。 可省略 預設 0
回傳 Object 一個新的 take。所有事件都提早同樣的量,durationMs 也縮短那麼多。一開始就有音的 take 會原樣回傳。
範例
js 複製
import { trimSilence } from '@openav/record' ;
const take = { v: 1, name: 'riff' , durationMs: 1500, events: [
{ t: 500, name: 'midi/note/on' , value: { note: 60, vel: 0.8, ch: 1 } },
{ t: 900, name: 'midi/note/off' , value: { note: 60, ch: 1 } },
] };
const t = trimSilence(take, { keep: 100 });
console.log(t.events.map((e) => e.t), t.durationMs); // [100, 500] 1100
module 一個晚了半秒才開始的 take 被往前移,讓第一個音出現在 100 毫秒。長度縮短被剪掉的 400 毫秒。
import { trimSilence } from '@openav/record'; packages/record/take.js 第 242 行
validateTake()#
函式 @openav/record validateTake(take)
檢查一個 take,把有問題的地方都列成看得懂的句子。空的清單表示沒問題。
它檢查:v 是 TAKE_VERSION ,name 是字串,durationMs 是 0 以上的數字,events 是陣列,每個事件都是有訊號 name 和 0 以上的 t 的物件,事件依時間排序而且沒有任何一個超過 durationMs,meta 如果有的話是物件。用在從檔案讀來的 take 上。要修復的話,用 normalizeTake() 。
回傳 String[] 像 v must be 1 (got 2) 或 events[3] is out of order (t 100 < 200) 這樣的句子。沒問題就是 []。它不會丟出錯誤。
範例
js 複製
import { validateTake } from '@openav/record' ;
const good = { v: 1, name: 'riff' , durationMs: 1500, events: [{ t: 500, name: 'midi/note/on' , value: { note: 60 } }] };
console.log(validateTake(good)); // []
console.log(validateTake({ v: 2, events: [{ t: -1, name: '' }] }));
// ["v must be 1 (got 2)", "name must be a string", "durationMs must be a number ≥ 0",
// "events[0].name must be a signal name", "events[0].t must be ms ≥ 0"]
console.log(validateTake(null )); // ["not an object"]
module 合格的 take 得到空清單。壞掉的會列出每個問題。根本不是物件的東西會直接這麼說。
import { validateTake } from '@openav/record'; packages/record/take.js 第 54 行
VIDEO_MIMES#
常數 @openav/record VIDEO_MIMES = array, 6 items
錄影器會依序嘗試的影片 MIME 型別,最好的在前:先是 H.264 加 AAC 的 MP4,再來是 WebM。
MP4 排第一,因為表演影片接下來要去的地方(剪輯軟體、Instagram、手機相簿)都能直接開,不用重新封裝,而且 Chrome 用硬體編 H.264。第一個字串指定了 profile(avc1.640033,High profile level 5.1,夠 4K 30 和 1080 乘 1920 60 用),因為 Chrome 對籠統的 avc1,mp4a 回答不行、對指定 profile 的卻回答可以,所以兩個都放進清單給別的瀏覽器用。單純的 video/mp4 是給 Safari 的。pickMime() 就是走過這份清單。
型別 String[]一份凍結的清單,有 6 個字串:video/mp4;codecs=avc1.640033,mp4a.40.2、video/mp4;codecs=avc1,mp4a、video/webm;codecs=vp9,opus、video/webm;codecs=vp8,opus、video/webm、video/mp4。
範例
js 複製
import { VIDEO_MIMES, pickMime } from '@openav/record' ;
console.log(VIDEO_MIMES.length, VIDEO_MIMES[0]); // 6 video/mp4;codecs=avc1.640033,mp4a.40.2
console.log(pickMime((m) => MediaRecorder.isTypeSupported(m)) || '(the browser chooses)' );
module 印出第一個候選,以及這個瀏覽器實際會用哪一個。
相關 AUDIO_MIMES 只錄聲音的檔案時,錄影器會依序嘗試的音訊 MIME 型別,最好的在前:先是 .m4a 的 AAC,再來是 Opus。 pickMime() 用瀏覽器自己的檢查,從清單裡挑出第一個瀏覽器錄得了的 MIME 型別。 extFor() 給一個錄好的 MIME 型別該用的副檔名:video/mp4 是 'mp4',audio/mp4 是 'm4a',不認識的是 'bin'。 Recorder 用一個 MediaRecorder 把一張畫布連同你給它的聲音錄成一個影片檔,所以畫面和聲音保持同步。 import { VIDEO_MIMES } from '@openav/record'; packages/record/mime.js 第 12 行
載入選項#
?oav_mic=click、window.OAV_P5 = { … }:oav-p5 怎麼起動。 28
oav-p5
?oav_clock=follow | off
外部的 MIDI 時鐘(來自 DAW、Strudel 或鼓機)要不要推動 drumPhase、drumBpm 和鼓機:follow 或 off。
設成 follow 的時候,外部的 MIDI 時鐘(每拍 24 個 tick,加上 start、stop 和 song position)會被跟隨:drumPhase 、beatPhase 、drumBar 、drumStep 、drumBpm 讀的就是它,已經在跑的鼓機也會從它取得速度和第一拍,跟著它開始和停止。OAVp5.clock 會告訴你它聽到什麼。沒有東西送時鐘過來的時候,什麼都不會改變。
off 會忽略所有時鐘訊息:OAVp5.clock.info.state 一直是 'none'。頁面上或機器上有東西在送時鐘、而你不想讓草圖跟著它走的時候用。
型別 String
可用的值 follow | off
預設 followoff 以外的任何值都當作 follow。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { clock: … }; 優先,其次是網頁網址上的 ?oav_clock=…,最後是 script 自己網址上的 ?clock=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_clock=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { clock: 'off' };
function setup() {
createCanvas(400, 300);
OAVp5.input([0xFA]); // MIDI start
setInterval(() => OAVp5.input([0xF8]), 20); // clock ticks: 24 a beat, here 50 a second
}
function draw() {
background(20);
fill(255);
text('clock option: ' + OAVp5.options.clock, 20, 30);
text('external clock: ' + OAVp5.clock.info.state, 20, 60); // none: the clock is ignored
}
草圖自己送出一個假的 MIDI 時鐘,設成 clock: 'off' 就被忽略:狀態一直是 "none"。把第一行刪掉,就會變成 "running"。
相關 OAVp5.clock oav-p5 聽到的外部 MIDI 時鐘:它的狀態、速度、現在在小節的哪裡。在別的程式或設備開始送時鐘之前什麼都沒有。 drumPhase 鼓機走到這一小節的哪裡,從 0 平滑地增加到快要 1,是你耳朵聽到的位置。 beatPhase 鼓機走到這一拍的哪裡,從 0 平滑地增加到快要 1,一小節會跑四次。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 link 加入一個房間:房間相同的 oav-p5 頁面,會透過中繼聽到彼此的程式送出的聲音。填房間名,空的就是不加入。 p5/oav-p5.js 第 155 行
?oav_compact=on | off
把 🎹 面板顯示成角落的一個小方塊,高度是視窗的三分之一,而不是整條橫幅:on 或 off。
一般來說,按 🎹 打開的面板是橫跨整個視窗的一條,最高到視窗的一半。設成 compact 就變成一個最寬 560 像素、高度是視窗三分之一的方塊,放在 🎹 所在的那個角落,旁邊的頁面還看得到。在 strudel.cc 上它是預設,因為程式編輯器需要那塊空間。
型別 String
可用的值 on | off
預設 off (on in Strudel)只有 on 會打開它。OAVp5.options.compact 讀回來是 true 或 false。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { compact: … }; 優先,其次是網頁網址上的 ?oav_compact=…,最後是 script 自己網址上的 ?compact=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_compact=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { compact: 'on' , panel: 'open' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('compact: ' + OAVp5.options.compact, 20, 30); // true
}
面板一開始就打開,是右下角的一個方塊,而不是橫跨整個視窗的一條。
相關 panel 🎹 面板一開始是收起來、打開,還是根本沒有:closed、open 或 off。 strudel oav-p5 要不要把這個頁面當成 Strudel(即時編碼編輯器),在它旁邊客氣一點:auto、on 或 off。 corner 🎹 按鈕、它的提示卡片和精簡面板放在視窗的哪一邊:right 或 left。 dock 🎹 按鈕和它的提示卡片放在視窗的哪個邊:bottom 或 top。有設定的話,面板一開始也在這個邊。 p5/oav-p5.js 第 152 行
?oav_corner=right | left
🎹 按鈕、它的提示卡片和精簡面板放在視窗的哪一邊:right 或 left。
把 🎹 移到左邊或右邊,它旁邊出現的卡片也跟著移動。scope() 畫在對面那一側,所以兩個不會碰在一起。在 openprocessing.org 上預設是 left,因為 OpenProcessing 自己的圖示在右上角。
型別 String
可用的值 right | left
預設 right (left on OpenProcessing)left 以外的任何值都當作 right。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { corner: … }; 優先,其次是網頁網址上的 ?oav_corner=…,最後是 script 自己網址上的 ?corner=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_corner=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { corner: 'left' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('corner: ' + OAVp5.options.corner + ' (look for the 🎹)' , 20, 30);
}
🎹 按鈕在視窗的左下角,不是右下角。
相關 dock 🎹 按鈕和它的提示卡片放在視窗的哪個邊:bottom 或 top。有設定的話,面板一開始也在這個邊。 panel 🎹 面板一開始是收起來、打開,還是根本沒有:closed、open 或 off。 compact 把 🎹 面板顯示成角落的一個小方塊,高度是視窗的三分之一,而不是整條橫幅:on 或 off。 scope() 把 value 顯示在網頁角落的小示波器上,並原樣還給你,所以可以包住任何運算式來觀察它。 p5/oav-p5.js 第 141 行
?oav_debug=1 | 0
在 console 印出 [oav-p5 debug] 開頭的幾行,關於點擊、🎹 按鈕和載入,用來查 🎹 為什麼沒反應。
🎹 放上頁面兩秒之後,會印出一行說頁面上的 🎹 是不是 oav-p5 自己的那一個,以及 oav-p5 從哪裡載入 OAV 的其他部分。之後每次點擊都多一行,記下指標的位置、點到了什麼、那個位置最上面是什麼,這就是找出是哪個頁面元素蓋在 🎹 上面的方法。面板打開、頁面把 🎹 換掉、是哪個名稱去要了麥克風,它也會說。
型別 String
可用的值 1 | 0
預設 '' (off)除了空的和 0,任何值都會打開它。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { debug: … }; 優先,其次是網頁網址上的 ?oav_debug=…,最後是 script 自己網址上的 ?debug=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_debug=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { debug: 1 };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('open the console, then click the canvas' , 20, 30); // [oav-p5 debug] lines appear
}
打開 console:載入兩秒之後會有一行 debug 說到 🎹,之後每點一下畫布就多一行,說指標底下是什麼。
相關 hints oav-p5 要不要在 🎹 旁邊顯示說明的卡片(麥克風被擋、沒有 Web MIDI、你的回呼出錯):on 或 off。 panel 🎹 面板一開始是收起來、打開,還是根本沒有:closed、open 或 off。 OAVp5.check() 執行 🩺 體檢:版本、頁面、瀏覽器、麥克風、MIDI、聲音、鼓機、錄影、接線和提示,做成一張可以截圖的卡片,也回傳成物件。 p5/oav-p5.js 第 158 行
?oav_dock=bottom | top
🎹 按鈕和它的提示卡片放在視窗的哪個邊:bottom 或 top。有設定的話,面板一開始也在這個邊。
把 🎹、它的卡片和面板移到上緣或下緣。在 openprocessing.org 上預設是 top,因為 OpenProcessing 的 console 會浮在草圖的下方。鼓機的卡片會放在固定在上緣的面板下面。
面板上有兩個按鈕給正在看的人用:「位置」依序切換下方、上方、浮動(浮動時拖曳面板上面那一列就能移動),「大小」依序切換小、中、大。這個瀏覽器會記住你的選擇,而且優先於這個選項。草圖沒有設定 dock 時,面板一開始在下方,openprocessing.org 上也一樣(2026-10-20 起,之前放在上方會蓋住草圖)。只有面板和 🎹 在同一個邊時,🎹 才會讓開。同一個頁面重新整理(OpenProcessing 按 Cmd+Enter)時,面板原本開著就會自動打開,從面板 🥁 開的鼓機也會用同一行節奏和速度再打起來。
型別 String
可用的值 bottom | top
預設 bottom (top on OpenProcessing)top 以外的任何值都當作 bottom。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { dock: … }; 優先,其次是網頁網址上的 ?oav_dock=…,最後是 script 自己網址上的 ?dock=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_dock=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { dock: 'top' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('dock: ' + OAVp5.options.dock + ' (look for the 🎹)' , 20, 30);
}
🎹 按鈕在視窗的右上角,不是右下角。
相關 corner 🎹 按鈕、它的提示卡片和精簡面板放在視窗的哪一邊:right 或 left。 panel 🎹 面板一開始是收起來、打開,還是根本沒有:closed、open 或 off。 compact 把 🎹 面板顯示成角落的一個小方塊,高度是視窗的三分之一,而不是整條橫幅:on 或 off。 p5/oav-p5.js 第 140 行
?oav_drumlatency=auto | 0 to 500 (milliseconds)
鼓點從 oav-p5 交給音訊時鐘到你聽到要多久,讓畫面跟聲音同時到:auto 或毫秒數。
鼓機把每一下鼓點稍微提前交給音訊時鐘,並在你會聽到的那一刻通知草圖(drumHit() 、kickDetected() 、env('kick') ),那一刻就是排定的時間加上輸出的延遲。auto 用瀏覽器回報的延遲。瀏覽器回報的數字如果比你的輸出實際花的時間小,畫面就會跑在聲音前面,自己給一個數字就能解決。專案的 README 舉的例子是藍牙喇叭,並建議試試 200。
型別 String | Number
可用的值 auto | 0 to 500 (milliseconds)
預設 auto數字會被限制在 0 到 500 之間。不是數字的值都當作 auto。OAVp5.options.drumlatency 讀回來的單位是秒(200 讀回 0.2)。
注意 只有預設的鼓機(drums=lookahead)會用到它:逐幀推進的那一種沒有提前排程的東西。 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { drumlatency: … }; 優先,其次是網頁網址上的 ?oav_drumlatency=…,最後是 script 自己網址上的 ?drumlatency=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_drumlatency=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { drumlatency: 200 };
function setup() {
createCanvas(400, 300);
startDrums('bd*4, [~ sd]*2, hh*8' , 108);
}
function draw() {
background(20);
fill(255);
text('drumlatency: ' + OAVp5.options.drumlatency + ' s' , 20, 30);
circle(width / 2, height / 2, 50 + env('kick' , 0.01, 0.3) * 200); // told 0.2 s after the hit is scheduled
}
圓在每一下大鼓脹大,通知的時間是鼓點交給音訊時鐘 200 毫秒之後,配合一個正好慢那麼多的喇叭。如果沒聽到聲音,點一下畫布。
待確認
沒有用藍牙喇叭試過:建議的 200 來自專案的 README,不是為這一頁量過的。
相關 drums 鼓機用什麼來計時:lookahead(提前在音訊時鐘上排程)或 frame(跟著動畫畫面走,0.5 版的做法)。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 drumHit() 一個由你定義的事件函式:鼓機每打一下就會呼叫它,告訴你是哪一種鼓、打得多用力。 env() 一個 0 到 1 的脈衝:每次事件(大鼓、拍子、琴鍵、按下)之後就跳起來,然後慢慢退下去(TouchDesigner 的 Trigger CHOP)。 p5/oav-p5.js 第 135 行
?oav_drums=lookahead | frame
鼓機用什麼來計時:lookahead(提前在音訊時鐘上排程)或 frame(跟著動畫畫面走,0.5 版的做法)。
設成 lookahead 的時候,Web Worker 裡有一個心跳,會把鼓點提前一點交給音訊時鐘,所以畫面卡頓或分頁很忙的時候,拍子還是穩的,草圖也會在每一下鼓點被聽到的時候收到通知。設成 frame 則是回到 oav-p5 0.5 的做法,由動畫畫面推著鼓機走。這是保險:提前排程的鼓機在某個地方出狀況的時候,frame 把舊的做法找回來,這時拍子只會跟畫面更新率一樣穩。
型別 String
可用的值 lookahead | frame
預設 lookaheadframe 以外的任何值都當作 lookahead。
注意 OAVp5.drums.clock 和 🩺 體檢會告訴你現在是哪一種時鐘在跑。要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { drums: … }; 優先,其次是網頁網址上的 ?oav_drums=…,最後是 script 自己網址上的 ?drums=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_drums=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { drums: 'frame' };
function setup() {
createCanvas(400, 300);
startDrums('bd*4, [~ sd]*2, hh*8' , 108);
}
function draw() {
background(20);
fill(255);
text('drums: ' + OAVp5.options.drums + ' (clock: ' + OAVp5.drums.clock.clock + ')' , 20, 30);
circle(width / 2, height / 2, 50 + env('kick' , 0.01, 0.3) * 200);
}
鼓機由動畫畫面推著走,文字也這麼說。如果沒聽到聲音,點一下畫布。
相關 drumlatency 鼓點從 oav-p5 交給音訊時鐘到你聽到要多久,讓畫面跟聲音同時到:auto 或毫秒數。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 OAVp5.check() 執行 🩺 體檢:版本、頁面、瀏覽器、麥克風、MIDI、聲音、鼓機、錄影、接線和提示,做成一張可以截圖的卡片,也回傳成物件。 p5/oav-p5.js 第 134 行
?oav_hardware=auto | click | off
oav-p5 什麼時候向瀏覽器要 Web MIDI(真的控制器需要它):auto 是載入時,click 是 🎹 面板打開時,off 是不要。
設成 auto 的時候,oav-p5 一載入就去要 Web MIDI,所以接上去的控制器不用誰按什麼就能彈草圖。瀏覽器會依網站記住你的答案。設成 click,只有第一次打開 🎹 面板時才去要。設成 off,它自己永遠不會去要,螢幕上的控制器和手機還是能彈草圖。
不管這個選項怎麼設,OAVp5.connectHardware() 都會去要,OAVp5.hardware 告訴你現在的狀態:還沒要過之前是 'off'。
型別 String
可用的值 auto | click | off
預設 auto (click in Strudel)只有 auto 和 click 會去要。其他的值效果跟 off 一樣。
注意 在 strudel.cc(或頁面上本來就有 Strudel 自己的函式)預設是 click,因為 Strudel 的 midin() 會自己去要 MIDI。你自己設了 hardware 就以你的為準。 Safari 和 iPad 根本沒有 Web MIDI:oav-p5 去要的時候(或你呼叫 OAVp5.connectHardware()),那裡的狀態就會變成 'unavailable'。 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { hardware: … }; 優先,其次是網頁網址上的 ?oav_hardware=…,最後是 script 自己網址上的 ?hardware=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_hardware=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { hardware: 'click' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('hardware option: ' + OAVp5.options.hardware, 20, 30);
text('Web MIDI state: ' + OAVp5.hardware, 20, 60); // stays "off" until you open the 🎹 panel
}
載入的時候頁面不會去要 Web MIDI,狀態一直是 "off"。按 🎹:這會打開面板並去要。把第一行刪掉,就會一載入就去要。
相關 OAVp5.hardware Web MIDI 目前的情形:'off'、'asking'、'connected'、'waiting'、'denied' 或 'unavailable'。 OAVp5.connectHardware() 現在就向瀏覽器要 Web MIDI,並回傳一個 promise,告訴你聽不聽得到真的控制器。 OAVp5.devices 插著的 MIDI 控制器的名稱,照瀏覽器回報的樣子:一個文字陣列,沒有的時候是空的。 panel 🎹 面板一開始是收起來、打開,還是根本沒有:closed、open 或 off。 strudel oav-p5 要不要把這個頁面當成 Strudel(即時編碼編輯器),在它旁邊客氣一點:auto、on 或 off。 p5/oav-p5.js 第 126 行
?oav_hints=on | off
oav-p5 要不要在 🎹 旁邊顯示說明的卡片(麥克風被擋、沒有 Web MIDI、你的回呼出錯):on 或 off。
出問題的時候,🎹 旁邊會跳出一張小卡片,用瀏覽器的語言說明發生了什麼、為什麼、要按什麼,每個頁面只說一次。hints: 'off' 讓卡片從頁面上消失:console 還是說一樣的話,OAVp5.hints 也還是列出說過的內容。做好的作品要放在展場牆上或錄影的時候用。
它也會把 scope() 的角落示波器藏起來,因為那是同一套網頁上的輔助。scope=off 只藏它。
型別 String
可用的值 on | off
預設 on只有 off 會把卡片關掉。其他任何值,包括文字 false,都讓卡片保持開著。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { hints: … }; 優先,其次是網頁網址上的 ?oav_hints=…,最後是 script 自己網址上的 ?hints=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_hints=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { hints: 'off' };
function setup() {
createCanvas(400, 300);
lag(1); // an unnamed lag() outside draw(): oav-p5 has something to say about that
}
function draw() {
background(20);
fill(255);
text('cards said: ' + OAVp5.hints.length + ' (none on the page)' , 20, 30);
text(OAVp5.hints.map((h) => h.what).join('\n' ), 20, 60);
}
oav-p5 注意到 setup() 裡的 lag(),平常會顯示一張卡片。設成 hints off,頁面上什麼都不會出現,但 console 和 OAVp5.hints 的清單裡還是有。
window.OAV_P5 = { hints: false }; // not 'off': the value is turned into the text "false"
function setup() {
createCanvas(400, 300);
lag(1);
}
function draw() {
background(20);
fill(255);
text('the card is still beside the 🎹' , 20, 30);
}
常見的陷阱:false 不等於 'off',所以卡片還是會出現。
相關 OAVp5.hints oav-p5 到現在顯示過(或本來會顯示)的卡片清單(麥克風被擋、沒有 Web MIDI、你的回呼出錯),每張都寫著發生什麼、為什麼、要按什麼。 scope scope() 要不要在網頁角落畫它的示波器:on 或 off。hints=off 也會把它關掉。debug 在 console 印出 [oav-p5 debug] 開頭的幾行,關於點擊、🎹 按鈕和載入,用來查 🎹 為什麼沒反應。 OAVp5.check() 執行 🩺 體檢:版本、頁面、瀏覽器、麥克風、MIDI、聲音、鼓機、錄影、接線和提示,做成一張可以截圖的卡片,也回傳成物件。 p5/oav-p5.js 第 1076 行
?oav_link=a room name
加入一個房間:房間相同的 oav-p5 頁面,會透過中繼聽到彼此的程式送出的聲音。填房間名,空的就是不加入。
房間相同的 oav-p5 頁面(另一個分頁、另一台電腦),會透過手機中繼收到這個頁面的程式送進虛擬 MIDI 輸出的東西(音符、控制器和時鐘),並在這裡聽到的那一刻播出來。收到的那一邊,OAVp5.last.source 是 'link'。收到的東西不會再往外送,所以兩個頁面不會互相繞圈。房間也是手機用的房間,所以一個 QR code 就能彈每一個連起來的頁面。
OAVp5.link.info 告訴你房間、有沒有連上、聽到幾個其他頁面。🎹 會多一個 🔗:另一個頁面在的時候是綠色,連線中或只有自己是黃色,中繼斷線是紅色。linkdelay 可以多加一段緩衝。
型別 String
可用的值 a room name
預設 '' (no room)只留下字母、數字、_ 和 -,而且只取前 32 個字元。
要先做什麼才有值
要能連上中繼(relay ,沒另外設的話是 wss://remote.cheyuwu.com/relay)。連不上的話 🔗 是紅色,什麼都不會分享。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { link: … }; 優先,其次是網頁網址上的 ?oav_link=…,最後是 script 自己網址上的 ?link=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_link=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
js 複製
window.OAV_P5 = { link: 'my-room' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
const info = OAVp5.link.info;
text('room: ' + OAVp5.options.link, 20, 30);
text('state: ' + (info ? info.state + ', ' + info.peers + ' other page(s)' : 'not linked' ), 20, 60);
}
static 在兩個分頁打開同一個網址:兩邊都顯示房間,連上之後顯示有另一個頁面。一個分頁的程式送進 OAV Virtual MIDI 的東西,另一個分頁也聽得到。
待確認
無頭瀏覽器的檢查連不到中繼,所以這個範例沒有跑。兩個頁面之間的行為是讀程式碼和專案的 README 得知的,這裡沒有親眼看過。
相關 OAVp5.link 和同一個房間裡其他 oav-p5 頁面之間的連結現在的情形:只有 OAVp5.link.info,沒有設房間的時候是 null。 OAVp5.link.info 連結的狀態:它的房間、state(connecting、open、closed)、有幾個其他頁面在、還有來回的時間。沒有設房間時是 null。 linkdelay 連在一起的頁面收到音符時多加的緩衝毫秒數,用來撫平不穩的網路:0 到 2000。 relay 手機和 link 房間用來傳話的中繼的位址:一個 WebSocket 網址。 room 手機要加入哪個房間才能彈這個頁面:預設是每個瀏覽器自己亂數取的一個。 p5/oav-p5.js 第 156 行
?oav_linkdelay=0 to 2000 (milliseconds)
連在一起的頁面收到音符時多加的緩衝毫秒數,用來撫平不穩的網路:0 到 2000。
只有跟 link 一起用才有意義:沒有房間就不會收到任何東西。從另一個頁面來的每個音符,會在它在那邊響起的那一刻播出,這已經把路上花的時間算進去了。linkdelay 在那之上再多加這麼多,所以走得比平常久的音符也還是準時。代價是另一個頁面來的所有東西都晚這麼多才到。
型別 Number
可用的值 0 to 2000 (milliseconds)
預設 0會被限制在 0 到 2000 之間。不是數字的值就是 0。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { linkdelay: … }; 優先,其次是網頁網址上的 ?oav_linkdelay=…,最後是 script 自己網址上的 ?linkdelay=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_linkdelay=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { linkdelay: 80 };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('linkdelay: ' + OAVp5.options.linkdelay + ' ms' , 20, 30);
text('link: ' + (OAVp5.link.info ? 'room ' + OAVp5.link.info.room : 'no room, so nothing to delay' ), 20, 60); // add link: 'my-room' to use it
}
沒有 link 房間的時候,這個數字只是存著:草圖說沒有東西可以延遲。在選項裡加上 link: 'my-room',就會套用到每個收到的音符。
相關 link 加入一個房間:房間相同的 oav-p5 頁面,會透過中繼聽到彼此的程式送出的聲音。填房間名,空的就是不加入。 OAVp5.link.info 連結的狀態:它的房間、state(connecting、open、closed)、有幾個其他頁面在、還有來回的時間。沒有設房間時是 null。 relay 手機和 link 房間用來傳話的中繼的位址:一個 WebSocket 網址。 p5/oav-p5.js 第 157 行
?oav_mic=auto | click | off
誰可以把麥克風打開:auto(第一次讀 micLevel 這些)、click(只有 🎤 或 startMic())或 off(誰都不行)。
設成 auto,草圖第一次讀 micLevel 、bass 、mid、treble 或 beat 的時候,oav-p5 就會向瀏覽器要麥克風。設成 click,讀它們不會有任何作用:只有有人在 🎹 面板按 🎤、或草圖呼叫 startMic() 時麥克風才會開,這樣權限的詢問會在你決定的時候才跳出來。設成 off,什麼都不能開麥克風,連 startMic() 也不行,它會 resolve 成 false。
off 是對頁面的一個承諾:一件絕對不能收音的展場作品可以這樣宣告。click 適合不該憑空跳出權限詢問的作品,或是聲音要等片頭之後才有意義的作品。麥克風關著的時候,它的所有值都是 0。
型別 String
可用的值 auto | click | off
預設 auto其他的值都當作 auto。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { mic: … }; 優先,其次是網頁網址上的 ?oav_mic=…,最後是 script 自己網址上的 ?mic=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_mic=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { mic: 'click' };
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
startMic(); // now the browser asks
}
function draw() {
background(20);
circle(width / 2, height / 2, 50 + micLevel * 300); // reading micLevel no longer asks
fill(255);
text('microphone: ' + OAVp5.mic.state + ' (click the canvas to start it)' , 20, 30);
}
讀 micLevel 不會去要麥克風:狀態一直是 "off",圓一直很小。點一下畫布,瀏覽器才會詢問。 會要求麥克風
window.OAV_P5 = { mic: 'off' };
let answer = 'click the canvas to try' ;
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
startMic().then((ok) => { answer = 'startMic() resolved ' + ok; });
}
function draw() {
background(20);
fill(255);
text('microphone: ' + OAVp5.mic.state, 20, 30);
text(answer, 20, 60); // startMic() resolved false
}
什麼都不能開麥克風:狀態一直是 "off",startMic() resolve 成 false。
待確認
還沒有在 openprocessing.org 本站實測:頁面網址上的 ?sketch_oav_mic=click 有沒有傳到草圖。
p5/oav-p5.js 第 128 行
?oav_opc=on | off
oav-p5 要不要把控制器接到 OP Configurator(OpenProcessing 的滑桿和按鈕)的變數上:on 或 off。
在 OpenProcessing 上,OPC.slider('radius', 10) 宣告一個有滑桿的變數。opc 開著的時候,oav-p5 會依序把控制器接到 OPC 的變數:旋鈕 1 接第一個滑桿,旋鈕 2 接第二個,推桿接在旋鈕之後,打擊墊 1 接第一個開關或按鈕。OPC 自己的面板會跟著旋鈕動。OAVp5.wiring() 會列出誰推動什麼。
off 讓 OPC 的變數只聽自己的滑桿。草圖自己有讀的控制(draw() 裡的 knob1)本來就歸草圖:那個位置的滑桿不再跟著它動。
型別 String
可用的值 on | off
預設 on只有 off 會把它關掉。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { opc: … }; 優先,其次是網頁網址上的 ?oav_opc=…,最後是 script 自己網址上的 ?opc=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_opc=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { opc: 'off' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('opc option: ' + OAVp5.options.opc, 20, 30);
text('controls wired to OPC variables: ' + OAVp5.wiring().length, 20, 60); // 0
}
在 OpenProcessing 以外沒有 OPC,所以不管怎麼設都是 0。在 OpenProcessing 上宣告了滑桿的話,設成 opc: 'off' 是 0,沒設就是滑桿和按鈕的數量。
待確認
沒有用 OpenProcessing 真正的 OPC 函式庫試過。接線是拿一個有同樣 options 結構的替身物件檢查的,這裡的範例在沒有 OPC 的地方跑,所以只能看到選項有送達。
相關 OAVp5.wiring() 列出現在哪一顆旋鈕、推桿或墊子在推動哪一個 OpenProcessing OPC 變數:[{ control: 'knob1', name: 'radius' }, …]。 knob1 控制器上旋鈕的位置,從 0 到 1:knob1 是第一顆旋鈕,knob2 是第二顆,一直到 knob8。 knob() 回傳第 n 顆旋鈕的位置,0 到 1:跟 knob1 到 knob8 一樣,但 n 可以是任何數字。 pad1 打擊墊被按得多用力,從 0 到 1:pad1 是第一個墊子,一直到 pad16。放開就是 0。 p5/oav-p5.js 第 127 行
?oav_panel=closed | open | off
🎹 面板一開始是收起來、打開,還是根本沒有:closed、open 或 off。
closed 只顯示小小的 🎹 按鈕,按下去才打開面板:螢幕上的控制器,加上 🎤、🔊、🥁、⏺、📷、📱、🩺 這些按鈕。open 在頁面一載入就打開面板,適合工作坊,每個人一開始就需要螢幕上的控制器。off 把 🎹 按鈕整個拿掉,適合展場裡的作品:沒有按鈕,OAVp5.panel.open() 也什麼都不做。說明問題的卡片還是會出現在角落,除非 hints 也設成 off。
型別 String
可用的值 closed | open | off
預設 closedopen 和 off 以外的任何值都當作 closed。
注意 設成 off 之後,面板能做的事還是都能用程式做:startMic()、startSound()、startRecording() 和各個選項照常運作,出問題時 console 也還是會說。 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { panel: … }; 優先,其次是網頁網址上的 ?oav_panel=…,最後是 script 自己網址上的 ?panel=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_panel=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { panel: 'open' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('panel: ' + OAVp5.options.panel, 20, 30);
}
頁面一載入,🎹 面板就是打開的:螢幕上的控制器和它的按鈕。
window.OAV_P5 = { panel: 'off' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('panel: ' + OAVp5.options.panel + ' (no 🎹 on this page)' , 20, 30);
}
完全沒有 🎹 按鈕。草圖還是聽得到真的控制器和其他一切。
相關 OAVp5.panel 把 🎹 面板當成物件:用 open()、close()、toggle() 從程式裡顯示和收起它。 OAVp5.panel.open() 打開 🎹 面板(open())、收起它(close()),或是切換(toggle())。 compact 把 🎹 面板顯示成角落的一個小方塊,高度是視窗的三分之一,而不是整條橫幅:on 或 off。 dock 🎹 按鈕和它的提示卡片放在視窗的哪個邊:bottom 或 top。有設定的話,面板一開始也在這個邊。 corner 🎹 按鈕、它的提示卡片和精簡面板放在視窗的哪一邊:right 或 left。 p5/oav-p5.js 第 126 行
?oav_profile=a profile id
螢幕上的 🎹 顯示哪一種控制器,用設定檔 id 指定,例如 akai-lpd8。不指定的話,它跟著插上去的那一個。
🎹 面板上的螢幕控制器會畫出這一台設備,knob1、fader1、pad1 指的就是它上面印的第一顆旋鈕、第一支推桿、第一個墊子。例如照著 LPD8 寫的草圖,可以用 profile: 'akai-lpd8' 指定它,沒有 LPD8 的人螢幕上也有一台。面板裡的選單可以手動換。
你不設的話,面板一開始是 MiniLab 3,一旦插上 OAV 有設定檔的真控制器,就改成跟著它。你設了的話,就維持你的選擇。不存在的 id 會在 console 回報,並列出已知的 id,面板也不會顯示控制器。
型別 String
可用的值 a profile id
預設 arturia-minilab3@openav/midi 有設定檔的任何 id:arturia-minilab3、akai-lpd8、korg-nanokontrol2、generic-keys49 等等。你不設的話,面板跟著插上去的那一台控制器。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { profile: … }; 優先,其次是網頁網址上的 ?oav_profile=…,最後是 script 自己網址上的 ?profile=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_profile=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { profile: 'akai-lpd8' , panel: 'open' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('profile: ' + OAVp5.options.profile, 20, 30);
circle(width / 2, height / 2, 40 + knob1 * 240); // knob 1 of the LPD8
}
面板打開,螢幕上是一台 Akai LPD8:八個墊子、八顆旋鈕。轉它的第一顆旋鈕,圓就變大。
相關 knob1 控制器上旋鈕的位置,從 0 到 1:knob1 是第一顆旋鈕,knob2 是第二顆,一直到 knob8。 fader1 控制器上推桿(滑桿)的位置,從 0 到 1:fader1 是第一支推桿,一直到 fader8。 pad1 打擊墊被按得多用力,從 0 到 1:pad1 是第一個墊子,一直到 pad16。放開就是 0。 panel 🎹 面板一開始是收起來、打開,還是根本沒有:closed、open 或 off。 OAVp5.input() 用位元組手動送一則 MIDI 訊息給 oav-p5:整個草圖的反應就像是控制器送出來的。 p5/oav-p5.js 第 126 行
?oav_recfps=1 to 60
錄下的影片的畫面更新率,每秒 1 到 60 格。
錄影時每秒從畫布取幾格,給 startRecording() 和 ⏺ 按鈕用。30 適合 Instagram 和 YouTube 接受的大多數場合。60 對快速的動作比較順,檔案也比較大。單一次錄影也可以自己指定:startRecording({ fps: 60 })。
型別 Number
可用的值 1 to 60
預設 30會四捨五入成整數,並限制在 1 到 60 之間。不是數字的值就當作 30。
注意 它可以用 OAVp5.options.recfps 和 OAVp5.record.fps 讀回來。 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { recfps: … }; 優先,其次是網頁網址上的 ?oav_recfps=…,最後是 script 自己網址上的 ?recfps=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_recfps=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { recfps: 60 };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('recording frame rate: ' + OAVp5.record.fps, 20, 30);
}
這個頁面錄下的影片是每秒 60 格,不是 30 格。
p5/oav-p5.js 第 132 行
?oav_reclimit=1 or more (seconds)
一次錄影最長可以錄多久,單位是秒:錄到這裡就會自己停下來。
太長的影片會把分頁的記憶體吃光,所以每次錄影都會在這個長度自己停下來,存下已經錄到的,並跳出一張卡片說明。長的作品就把它調高,或是分段錄,也可以替單次錄影指定長度:startRecording(10) 十秒後停下,超過上限的數字會被砍到上限。
型別 Number
可用的值 1 or more (seconds)
預設 300至少是 1。不是數字的值就當作 300(五分鐘)。
注意 它可以用 OAVp5.options.reclimit 和 OAVp5.record.limit 讀回來。 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { reclimit: … }; 優先,其次是網頁網址上的 ?oav_reclimit=…,最後是 script 自己網址上的 ?reclimit=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_reclimit=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { reclimit: 60 };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('longest take: ' + OAVp5.record.limit + ' seconds' , 20, 30);
}
這個頁面錄的影片,錄滿一分鐘就會自己停。
p5/oav-p5.js 第 133 行
?oav_record=on | nomic | off
錄影能不能用、錄影要不要包含麥克風:on、nomic 或 off。
設成 on,⏺ 和 📷 都在 🎹 面板裡,startRecording() 會把畫布和草圖發出的聲音一起錄下來,麥克風開著的話也包含在內。nomic 讓麥克風不進錄影。麥克風收進去又沒戴耳機的話,喇叭的聲音會透過麥克風被錄第二次,所以大聲放聲音的時候把它關掉。off 把 ⏺ 和 📷 拿掉,讓 startRecording() 什麼都不做。
off 跟 mic=off 一樣是對頁面的承諾:一件不能被錄下來的作品可以這樣宣告。單次錄影還是可以自己決定:startRecording({ mic: false })。
型別 String
可用的值 on | nomic | off
預設 on其他的值都當作 on。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { record: … }; 優先,其次是網頁網址上的 ?oav_record=…,最後是 script 自己網址上的 ?record=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_record=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { record: 'nomic' };
function setup() {
createCanvas(400, 300);
}
function keyPressed() {
if (key === 'r' ) startRecording(3); // three seconds, without the microphone
}
function draw() {
background(20 + 20 * sin(frameCount * 0.05));
fill(255);
text('press r to record 3 seconds (' + (recordingIsOn ? 'recording' : 'idle' ) + ')' , 20, 30);
}
按 r:錄下三秒的畫布並下載,就算麥克風開著也不會錄進去。
p5/oav-p5.js 第 131 行
?oav_relay=a ws:// or wss:// address
手機和 link 房間用來傳話的中繼的位址:一個 WebSocket 網址。
📱 按鈕會顯示一個 QR code,讓手機打開遙控頁面,手機的 MIDI 分頁就是透過這個中繼來彈你的草圖。link 的房間也用它。預設是專案的公開中繼。想讓資料只留在自己的機器上,就從儲存庫的副本執行 node packages/relay/server.js,再把 relay 指到 ws://localhost:7458/relay。
型別 String (URL)
可用的值 a ws:// or wss:// address
預設 wss://remote.cheyuwu.com/relay照你給的使用。
注意 載入的時候不會連它。要打開 📱 卡片(OAVp5.phone() )或設了 link 才會用到。 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { relay: … }; 優先,其次是網頁網址上的 ?oav_relay=…,最後是 script 自己網址上的 ?relay=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_relay=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { relay: 'ws://localhost:7458/relay' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('relay: ' + OAVp5.options.relay, 20, 30); // used when you open 📱 or set link
}
草圖只印出現在用的位址:手機或連在一起的頁面,現在會連到你自己電腦上的中繼。
相關 room 手機要加入哪個房間才能彈這個頁面:預設是每個瀏覽器自己亂數取的一個。 remote 📱 的 QR code 在手機上打開的那個遙控頁面的位址。 link 加入一個房間:房間相同的 oav-p5 頁面,會透過中繼聽到彼此的程式送出的聲音。填房間名,空的就是不加入。 OAVp5.phone() 顯示 📱 卡片:一個 QR code,讓手機打開遙控頁面,手機上的 MIDI 分頁就能彈這個草圖。回傳房間名稱。 p5/oav-p5.js 第 127 行
?oav_remote=an https:// address
📱 的 QR code 在手機上打開的那個遙控頁面的位址。
手機上的控制器就在那個頁面裡。QR code 和它下面的連結,是這個位址再加上中繼、房間和 MIDI 分頁。自己架遙控頁面的時候才需要改它,例如手機連不到公開網站的區域網路。
型別 String (URL)
可用的值 an https:// address
預設 https://openaudiovisual.com/packages/remote/照你給的使用,後面接上 ?relay=…&room=…&tab=midi。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { remote: … }; 優先,其次是網頁網址上的 ?oav_remote=…,最後是 script 自己網址上的 ?remote=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_remote=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { remote: 'http://192.168.0.10:8080/packages/remote/' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('remote page: ' + OAVp5.options.remote, 20, 30); // the 📱 QR code points here
}
草圖印出現在用的遙控頁面位址。按 🎹 再按 📱:QR code 和它下面的連結都指向那個位址。
相關 relay 手機和 link 房間用來傳話的中繼的位址:一個 WebSocket 網址。 room 手機要加入哪個房間才能彈這個頁面:預設是每個瀏覽器自己亂數取的一個。 OAVp5.phone() 顯示 📱 卡片:一個 QR code,讓手機打開遙控頁面,手機上的 MIDI 分頁就能彈這個草圖。回傳房間名稱。 p5/oav-p5.js 第 129 行
?oav_room=a room name
手機要加入哪個房間才能彈這個頁面:預設是每個瀏覽器自己亂數取的一個。
手機和頁面在 relay 上的一個房間碰面。留空的話,oav-p5 第一次會挑一個像 p5-k3x9a 的名字,存在這個瀏覽器裡(localStorage),所以重新整理之後同一個 QR code 還是有用。設了 link 的話,就用它的房間。想要一個大家打得出來、記得住的名字,例如一班一個,就自己設 room。
手機只需要房間名稱就能加入,所以如果這很重要,就取一個別人猜不到的名字。OAVp5.phone() 會回傳現在用的房間。
型別 String
可用的值 a room name
預設 '' (random per browser)留空的話,會自己取一個像 p5-k3x9a 的名字,存在這個瀏覽器裡。只留下字母、數字、_ 和 -,而且只取前 32 個字元。如果什麼都沒剩下,房間就叫 default。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { room: … }; 優先,其次是網頁網址上的 ?oav_room=…,最後是 script 自己網址上的 ?room=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_room=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { room: 'class-2026' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('room: ' + OAVp5.options.room, 20, 30); // the phones' QR code uses this room
}
草圖印出房間名稱。📱 卡片上現在顯示 class-2026,不是亂數的名字。
相關 relay 手機和 link 房間用來傳話的中繼的位址:一個 WebSocket 網址。 link 加入一個房間:房間相同的 oav-p5 頁面,會透過中繼聽到彼此的程式送出的聲音。填房間名,空的就是不加入。 remote 📱 的 QR code 在手機上打開的那個遙控頁面的位址。 OAVp5.phone() 顯示 📱 卡片:一個 QR code,讓手機打開遙控頁面,手機上的 MIDI 分頁就能彈這個草圖。回傳房間名稱。 p5/oav-p5.js 第 127 行
?oav_scope=on | off
scope() 要不要在網頁角落畫它的示波器:on 或 off。hints=off 也會把它關掉。
設成 off,scope() 不會在角落畫任何東西,只回傳它的值,所以演出的時候草圖可以把 scope() 留在程式裡,只是把圖藏起來。畫在畫布上的 drawTrail() 不受影響。
型別 String
可用的值 on | off
預設 on只有 off 會把它關掉。
注意 圖沒有出現的話,也要檢查 hints :hints=off 也會把它藏起來。 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { scope: … }; 優先,其次是網頁網址上的 ?oav_scope=…,最後是 script 自己網址上的 ?scope=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_scope=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { scope: 'off' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
const wave = 0.5 + 0.5 * sin(frameCount * 0.05);
circle(width / 2, height / 2, 40 + scope(wave, 'wave' ) * 200); // returns wave, draws nothing
}
圓還是一漲一縮,但網頁角落沒有圖。把第一行刪掉,圖就回來了。
相關 scope() 把 value 顯示在網頁角落的小示波器上,並原樣還給你,所以可以包住任何運算式來觀察它。 drawTrail() 在畫布上畫一條穿過 values 的線,所以它會在作品裡、也會被錄進影片:最舊的在 x,lo 在方框底,hi 在方框頂。 hints oav-p5 要不要在 🎹 旁邊顯示說明的卡片(麥克風被擋、沒有 Web MIDI、你的回呼出錯):on 或 off。 p5/oav-p5.js 第 3069 行
?oav_shim=on | off
oav-p5 要不要取代 navigator.requestMIDIAccess,讓自己寫了 Web MIDI 程式的草圖也聽得到 🎹 和手機:on 或 off。
設成 on,自己呼叫 navigator.requestMIDIAccess() 的草圖(WebMidi.js 就是)會拿到真的連接埠,再多一個叫 "OAV Virtual MIDI" 的輸入,螢幕控制器和手機彈的東西都從它進來。在沒有 Web MIDI 的 Safari 和 iPad 上也能用:草圖只拿到虛擬連接埠,所以 WebMidi.js 的草圖在那裡還是能跑。其他程式也可以把東西送進它的輸出(Strudel 的 .midi('OAV Virtual MIDI'))。
off 不動瀏覽器自己的 requestMIDIAccess。頁面本來就有自己的 MIDI 處理、不能多出一個連接埠的時候用。
型別 String
可用的值 on | off
預設 on只有 off 會把它關掉。
注意 順序很重要:oav-p5 要比要 MIDI 的程式先載入,這就是為什麼載入那一行要放在最前面。更早就開了 MIDI 的頁面會維持瀏覽器自己的答案,🩺 也會這麼說。 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { shim: … }; 優先,其次是網頁網址上的 ?oav_shim=…,最後是 script 自己網址上的 ?shim=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_shim=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { shim: 'off' };
let names = 'asking…' ;
function setup() {
createCanvas(400, 300);
navigator.requestMIDIAccess().then(
(access) => { names = [...access.inputs.values()].map((i) => i.name).join(', ' ) || 'no inputs' ; },
(error) => { names = 'refused: ' + error.name; }
);
}
function draw() {
background(20);
fill(255);
text('shim: ' + OAVp5.options.shim, 20, 30);
text('MIDI inputs: ' + names, 20, 60);
}
關掉 shim,草圖只會拿到瀏覽器自己的答案(真的輸入,或被拒絕)。把第一行刪掉,清單裡就多了 "OAV Virtual MIDI"。瀏覽器可能會問你要不要給 MIDI 權限。
相關 OAVp5.input() 用位元組手動送一則 MIDI 訊息給 oav-p5:整個草圖的反應就像是控制器送出來的。 hardware oav-p5 什麼時候向瀏覽器要 Web MIDI(真的控制器需要它):auto 是載入時,click 是 🎹 面板打開時,off 是不要。 midiMessage() 你來定義這個函式。每收到一則 MIDI 訊息,oav-p5 就呼叫它,帶著原始的位元組和它從哪裡來。 OAVp5.last 最近收到的音符、控制變化或音高輪訊息:它的種類、號碼、值、通道、來自哪裡、是哪個控制。在任何訊息之前是 null。 p5/oav-p5.js 第 127 行
?oav_sound=click | off | an instrument id
音符有沒有聲音:click(要呼叫 startSound() 或按 🔊 才有)、off(永遠沒有),或一個樂器 id,從第一次點擊就開始出聲。
設成 click,要等草圖呼叫 startSound() ,或有人在 🎹 面板按 🔊,才會有聲音。給一個樂器 id,每個琴鍵、打擊墊、螢幕控制器和手機,從頁面上的第一次點擊開始就都聽得到,一行程式都不用寫,所以任何 MIDI 草圖只要在網址加上 ?oav_sound=piano 就能彈。off 是對頁面的承諾:什麼都不發聲,startSound() 會 resolve 成 false,🔊 按鈕也會被藏起來。
瀏覽器要網頁被點過一下才讓它出聲,而 MIDI 的琴鍵不算點擊,所以第一個聲音要等一次點擊:在那之前會有一張卡片說「聲音準備好了,還沒響」。
型別 String
可用的值 click | off | an instrument id
預設 click樂器 id,像是 piano、organ、strings、harp 或 music-box。on 和 auto 會啟動預設的樂器(鋼琴)。不存在的 id 會播預設的合成器(synth/pad),console 會列出存在的 id。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { sound: … }; 優先,其次是網頁網址上的 ?oav_sound=…,最後是 script 自己網址上的 ?sound=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_sound=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { sound: 'organ' };
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
OAVp5.play(60 + floor(random(12)), 0.8); // a key, as a controller would play it
}
function draw() {
background(20);
fill(255);
text('sound: ' + OAVp5.sound.state + ' (' + OAVp5.sound.instrument + ')' , 20, 30);
text('click the canvas to play a note' , 20, 60);
}
沒有呼叫 startSound():從你的第一次點擊開始就聽得到管風琴,那一下點擊同時彈一個音。狀態從 "waiting" 變成 "on"。
window.OAV_P5 = { sound: 'off' };
let answer = 'click the canvas to try' ;
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
startSound().then((ok) => { answer = 'startSound() resolved ' + ok; });
}
function draw() {
background(20);
fill(255);
text('sound: ' + OAVp5.sound.state, 20, 30);
text(answer, 20, 60); // startSound() resolved false
}
什麼都不能發聲:狀態一直是 "off",startSound() resolve 成 false,面板裡也沒有 🔊 按鈕。
相關 startSound() 讓你彈的鍵都聽得到,用你指定的樂器發聲,並回傳一個 promise,告訴你聲音有沒有開起來。 stopSound() 關掉聲音,讓正在響的音全部放開,樂器還留在記憶體裡,所以再呼叫 startSound() 馬上就回來。 soundIsOn 現在彈的音有沒有被聽到:startSound() 完成之後是 true,之前和之後都是 false。 tone Tone.js(startSound() 背後的聲音引擎)從哪裡載入:一個 ES module 的位址。空的就是 jsDelivr。 OAVp5.play() 像控制器那樣彈一個鍵:midi.play(60, 0.8) 按下中央 C,midi.play(60, 0) 放開。 p5/oav-p5.js 第 130 行
?oav_strudel=auto | on | off
oav-p5 要不要把這個頁面當成 Strudel(即時編碼編輯器),在它旁邊客氣一點:auto、on 或 off。
Strudel 是即時編碼編輯器,不是草圖,所以 oav-p5 看到它的時候有幾件事會改變:Strudel 自己擁有的名稱(note、velocity、beat)不去動,並用一張卡片說明要改讀什麼,MIDI 交給 Strudel 自己的 midin() 去要,不是載入的時候(hardware 變成 click),🎹 面板變成 compact 的小方塊,🎹 放在 Strudel 側邊按鈕的上面。oav-p5 只看網址和頁面上有哪些名稱:不會呼叫 Strudel 的任何東西。
on 讓任何頁面都用這種做法,off 讓 strudel.cc 上也用一般的做法。你自己給的設定(hardware 、compact )還是比 Strudel 模式的選擇優先。
型別 String
可用的值 auto | on | off
預設 autoauto 的意思是在 strudel.cc 上,或頁面上本來就有 Strudel 自己的函式(note() 和 s(),加上 stack() 或 samples())。其他的值都當作 auto。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { strudel: … }; 優先,其次是網頁網址上的 ?oav_strudel=…,最後是 script 自己網址上的 ?strudel=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_strudel=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { strudel: 'on' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('hardware: ' + OAVp5.options.hardware, 20, 30); // click: Strudel asks for MIDI itself
text('compact panel: ' + OAVp5.options.compact, 20, 60); // true
}
在一般的頁面上,strudel 設成 on 就換成 Strudel 的規矩:載入的時候不去要 Web MIDI,面板也會是精簡的小方塊。
相關 hardware oav-p5 什麼時候向瀏覽器要 Web MIDI(真的控制器需要它):auto 是載入時,click 是 🎹 面板打開時,off 是不要。 compact 把 🎹 面板顯示成角落的一個小方塊,高度是視窗的三分之一,而不是整條橫幅:on 或 off。 link 加入一個房間:房間相同的 oav-p5 頁面,會透過中繼聽到彼此的程式送出的聲音。填房間名,空的就是不加入。 clock 外部的 MIDI 時鐘(來自 DAW、Strudel 或鼓機)要不要推動 drumPhase、drumBpm 和鼓機:follow 或 off。 OAVp5.clock oav-p5 聽到的外部 MIDI 時鐘:它的狀態、速度、現在在小節的哪裡。在別的程式或設備開始送時鐘之前什麼都沒有。 p5/oav-p5.js 第 148 行
?oav_telemetry=off | on
螢幕上的控制器可不可以把一筆匿名的頁面載入次數送給專案:off 或 on。
在 oav-p5 裡預設是關的。設成 on,🎹 面板建立螢幕控制器的時候,@openav/midi 會把每次頁面載入的一筆匿名紀錄送到專案的 Google Analytics,讓專案知道它的控制器用在哪些地方。@openav/midi 的 README(Telemetry 那一節)列出它確切送了什麼:嵌入的種類、設定檔、頁面的來源網域(絕不含路徑、查詢字串或標題)和版本。沒有任何人彈的東西會被送出,也不會設定 cookie。
型別 String
可用的值 off | on
預設 off只有 on 會打開它。
注意 依照那份 README,從 localhost、區域網路位址、file:// 或 openaudiovisual.com 本身都不會送,瀏覽器送出「不要追蹤」或 Global Privacy Control 的時候也不會送。 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { telemetry: … }; 優先,其次是網頁網址上的 ?oav_telemetry=…,最後是 script 自己網址上的 ?telemetry=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_telemetry=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { telemetry: 'on' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('telemetry: ' + OAVp5.options.telemetry, 20, 30); // nothing visible changes
}
畫面上看不出任何變化。這個選項會在 🎹 面板第一次打開的時候傳給螢幕控制器。
待確認
這裡沒有親眼看到那筆紀錄送出:範例在 localhost 上跑,那裡不會送任何東西。它送了什麼,以 @openav/midi 的 README 為準。
相關 panel 🎹 面板一開始是收起來、打開,還是根本沒有:closed、open 或 off。 profile 螢幕上的 🎹 顯示哪一種控制器,用設定檔 id 指定,例如 akai-lpd8。不指定的話,它跟著插上去的那一個。 p5/oav-p5.js 第 129 行
?oav_title=a name
放進錄影和圖片檔名裡的名稱,例如 oav-my-piece-20261015-183012.mp4。
沒有它的話,錄影檔名是 oav-20261015-183012.mp4。設了 title: 'my-piece',就是 oav-my-piece-20261015-183012.mp4,好幾件作品的錄影就會自己排好。單次錄影也可以有自己的名字:startRecording({ name: 'take-2' }) 會取代它。
型別 String
可用的值 a name
預設 '' (no name)空白和檔名不能用的字元會變成 -,而且只取前 40 個字元。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { title: … }; 優先,其次是網頁網址上的 ?oav_title=…,最後是 script 自己網址上的 ?title=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_title=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { title: 'my-piece' };
let saved = 'press r to record 2 seconds' ;
function setup() {
createCanvas(400, 300);
}
function keyPressed() {
if (key === 'r' ) startRecording(2);
}
function recordingSaved(info) {
saved = 'saved as ' + info.name;
}
function draw() {
background(20);
fill(255);
text(saved, 20, 30);
}
按 r:錄兩秒並下載,草圖顯示檔名,檔名以 oav-my-piece- 開頭。
p5/oav-p5.js 第 133 行
?oav_tone=an ES module URL
Tone.js(startSound() 背後的聲音引擎)從哪裡載入:一個 ES module 的位址。空的就是 jsDelivr。
聲音(startSound() 、sound 選項、🔊 按鈕)用的是 Tone.js 15,oav-p5 在第一次需要聲音的時候才載入它。預設從 jsDelivr 來。頁面在學校防火牆或擋廣告的外掛後面、CDN 被擋住,或是你自己架所有東西的時候,把 tone 指到你自己的那一份。載不到的時候,會有一張卡片說是哪個主機連不上,以及可以怎麼試。
型別 String (URL)
可用的值 an ES module URL
預設 '' (jsDelivr)Tone.js 第 15 版的 ES module 位址。留空的話用 https://cdn.jsdelivr.net/npm/tone@15.0.4/+esm,OAVp5.options.tone 讀回來是 ''。
注意 要在 oav-p5 載入之前設好。寫在載入那一行上面的 window.OAV_P5 = { tone: … }; 優先,其次是網頁網址上的 ?oav_tone=…,最後是 script 自己網址上的 ?tone=…。寫在 window.OAV_P5 裡的值會被轉成文字,所以要寫 'off',不要寫 false。在 OpenProcessing 上,網頁網址要寫成 ?sketch_oav_tone=…(專案的 README 說 OpenProcessing 會把它轉給草圖)。 範例
window.OAV_P5 = { tone: 'https://cdn.jsdelivr.net/npm/tone@15.0.4/+esm' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('Tone.js from: ' + OAVp5.options.tone, 20, 30); // the address here is the built-in one
}
印出 Tone.js 會從哪裡來。這裡的位址就是內建的那個,所以不會有任何改變。換成你自己的那一份,就能自己架。
相關 sound 音符有沒有聲音:click(要呼叫 startSound() 或按 🔊 才有)、off(永遠沒有),或一個樂器 id,從第一次點擊就開始出聲。 startSound() 讓你彈的鍵都聽得到,用你指定的樂器發聲,並回傳一個 promise,告訴你聲音有沒有開起來。 soundIsOn 現在彈的音有沒有被聽到:startSound() 完成之後是 true,之前和之後都是 false。 p5/oav-p5.js 第 130 行
OAVp5 物件(進階)#
oav-p5 掛在 OAVp5 與 midi 底下的東西:狀態、訂閱、體檢。 13
oav-p5
OAVp5同一條也包含: midi oavMidi
oav-p5 把所有東西掛在上面的那個物件:MIDI 狀態、面板、麥克風、時鐘、體檢。midi 和 oavMidi 是同一個物件。
只要 oav-p5 載入了,它就在那裡,setup() 和 draw() 裡都一樣。p5 風格的簡單名稱(knob1、note、micLevel)是輕鬆的入口。想要更多的時候就用 OAVp5:原始的 MIDI 狀態(OAVp5.note 、OAVp5.cc 、OAVp5.bend 、OAVp5.held 、OAVp5.last )、訂閱(OAVp5.on() )、自己送位元組進去的方法(OAVp5.input() )、麥克風的數字(OAVp5.mic )、外部時鐘(OAVp5.clock )、連結(OAVp5.link )、數值隨時間變化(OAVp5.chop ),還有體檢(OAVp5.check() )。
三個名字,同一個物件。OAVp5 一定在,oavMidi 也一定在。midi 則是頁面本來沒有自己的 midi 時才有,如果頁面已經有了,就不去動它,console 會說要改讀 OAVp5。OAVp5.options 放的是 oav-p5 讀到的載入選項:OAVp5.options.mic,其他每個載入選項(例如 mic )也一樣。
要先做什麼才有值
只需要載入那一行。如果它沒載入,OAVp5 就沒有定義,讀它的草圖會停在「OAVp5 is not defined」。
注意 midi 和 oavMidi 是同一個物件的其他名字。範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('same object: ' + (OAVp5 === midi && OAVp5 === oavMidi), 20, 30);
text('version: ' + OAVp5.version, 20, 60);
text('MIDI hardware: ' + OAVp5.hardware, 20, 90);
text('microphone: ' + OAVp5.mic.state, 20, 120); // reading the state does not start it
text('external clock: ' + OAVp5.clock.info.state, 20, 150);
}
一塊小小的狀態板:三個名字是同一個物件,然後是版本,以及 MIDI、麥克風和時鐘的狀態。
相關 OAVp5.version 載入的 oav-p5 的版本,是像 '0.8.0' 這樣的文字。 OAVp5.on() 用名字監聽一個 MIDI 事件,不必定義全域函式,並回傳一個停止監聽的函式。 OAVp5.input() 用位元組手動送一則 MIDI 訊息給 oav-p5:整個草圖的反應就像是控制器送出來的。 OAVp5.check() 執行 🩺 體檢:版本、頁面、瀏覽器、麥克風、MIDI、聲音、鼓機、錄影、接線和提示,做成一張可以截圖的卡片,也回傳成物件。 OAVp5.mic 平常那些名稱底下的麥克風:它的狀態、跟 micLevel 和 bass 一樣的數字(讀它們不會把麥克風打開)、原始的音量,還有 start、stop 和事件。 OAVp5.clock oav-p5 聽到的外部 MIDI 時鐘:它的狀態、速度、現在在小節的哪裡。在別的程式或設備開始送時鐘之前什麼都沒有。 OAVp5.chop CHOP 函式放在同一個地方,加上它們背後模組的狀態:OAVp5.chop.lag(…)、.state、.slots()、.reset()。 p5/oav-p5.js 第 110 行
OAVp5.check([opts])
執行 🩺 體檢:版本、頁面、瀏覽器、麥克風、MIDI、聲音、鼓機、錄影、接線和提示,做成一張可以截圖的卡片,也回傳成物件。
跟面板裡的 🩺 按鈕做的事一樣。學生那邊有東西不能用的時候,在 console 打 OAVp5.check()(或按 🩺)就會得到一張卡片,說這個瀏覽器做了什麼:頁面是不是在 iframe 裡、麥克風開著還是被擋、有沒有 Web MIDI、找到哪些設備、最後一則訊息、聲音和鼓機的狀態、能不能錄 MP4、控制器接到哪些變數、草圖自己的哪些名稱擋住了路,還有出現過哪些卡片。卡片上有複製按鈕,也有連到網站完整裝置檢查的連結。
參數 名稱 型別 說明 optsObject{ show: false } 不顯示頁面上的卡片,只回傳報告(文字還是會印到 console)。不寫的話就顯示卡片。 可省略
回傳 Object 報告:{ version, time, page, browser, microphone, midi, sound, drums, wiring, own, hints, external, recording, text }。text 是整張卡片的純文字,其他欄位是同樣的資訊做成的資料(microphone.state、midi.hardware、midi.devices …)。
注意 它是呼叫那一刻的快照,不是即時的畫面。再呼叫一次會取代那張卡片。 範例
let report;
function setup() {
createCanvas(400, 300);
report = OAVp5.check({ show: false }); // the report, without the card on the page
}
function draw() {
background(20);
fill(255);
textSize(10);
text(report.text, 10, 10, width - 20, height - 20);
}
把體檢的文字印在畫布上:版本、瀏覽器、麥克風、MIDI 等等,一項一行。
function setup() {
createCanvas(400, 300);
}
function mousePressed() {
OAVp5.check(); // the card appears over the page
}
function draw() {
background(20);
fill(255);
text('click the canvas for the check-up card' , 20, 30);
}
點一下畫布:體檢卡片會蓋在頁面上,附有複製按鈕,可以直接截圖。
相關 OAVp5.hints oav-p5 到現在顯示過(或本來會顯示)的卡片清單(麥克風被擋、沒有 Web MIDI、你的回呼出錯),每張都寫著發生什麼、為什麼、要按什麼。 OAVp5.version 載入的 oav-p5 的版本,是像 '0.8.0' 這樣的文字。 debug 在 console 印出 [oav-p5 debug] 開頭的幾行,關於點擊、🎹 按鈕和載入,用來查 🎹 為什麼沒反應。 OAVp5.mic.state 麥克風現在的情形:'off'、'asking'、'on'、'denied' 或 'unavailable'。 OAVp5.hardware Web MIDI 目前的情形:'off'、'asking'、'connected'、'waiting'、'denied' 或 'unavailable'。 p5/oav-p5.js 第 2818 行
OAVp5.clock
oav-p5 聽到的外部 MIDI 時鐘:它的狀態、速度、現在在小節的哪裡。在別的程式或設備開始送時鐘之前什麼都沒有。
有些程式負責打拍子,其他人跟著走:Strudel(midicmd("clock*96, <start stop>/8"))、透過虛擬線連過來的 DAW、用 MIDI 線接的鼓機。它們每拍送 24 個時鐘訊息,加上 start、continue、stop 和 song position。oav-p5 把它們數起來:OAVp5.clock.info 說有沒有時鐘在跑,OAVp5.clock.bpm 是它的速度,OAVp5.clock.phase 是現在走到小節和拍子的哪裡。
時鐘在動的時候,drumPhase 、beatPhase 、drumBar 、drumStep 、drumBpm 讀的就是它,已經在跑的鼓機也會跟著它。?oav_clock=off 會把這些全部關掉。時鐘訊息不會交給 midiMessage() :它們屬於時鐘。
注意 第一個時鐘訊息會從 oav-p5.js 旁邊載入負責計算的程式(@openav/midi 的 MidiClock),要幾個畫面。在那之前,這些成員讀到的是 none、null、null。 這是進階用法:大多數草圖只讀 drumPhase,讓它自己去跟著時鐘。 範例
function setup() {
createCanvas(400, 300);
OAVp5.input([0xFA]); // MIDI start
setInterval(() => OAVp5.input([0xF8]), 20); // clock ticks: 24 a beat, here 50 a second (125 BPM)
}
function draw() {
background(20);
fill(255);
text('clock: ' + OAVp5.clock.info.state, 20, 30);
text('tempo: ' + (OAVp5.clock.bpm ? nf(OAVp5.clock.bpm, 1, 1) : '-' ) + ' BPM' , 20, 60);
const p = OAVp5.clock.phase;
if (p) circle(40 + p.beat * (width - 80), 150, 30); // sweeps across once a beat
}
草圖自己送出一個假的 MIDI 時鐘,大約 125 BPM。一個圓每拍從左掃到右一次,狀態和速度也印出來。
相關 OAVp5.clock.info 外部時鐘的快照:state(none、running、stopped 或 lost)、bpm、鼓機有沒有跟著它、它從哪裡來,還有各種計數。 OAVp5.clock.bpm 外部 MIDI 時鐘的速度,單位是每分鐘幾拍,沒有的時候是 null。 OAVp5.clock.phase 外部時鐘現在走到哪裡:{ pos, bar, beat, bpm, running },沒有速度的時候是 null。 clock 外部的 MIDI 時鐘(來自 DAW、Strudel 或鼓機)要不要推動 drumPhase、drumBpm 和鼓機:follow 或 off。 drumPhase 鼓機走到這一小節的哪裡,從 0 平滑地增加到快要 1,是你耳朵聽到的位置。 beatPhase 鼓機走到這一拍的哪裡,從 0 平滑地增加到快要 1,一小節會跑四次。 p5/oav-p5.js 第 713 行
OAVp5.clock.bpm#
變數 oav-p5 OAVp5.clock.bpm
外部 MIDI 時鐘的速度,單位是每分鐘幾拍,沒有的時候是 null。
原始的數字,沒有四捨五入。任何要跟著時鐘速度走的東西都可以用它:OAVp5.clock.bpm / 60 就是每秒幾拍。鼓機如果跟著時鐘,drumBpm 會顯示四捨五入到小數一位的值。
型別 Number | null用最近 24 個時鐘 tick 擬合出來的,所以一下子就會穩定,不會因為一個晚到的 tick 就跳動。兩個 tick 到齊之前,或設了 clock=off,都是 null。
範例
function setup() {
createCanvas(400, 300);
OAVp5.input([0xFA]); // MIDI start
setInterval(() => OAVp5.input([0xF8]), 20); // clock ticks: 24 a beat, here 50 a second (125 BPM)
}
function draw() {
background(20);
fill(255);
textSize(48);
textAlign(CENTER, CENTER);
const bpm = OAVp5.clock.bpm;
text(bpm ? nf(bpm, 1, 1) + ' BPM' : 'no clock' , width / 2, height / 2);
}
印出草圖自己送的假時鐘的速度:幾個 tick 到齊之後大約是 125 BPM。
相關 OAVp5.clock oav-p5 聽到的外部 MIDI 時鐘:它的狀態、速度、現在在小節的哪裡。在別的程式或設備開始送時鐘之前什麼都沒有。 OAVp5.clock.info 外部時鐘的快照:state(none、running、stopped 或 lost)、bpm、鼓機有沒有跟著它、它從哪裡來,還有各種計數。 OAVp5.clock.phase 外部時鐘現在走到哪裡:{ pos, bar, beat, bpm, running },沒有速度的時候是 null。 drumBpm 鼓機的速度,單位是每分鐘幾拍。 p5/oav-p5.js 第 716 行
OAVp5.clock.info#
物件 oav-p5 OAVp5.clock.info
外部時鐘的快照:state(none、running、stopped 或 lost)、bpm、鼓機有沒有跟著它、它從哪裡來,還有各種計數。
state 在收到任何時鐘訊息之前是 'none',時鐘在走的時候是 'running',MIDI stop 之後是 'stopped'(位置停住,時鐘訊息可能還在來),超過 0.6 秒沒收到時鐘訊息就是 'lost'。bpm 是用最近 24 個 tick 擬合出來的速度,四捨五入到小數兩位,兩個 tick 之前是 null。running 在播放中是 true,只有時鐘、沒有 start 訊息的串流也算(那時 implicit 是 true)。
following 在鼓機讀這個時鐘的時候是 true:它在跑、有速度、而且沒有設 clock=off。source 說訊息從哪裡來:'hardware'、'screen'、'phone'、'virtual-out'(某個程式送進 OAV Virtual MIDI)或 'link'。clocks、dupes、starts、stops、continues 數的是收到了什麼:用同一個時間戳送三次的 start 只算一次,其餘加進 dupes。
型別 Object每次讀都是一個新的物件:{ state, bpm, running, following, source, implicit, clocks, dupes, starts, stops, continues }。在任何時鐘訊息之前是 { state: 'none', bpm: null, running: false, following: false, source: null, clocks: 0 }。
範例
function setup() {
createCanvas(400, 300);
OAVp5.input([0xFA]); // MIDI start
setInterval(() => OAVp5.input([0xF8]), 20); // clock ticks: 24 a beat, here 50 a second (125 BPM)
}
function draw() {
background(20);
fill(255);
const i = OAVp5.clock.info;
text('state: ' + i.state + (i.running ? ' (running)' : '' ), 20, 30);
text('bpm: ' + i.bpm + ', following: ' + i.following, 20, 60);
text('source: ' + i.source + ', clocks heard: ' + i.clocks, 20, 90);
}
草圖自己送一個假的時鐘,同時印出時鐘的快照:狀態很快就從 "none" 變成 "running"。
相關 OAVp5.clock oav-p5 聽到的外部 MIDI 時鐘:它的狀態、速度、現在在小節的哪裡。在別的程式或設備開始送時鐘之前什麼都沒有。 OAVp5.clock.bpm 外部 MIDI 時鐘的速度,單位是每分鐘幾拍,沒有的時候是 null。 OAVp5.clock.phase 外部時鐘現在走到哪裡:{ pos, bar, beat, bpm, running },沒有速度的時候是 null。 clock 外部的 MIDI 時鐘(來自 DAW、Strudel 或鼓機)要不要推動 drumPhase、drumBpm 和鼓機:follow 或 off。 p5/oav-p5.js 第 715 行
OAVp5.clock.phase#
變數 oav-p5 OAVp5.clock.phase
外部時鐘現在走到哪裡:{ pos, bar, beat, bpm, running },沒有速度的時候是 null。
一個小節是四拍。bar 和 beat 從 0 往上數,到下一個第一拍就回到 0,跟 drumPhase 、beatPhase 一樣。stop 之後位置會停住。沒有 start 訊息的時鐘,以沉默半秒之後的第一個 tick 當第一拍,所以還是有相位可以用。
型別 Object | null每次讀都是一個新的物件:pos 是從 start 起算經過幾個小節(0 是第 1 小節的第一拍),bar 和 beat 是目前小節和拍子走了 0 到 1 的多少,bpm 是速度,running 是播放有沒有在動。知道速度之前是 null。
範例
function setup() {
createCanvas(400, 300);
OAVp5.input([0xFA]); // MIDI start
setInterval(() => OAVp5.input([0xF8]), 20); // clock ticks: 24 a beat, here 50 a second (125 BPM)
}
function draw() {
background(20);
const p = OAVp5.clock.phase;
if (!p) return ;
noStroke();
fill(255, 211, 107);
circle(40 + p.bar * (width - 80), 100, 30); // once a bar (four beats)
fill(120, 190, 255);
circle(40 + p.beat * (width - 80), 200, 30); // once a beat
}
兩個圓依著假的時鐘從左掃到右:藍色的每拍一次,黃色的每小節(四拍)一次。
相關 OAVp5.clock oav-p5 聽到的外部 MIDI 時鐘:它的狀態、速度、現在在小節的哪裡。在別的程式或設備開始送時鐘之前什麼都沒有。 OAVp5.clock.info 外部時鐘的快照:state(none、running、stopped 或 lost)、bpm、鼓機有沒有跟著它、它從哪裡來,還有各種計數。 drumPhase 鼓機走到這一小節的哪裡,從 0 平滑地增加到快要 1,是你耳朵聽到的位置。 beatPhase 鼓機走到這一拍的哪裡,從 0 平滑地增加到快要 1,一小節會跑四次。 p5/oav-p5.js 第 718 行
OAVp5.drums.clock#
物件 oav-p5 OAVp5.drums.clock
鼓機怎麼計時:用哪一種時鐘推動、提前排程多久,以及 callback 為了等喇叭而延後多久。
這是 🩺 檢查報告背後的診斷資料。打擊感覺太晚或太早的時候,先看這裡:說明文件寫藍牙喇叭的 latency 大約是 0.15 到 0.25 秒,?oav_drumlatency=200 可以手動設定。late、skipped、warning、why 是檢查報告會讀的其他備註。
型別 Object
範圍 { clock, heartbeat, lookahead, latency, audio, extended, preDraw, … }每次讀都是一個新的物件。clock 是 'lookahead'(打擊事先交給音訊時鐘排好,預設)或 'frame'(跟著畫面走,載入選項 drums )。heartbeat 是 'worker'(Web Worker 每 25 毫秒叫醒它)、'interval'(備用的做法)、用畫面時鐘的時候是 'frame',鼓機還沒有的時候是 'none'。lookahead 是提前排程的秒數,extended 為 true 表示 draw() 太慢,讓它看得更遠。latency 是 callback 為了等喇叭輸出而延後的秒數,audio 是鼓組的音訊時鐘有沒有在跑,preDraw 是在 draw() 前面送 callback 的 p5 掛鉤。
範例
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 120);
textSize(14);
}
function draw() {
background(20);
fill(255);
const c = OAVp5.drums.clock;
text('clock: ' + c.clock + ' (' + c.heartbeat + ')' , 20, 30);
text('lookahead: ' + c.lookahead + ' s' , 20, 60);
text('latency: ' + c.latency + ' s' , 20, 90);
text('hook before draw: ' + (c.preDraw || 'none' ), 20, 120);
}
鼓機播放時的時鐘設定。
待確認
late、skipped、warning、why 各自精確計的是什麼,還沒有查證。
相關 OAVp5.drums 鼓機相關名稱背後的物件:機器的狀態、各種計數、那一行節奏、它的歷史,以及載入進來的鼓機。 startDrums() 啟動鼓機:頁面上出現一個 16 格的鼓機,播放預設節奏、你自己畫的格子或一行字寫的節奏,並替草圖數小節。 drumPhase 鼓機走到這一小節的哪裡,從 0 平滑地增加到快要 1,是你耳朵聽到的位置。 p5/oav-p5.js 第 1451 行
OAVp5.drums.machine#
變數 oav-p5 OAVp5.drums.machine
頁面上那個鼓機格子背後的 @openav/drums 鼓機,還沒載入的時候是 null。
就是 @openav/drums 的 mountDrums() 回傳的東西,有 seq(序列器)、engine(鼓組)、play(on)、setPattern(p)、setBpm(bpm)、phase()、now()、info() 和節奏相關的函式。OAVp5.drums.machine.play(false) 會讓鼓機停止發聲和計數,但格子還留在畫面上:OAVp5.drums.state 這時是 'stopped'。它是進階的把手,這一頁的名稱已經涵蓋草圖平常需要的一切。
型別 Object | nullstartDrums() 把鼓機載入之後設定一次。在那之前,或載入失敗之後,都是 null。
範例
function setup() {
createCanvas(400, 300);
startDrums('four on floor' , 120);
textAlign(CENTER, CENTER);
}
function mousePressed() {
const m = OAVp5.drums.machine;
if (m) m.play(!m.seq.playing); // pause and resume, the grid stays
}
function draw() {
background(OAVp5.drums.state === 'stopped' ? 80 : 20);
fill(255);
text('click to pause or resume (' + OAVp5.drums.state + ')' , width / 2, height / 2);
}
點一下讓鼓機暫停,格子還留在頁面上,狀態會顯示 "stopped",再點一下就繼續。
p5/oav-p5.js 第 1437 行
OAVp5.hints
oav-p5 到現在顯示過(或本來會顯示)的卡片清單(麥克風被擋、沒有 Web MIDI、你的回呼出錯),每張都寫著發生什麼、為什麼、要按什麼。
每種狀況在一個頁面只說一次。這個清單把它們全都記著,包括因為設了 hints=off 而沒有顯示在頁面上的,所以草圖行為怪怪的、螢幕上又什麼都沒有的時候,就看這裡:console.log(OAVp5.hints)。
型別 Array<Object>一開始是空的,只會變長。每一項是 { id, kind, what, why, next }:id 是這個狀況的名字('micDenied'、'chopOut:lag'),kind 是 'error'、'warn' 或 'info',what、why、next 是卡片上的三行字,用瀏覽器的語言寫。
注意 因為狀況結束(麥克風開起來了)而收掉的卡片,還是會留在清單裡。 範例
function setup() {
createCanvas(400, 300);
lag(1); // an unnamed lag() outside draw(): oav-p5 has something to say
}
function draw() {
background(20);
fill(255);
text(OAVp5.hints.length + ' card(s) so far' , 20, 30);
text(OAVp5.hints.map((h) => h.kind + ': ' + h.what).join('\n' ), 20, 60);
}
列出 oav-p5 到現在說過的話:這裡是 lag() 在 draw() 外面被呼叫。同樣的句子也在 console 裡。
相關 hints oav-p5 要不要在 🎹 旁邊顯示說明的卡片(麥克風被擋、沒有 Web MIDI、你的回呼出錯):on 或 off。 OAVp5.check() 執行 🩺 體檢:版本、頁面、瀏覽器、麥克風、MIDI、聲音、鼓機、錄影、接線和提示,做成一張可以截圖的卡片,也回傳成物件。 debug 在 console 印出 [oav-p5 debug] 開頭的幾行,關於點擊、🎹 按鈕和載入,用來查 🎹 為什麼沒反應。 p5/oav-p5.js 第 1193 行
OAVp5.link
和同一個房間裡其他 oav-p5 頁面之間的連結現在的情形:只有 OAVp5.link.info,沒有設房間的時候是 null。
連結把共用同一個房間的 oav-p5 頁面連在一起:一個頁面的程式送進 OAV Virtual MIDI 的東西(音符、控制器和時鐘),房間裡其他每個頁面都會在它在那邊響起的那一刻聽到。用 ?oav_link=my-room 打開它,OAVp5.link.info 是看它進行得如何的地方。沒有別的東西要呼叫:連結的建立和結束都由那個選項決定。
範例
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text(OAVp5.link.info ? 'linked' : 'not linked: add link: \'my-room\' to the options' , 20, 30);
}
說這個頁面有沒有連結。沒有 link 房間的話就沒有,草圖會這麼說。
相關 OAVp5.link.info 連結的狀態:它的房間、state(connecting、open、closed)、有幾個其他頁面在、還有來回的時間。沒有設房間時是 null。 link 加入一個房間:房間相同的 oav-p5 頁面,會透過中繼聽到彼此的程式送出的聲音。填房間名,空的就是不加入。 linkdelay 連在一起的頁面收到音符時多加的緩衝毫秒數,用來撫平不穩的網路:0 到 2000。 relay 手機和 link 房間用來傳話的中繼的位址:一個 WebSocket 網址。 p5/oav-p5.js 第 854 行
OAVp5.link.info#
變數 oav-p5 OAVp5.link.info
連結的狀態:它的房間、state(connecting、open、closed)、有幾個其他頁面在、還有來回的時間。沒有設房間時是 null。
在 draw() 裡讀它來做狀態列,或是看 🎹 上的 🔗:另一個頁面在的時候是綠色,連線中或只有自己是黃色,中繼不見是紅色。🩺 體檢也印出同樣的資訊。每次讀都會產生一個新的物件。
型別 Object | null沒有 link 房間的時候是 null。有的話是 { room, state, peers, rtt, trip, late, sent, received, … }。state 是 'connecting'、'open' 或 'closed'(中繼不見了,正在重試)。peers 是最近 6 秒內聽到幾個其他頁面。rtt 是最近 30 秒到中繼最短的一趟來回,trip 是由它估出來的單程時間,單位都是毫秒。late 數的是比該響的時間晚到、被立刻播出的音符。sent 和 received 數訊息。物件裡還有 diff、lead、msgs、dupes 和 id,它們說明時鐘怎麼對齊(packages/relay/link.js)。
範例
js 複製
window.OAV_P5 = { link: 'my-room' };
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
const i = OAVp5.link.info;
text('room: ' + i.room + ', state: ' + i.state, 20, 30);
text('other pages: ' + i.peers + ', one-way trip: ' + (i.trip == null ? '-' : i.trip + ' ms' ), 20, 60);
}
static 在兩個分頁打開同一個網址:兩邊都顯示 "open" 和一個其他頁面,並顯示來回的時間。
function setup() {
createCanvas(400, 300);
}
function draw() {
background(20);
fill(255);
text('link.info: ' + JSON.stringify(OAVp5.link.info), 20, 30); // null: no room
}
沒有 link 房間的話,印出 null。
待確認
兩個真的頁面之間的數值沒有親眼看過:無頭瀏覽器的檢查連不到中繼。欄位和它們的意思出自程式碼(packages/relay/link.js)和專案的 README。
相關 OAVp5.link 和同一個房間裡其他 oav-p5 頁面之間的連結現在的情形:只有 OAVp5.link.info,沒有設房間的時候是 null。 link 加入一個房間:房間相同的 oav-p5 頁面,會透過中繼聽到彼此的程式送出的聲音。填房間名,空的就是不加入。 linkdelay 連在一起的頁面收到音符時多加的緩衝毫秒數,用來撫平不穩的網路:0 到 2000。 relay 手機和 link 房間用來傳話的中繼的位址:一個 WebSocket 網址。 p5/oav-p5.js 第 856 行
OAVp5.sound.shell#
變數 oav-p5 OAVp5.sound.shell同一條也包含: OAVp5.sound.engine OAVp5.sound.module
載入進來的 @openav/sound 各部分:Sound 外殼、它的 Tone.js 引擎,以及模組本身。聲音載入之前三個都是 null。
OAVp5.sound.shell 是 @openav/sound 的 Sound 實例,OAVp5.sound.engine 是它用來發聲的 Tone.js 引擎(noteOn、noteOff、sustain、releaseAll、setInstrument),OAVp5.sound.module 是整個模組,例如 OAVp5.sound.module.listInstruments() 會列出所有樂器代號。它們是進階的把手:草圖層級的名稱(startSound() 、soundIsOn )已經涵蓋草圖平常需要的一切。
型別 Object | nullstartSound() 載入 @openav/sound 之後設定一次(引擎和外殼同時,模組稍微早一點)。在那之前,或載入失敗之後,都是 null。
範例
let ids = [];
function setup() {
createCanvas(400, 300);
startSound('synth/pluck' );
}
function draw() {
background(20);
fill(255);
textSize(12);
if (!ids.length && OAVp5.sound.module) ids = OAVp5.sound.module.listInstruments().map((d) => d.id);
text(ids.length ? ids.join(', ' ) : 'loading…' , 20, 20, width - 40, height - 40);
}
模組一載入,就列出 startSound() 接受的所有樂器代號。
p5/oav-p5.js 第 1318 行
OAVp5.version
載入的 oav-p5 的版本,是像 '0.8.0' 這樣的文字。
回報問題或確認頁面拿到哪個版本時有用:openaudiovisual.com/p5/oav-p5.js 永遠是最新版,帶版本標籤的 jsDelivr 網址則是固定的。同一個數字也在 🩺 體檢的第一行。
型別 Stringoav-p5 載入時設定,之後不會變。
注意 如果這個檔案載入了兩次(載入那一行和 OpenProcessing 的函式庫清單各一次),先載入的那一個生效,第二個什麼都不做。 範例
function setup() {
createCanvas(400, 300);
textAlign(CENTER, CENTER);
textSize(32);
}
function draw() {
background(20);
fill(255);
text('oav-p5 ' + OAVp5.version, width / 2, height / 2);
}
顯示這個頁面載入的 oav-p5 版本。
相關 OAVp5 oav-p5 把所有東西掛在上面的那個物件:MIDI 狀態、面板、麥克風、時鐘、體檢。midi 和 oavMidi 是同一個物件。 OAVp5.check() 執行 🩺 體檢:版本、頁面、瀏覽器、麥克風、MIDI、聲音、鼓機、錄影、接線和提示,做成一張可以截圖的卡片,也回傳成物件。 p5/oav-p5.js 第 259 行
MIDI 輸入輸出(@openav/midi)#
連接埠、解析、設定檔、時鐘、虛擬 MIDI。 42
@openav/midi
BEND_CENTER#
常數 @openav/midi BEND_CENTER = 8192
彎音輪休息時的數值:8192,也就是 14 位元範圍 0..16383 的中間。
手動組彎音訊息時用得到:{ type: 'pitchbend', ch: 1, value: BEND_CENTER } 就是休息中的彎音輪。設定檔裡帶彈簧的彎音輪(spring)放開後會回到這個值。
型別 Number
範圍 8192常數。彎音輪的 value 等於它就表示「沒有彎」,在 bendToUnit() 裡會變成 0。
範例
js 複製
import { BEND_CENTER, encodeMessage } from '@openav/midi' ;
console.log(BEND_CENTER); // 8192
console.log(encodeMessage({ type: 'pitchbend' , ch: 1, value: BEND_CENTER }));
module 印出 8192,再印出休息位置彎音訊息的位元組。
相關 bendToUnit() 把 14 位元的彎音輪數值(0..16383)轉成 −1..1,中心(8192)剛好是 0。 unitToBend() 把 −1..1 的數字轉成 14 位元的彎音輪數值(0..16383),是 bendToUnit() 的反向。 import { BEND_CENTER } from '@openav/midi'; packages/midi/parse.js 第 15 行
bendToUnit()#
函式 @openav/midi bendToUnit(v)
把 14 位元的彎音輪數值(0..16383)轉成 −1..1,中心(8192)剛好是 0。
MIDI 把彎音輪的中心定在 8192,所以中心以下有 8192 級,以上有 8191 級。它在中心以下除以 8192,以上除以 8191,因此最低剛好是 −1,最高剛好是 +1,休息位置剛好是 0。發佈成 midi/bend 和 midi/ch/<ch>/bend 的就是這個數字。
參數 名稱 型別 說明 vNumber彎音輪的數值,0..16383(parseMessage() 放在 value 裡的那個)。
回傳 Number 0 時是 −1,8192 時是 0,16383 時是 +1。
範例
js 複製
import { bendToUnit, unitToBend } from '@openav/midi' ;
console.log(bendToUnit(0), bendToUnit(8192), bendToUnit(16383)); // -1 0 1
console.log(bendToUnit(12288)); // about 0.5
console.log(unitToBend(bendToUnit(5000))); // 5000
module 印出 −1、0、1,接著約 0.5,最後 5000:來回轉換不會失真。
相關 unitToBend() 把 −1..1 的數字轉成 14 位元的彎音輪數值(0..16383),是 bendToUnit() 的反向。 BEND_CENTER 彎音輪休息時的數值:8192,也就是 14 位元範圍 0..16383 的中間。 genericSignals() 列出一則 MIDI 事件會變成哪些跟裝置無關的訊號:midi/cc/74、midi/note/on、midi/bend,以及各頻道版本,例如 midi/ch/2/cc/74。 import { bendToUnit } from '@openav/midi'; packages/midi/parse.js 第 92 行
CONTROL_TYPES#
常數 @openav/midi CONTROL_TYPES = array, 8 items
設定檔裡可以出現的控制項種類:knob、encoder、fader、pad、button、keys、wheel、strip。
各自送什麼:knob 和 fader 送 CC(0..1)。encoder 送 CC,可以是相對(位置加上 /delta 訊號)或絕對。pad 送音符(按著時是力度,並有 /hit 脈衝)或 CC。button 送音符或 CC,可以是瞬間或切換。keys 是一段音域的音符。wheel 和 strip 送 CC 或彎音,可以是雙向,也可以帶彈簧。
型別 string[]
範圍 ['knob', 'encoder', 'fader', 'pad', 'button', 'keys', 'wheel', 'strip']一個普通陣列。控制項的 type 必須是其中之一,否則 validateProfile() 會回報。
範例
js 複製
import { CONTROL_TYPES, PROFILES } from '@openav/midi' ;
const count = {};
for (const p of PROFILES) for (const c of p.controls) count[c.type] = (count[c.type] || 0) + 1;
console.log(CONTROL_TYPES.map((t) => t + ': ' + (count[t] || 0)).join(', ' ));
module 依清單順序,印出內建設定檔裡每種控制項各有幾個。
import { CONTROL_TYPES } from '@openav/midi'; packages/midi/profiles.js 第 27 行
controllerRoutes()#
函式 @openav/midi controllerRoutes(profile, bindings, { short = profile.short, ...extra } = {})
建立「這個控制項推動那個參數」的 Mapper 路由:controllerRoutes(profile, { knob1: 'hue' }) 會把旋鈕的訊號接到參數 hue。
這是專案的第一條規則(連續控制要經過參數):世界不在 update() 裡讀 midi/lpd8/k1,而是宣告一個參數,由路由來推動它。設定檔沒有的 id 會丟出 profile akai-lpd8 has no control "x",打錯字一定看得見。
參數 名稱 型別 說明 profileObject那台機器的設定檔(原始或整理過的都行):用來檢查 id,並判斷哪些控制項是雙向的。 bindingsObject{ 控制項 id: target },target 是參數名稱,或路由物件({ target, inMin, inMax, … })。打擊墊寫成 { target: 'burst', on: 'hit' } 時,聽的是 /hit 脈衝而不是力度。 shortString訊號的命名空間,預設是設定檔的 short。同一台機器用了別的命名空間時才需要改。 可省略 預設 profile.short extraObject第三個參數裡其他的鍵(例如 { inMin: 0, inMax: 0.5 })會當成預設值複製到每一條路由上,路由物件自己有的鍵優先。 可省略
回傳 Object[] [{ source, target, … }],可以直接用 mapper.addRoute(route)。雙向的控制項(彎音輪)會自動加上 inMin: -1, inMax: 1。第三個參數裡的其他鍵,會複製到每一條路由上。
範例
js 複製
import { controllerRoutes, profileById } from '@openav/midi' ;
const routes = controllerRoutes(profileById('lpd8' ), {
k1: 'hue' , // knob 1 drives the param "hue"
k2: { target: 'size' , inMin: 0, inMax: 1 }, // a route object, extra keys are kept
pad1: { target: 'burst' , on: 'hit' }, // fire on the hit, not on the velocity
});
for (const r of routes) console.log(r.source, '->' , r.target);
module 印出 midi/lpd8/k1 -> hue、midi/lpd8/k2 -> size 和 midi/lpd8/pad1/hit -> burst:每個控制項用哪個訊號去推動它的參數。
相關 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 profileSignals() 列出一份設定檔會發佈在它自己命名空間底下的所有訊號名稱(LPD8 是 midi/lpd8/…),給文件、測試和映射畫面用。 linkControllers() 透過房間中繼把螢幕上的控制器和手機連起來:告訴手機現在螢幕上是哪台機器,把這台電腦上的動作回傳給手機,也把手機的動作鏡射到這裡。 import { controllerRoutes } from '@openav/midi'; packages/midi/manager.js 第 159 行
createVirtualMIDIAccess()#
函式 @openav/midi createVirtualMIDIAccess({ name = 'OAV Virtual MIDI' , manufacturer = 'open-audiovisual' , id = 'oav-virtual' , sysex = false , onOutput = null , onClear = null } = {})
建立一個 Web MIDI 的 MIDIAccess,它永遠多出一組自己的輸入和輸出,讓寫給 MIDI 鍵盤的草圖在沒有接任何機器時也能動。
2018 年寫的草圖呼叫 navigator.requestMIDIAccess()(或 WebMidi.js)然後等鍵盤,在沒有鍵盤的筆電上什麼都不會出現,在 iPad 上更是完全沒有 Web MIDI。改成交給它這個 access,螢幕上的控制器就變成一個看起來像真的 MIDI 輸入:草圖原封不動的程式碼,會收到跟鍵盤送出的位元組一模一樣的 midimessage 事件。
虛擬「輸出」也長得像真的埠:對它輸出的程式(Strudel 的 .midi('OAV Virtual MIDI')、WebMidi.js 草圖)會呼叫 send(bytes, timestamp),onOutput 會同時收到位元組和時間戳。可以用 addReal(realAccess) 併入真實的埠:它們排在 inputs 的「前面」,虛擬的排最後,所以只聽 inputs[0] 的草圖,有硬體時用硬體,沒有時用虛擬控制器。真實的埠原封不動地穿過,所以不會重複送達。
參數 名稱 型別 說明 nameString虛擬埠的名稱,也就是草圖看到的名字。 可省略 預設 'OAV Virtual MIDI' manufacturerString虛擬埠回報的 manufacturer。 可省略 預設 'open-audiovisual' idString埠 id 的前綴:埠的 id 是 <id>-in 和 <id>-out。 可省略 預設 'oav-virtual' sysexBooleanaccess.sysexEnabled 回報的值。 可省略 預設 false onOutputFunction | null程式送到虛擬「輸出」時呼叫的 (bytes, timestamp) => …。timestamp 是這些位元組該發聲的時間(performance.now() 毫秒),undefined 表示「現在」。 可省略 預設 null onClearFunction | null程式呼叫 output.clear() 時被呼叫(Web MIDI 的意思是:丟掉還在排隊的)。 可省略 預設 null
回傳 Object { access, input, output, emit(bytes), addReal(realAccess), real, requests, dispose() }。把 access 交給程式。emit() 把位元組彈進虛擬輸入。
注意 跟 Web MIDI 一樣,access.inputs 和 access.outputs 都是 Map。input.listening 是程式在這個埠上掛了幾個監聽,requests 是 virtualRequestMIDIAccess() 把這個 access 交出去幾次。 範例
js 複製
import { createVirtualMIDIAccess } from '@openav/midi' ;
const v = createVirtualMIDIAccess({
name: 'Demo MIDI' ,
onOutput: (bytes, timestamp) => console.log('program played' , bytes, timestamp),
});
const input = [...v.access.inputs.values()][0];
const output = [...v.access.outputs.values()][0];
console.log(input.name, '|' , input.listening, 'listeners' );
input.onmidimessage = (e) => console.log('program heard' , [...e.data]); // what a 2018 sketch does
v.emit([0x90, 60, 100]); // the on-screen keyboard plays: program heard [144, 60, 100]
output.send([0x90, 62, 80]); // the program plays out: program played [144, 62, 80] undefined
module 印出埠名與 0 個監聽,接著是 program heard [144, 60, 100],再來是 program played [144, 62, 80] undefined(沒有時間戳就是「現在」)。
待確認
這一頁沒有實測:真正的 WebMidi.js 或 Strudel 草圖跟虛擬埠對話,以及 onOutput 的時間戳跟硬體埠比起來對不對得上。範例是直接呼叫那些埠。
相關 virtualRequestMIDIAccess() 包裝 navigator.requestMIDIAccess,讓每個呼叫者都拿到虛擬的 access:真實的埠有及時回應就併進來,就算沒有 Web MIDI 或被拒絕,虛擬輸入也還在。 Midi Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 import { createVirtualMIDIAccess } from '@openav/midi'; packages/midi/virtual-access.js 第 139 行
DEFAULT_PROFILE#
常數 @openav/midi DEFAULT_PROFILE = "arturia-minilab3"
沒有指定時使用的設定檔 id:'arturia-minilab3'。
型別 String
範圍 'arturia-minilab3'常數。沒有 profile 屬性的 <oav-controller>,和沒有 ?profile= 的 iframe 嵌入,都會顯示這台機器。
範例
js 複製
import { DEFAULT_PROFILE, parseEmbedOptions } from '@openav/midi/embed' ;
console.log(DEFAULT_PROFILE);
console.log(parseEmbedOptions({}).profile === DEFAULT_PROFILE); // true
module 印出 arturia-minilab3,再印出 true。
import { DEFAULT_PROFILE } from '@openav/midi/embed'; packages/midi/embed.js 第 26 行
describe()#
函式 @openav/midi describe(ev)
把解析過的 MIDI 事件變成一行好讀的文字,給記錄和學習畫面用:CC ch1 #74=64、Bend ch1 0.50。
格式是 Note On ch1 n60 v100(On 後面兩個空格)、Note Off ch1 n60、CC ch1 #74=64、Bend ch1 0.50(彎音換成 −1..1,兩位小數)、Pressure ch1 50、Poly AT ch1 n60 33、Program ch1 5。Midi 引擎解析的每則訊息(時鐘除外),都會把這樣的一行寫進它的記錄。
回傳 String 那一行文字。給 null 回傳 ?,沒有格式的類型(clock、sysex)則回傳類型名稱。
注意 容易跟 describeMessage()、describeDetail() 搞混:那兩個寫的是 <oav-controller> 讀數列用的較短文字(CC 70 = 53)。這一個吃事件物件,是給記錄用的。 範例
js 複製
import { parseMessage, describe } from '@openav/midi' ;
for (const bytes of [[0x90, 60, 100], [0xb0, 74, 64], [0xe0, 0, 96]]) {
console.log(describe(parseMessage(bytes)));
}
module 印出 Note On ch1 n60 v100、CC ch1 #74=64 和 Bend ch1 0.50。
相關 parseMessage() 把一則 MIDI 訊息的位元組轉成事件物件,例如 { type: 'noteon', ch: 1, note: 60, vel: 100 },不認得的位元組就回傳 null。 describeMessage() 把一則解析過的 MIDI 訊息寫成人讀得懂的樣子:CC 70 = 53、Note 36 · vel 90、Bend 12288。 describeDetail() 為 control 事件的內容寫出讀數列的文字:midi/minilab3/knob1 = 0.42 ← CC 74 = 53 · ch 1。 import { describe } from '@openav/midi'; packages/midi/parse.js 第 195 行
EchoGuard#
類別 @openav/midi new EchoGuard({ window = 40, max = 256 } = {})
認出「自己送出的 MIDI 又回來了」:記住送出過什麼,短時間內抵達的相同訊息就是回音,不是演奏者彈的。
虛擬線路或 DAW 可能把你送出的訊息原樣從輸入送回來,引擎會把它當成演奏者。sent(bytes) 記下一則訊息,isEcho(bytes) 問「剛到的這則是不是那一則回來了」。每則送出的訊息最多只抵銷「一個」回音,所以兩則真正相同的訊息不會被一起吃掉。
力度 0 的 note-on 當成 note-off,雙向都算,因為 DAW 可能把其中一種改寫成另一種。系統訊息(時鐘之類)不會被記錄。Midi 內建一個(midi.echo,由 echoWindow 選項設定),並把找到的回音丟掉,數量記在 midi.echoDropped。
參數 名稱 型別 說明 windowNumber回音可以晚多久回來(毫秒)。0 表示關閉。 可省略 預設 40 maxNumber要記住幾則送出的訊息,只留最新的 max 則。 可省略 預設 256
範例
js 複製
import { EchoGuard } from '@openav/midi' ;
const guard = new EchoGuard({ window: 40 });
guard.sent([0x90, 60, 100], 1000); // we sent note-on 60 at t = 1000 ms
console.log(guard.isEcho([0x90, 60, 100], 1010)); // true: it came back 10 ms later
console.log(guard.isEcho([0x90, 60, 100], 1011)); // false: one send explains one echo
guard.sent([0x90, 62, 100], 2000);
console.log(guard.isEcho([0x90, 62, 100], 2100)); // false: 100 ms later is a performer
module 印出 true、false、false:回音只抵銷一次,太晚到的訊息是真的音符。
相關 Midi Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。 isLive() 判斷一個 MIDI 埠還能不能送資料:它存在,而且 state 不是 'disconnected'。 import { EchoGuard } from '@openav/midi'; packages/midi/ports.js 第 72 行
encodeMessage()#
函式 @openav/midi encodeMessage(ev)
把事件物件轉回 MIDI 位元組,是 parseMessage() 的反向:{ type: 'cc', ch: 1, cc: 74, value: 64 } 會變成 [0xb0, 74, 64]。
encodeMessage({ type: 'noteon', ch: 10, note: 36, vel: 127 }) 得到 [0x99, 36, 127]。頻道會限制在 1..16,每個資料位元組都會四捨五入並限制在 0..127,所以錯誤的數值做不出不合法的訊息。沒給 vel 的話,noteon 是 100,noteoff 是 0。
pitchbend 的 value(0..16383)會照 MIDI 規定拆成兩個 7 位元的位元組,低位在前。套件用它來做螢幕上的控制器和 LED 回饋。
參數 名稱 型別 說明 evObject事件物件:type 是 noteon、noteoff、polyat、cc、program、chanat 或 pitchbend,欄位跟 parseMessage() 對該類型用的一樣。ch 預設是 1。
回傳 number[] | null 位元組陣列,可以直接給 output.send()。做不出來的類型(clock、sysex、不認得的)回傳 null。
範例
js 複製
import { encodeMessage, parseMessage } from '@openav/midi' ;
const bytes = encodeMessage({ type: 'noteon' , ch: 10, note: 36, vel: 127 });
console.log(bytes); // [153, 36, 127]
console.log(parseMessage(bytes)); // back to the same event
module 印出 [153, 36, 127](0x99、0x24、0x7f),再印出它原本的事件。
js 複製
import { encodeMessage } from '@openav/midi' ;
console.log(encodeMessage({ type: 'pitchbend' , ch: 1, value: 8192 })); // centre: [224, 0, 64]
console.log(encodeMessage({ type: 'cc' , ch: 1, cc: 74, value: 300 })); // value held to 127
console.log(encodeMessage({ type: 'clock' })); // null
module 三個結果:彎音輪的中心、被限制在 127 的數值,以及做不出來的訊息類型回傳 null。
相關 parseMessage() 把一則 MIDI 訊息的位元組轉成事件物件,例如 { type: 'noteon', ch: 1, note: 60, vel: 100 },不認得的位元組就回傳 null。 describe() 把解析過的 MIDI 事件變成一行好讀的文字,給記錄和學習畫面用:CC ch1 #74=64、Bend ch1 0.50。 unitToBend() 把 −1..1 的數字轉成 14 位元的彎音輪數值(0..16383),是 bendToUnit() 的反向。 Midi Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。 import { encodeMessage } from '@openav/midi'; packages/midi/parse.js 第 48 行
eventOfSignal()#
函式 @openav/midi eventOfSignal(name, value, { channel = 1 } = {})
genericSignals() 的反向:把一個通用的 MIDI 訊號(midi/ch/10/note/36、midi/cc/74)轉回它原本的 MIDI 事件。
有了它,只聽得到 Signals 匯流排的東西(正在回放的錄音、螢幕上的鋼琴、手機),也能像收到原始位元組一樣去推動控制器模型。數值會還原成當初發佈時的 0..127 尺度(值 × 127 再四捨五入),所以跟 genericSignals() 來回轉換不會失真。
音符訊號的值大於 0 是 noteon(力度至少 1),等於 0 是 noteoff。螢幕上的鋼琴發佈的頻道是 0,會被當成 channel 參數。硬體的一則訊息會用兩組名稱各發佈一次,所以想「每則訊息只處理一次」的話,請用 followMidiSignals() ,不要對每個名稱都呼叫這個函式。
參數 名稱 型別 說明 nameString訊號名稱。只認得 midi/ 開頭的通用名稱。 valueany訊號的值:0..1 的數字(彎音是 −1..1),或 midi/note/on、midi/note/off 那個 { note, vel, velocity, ch } 內容。 channelNumber沒有頻道資訊的短名稱(midi/cc/<n>、midi/bend)要用哪個頻道。 可省略 預設 1
回傳 { family, ev } | null family 是 'channel' 或 'legacy',ev 是事件。裝置名稱(midi/minilab3/…)、midi/virtual 和其他名稱都回傳 null。
範例
js 複製
import { eventOfSignal } from '@openav/midi' ;
console.log(JSON.stringify(eventOfSignal('midi/ch/10/note/36' , 0.5)));
console.log(JSON.stringify(eventOfSignal('midi/cc/74' , 1, { channel: 4 })));
console.log(eventOfSignal('midi/minilab3/knob1' , 1)); // a device name: null
module 印出頻道 10、力度 64 的 noteon,頻道 4、值 127 的 CC 74,最後是 null。
import { eventOfSignal } from '@openav/midi'; packages/midi/parse.js 第 156 行
findPort()#
函式 @openav/midi findPort(ports, query)
用名稱找 MIDI 埠:先試 /regex/flags,再試完整名稱,最後試不分大小寫的子字串,都找不到就回傳 null。
midi-out 屬性、hardware 屬性裡的埠名,以及 createController() 的 setOutput(query),都是用它來理解你打的字。
參數 名稱 型別 說明 portsObject[]有 name 的埠(陣列,例如 midi.outputs)。 queryString名稱、名稱的一部分('IAC'、'loopMIDI'),或 '/^mini/'。結尾斜線後面的旗標會被採用,沒寫旗標的話正規表示式不分大小寫。
回傳 Object | null 第一個符合的埠。壞掉的正規表示式只會找不到,不會丟出錯誤。
範例
js 複製
import { findPort } from '@openav/midi' ;
const ports = [{ name: 'IAC Driver Bus 1' }, { name: 'loopMIDI Port' }, { name: 'Minilab3 MIDI' }];
console.log(findPort(ports, 'iac' ).name); // IAC Driver Bus 1 (substring, any case)
console.log(findPort(ports, '/^mini/' ).name); // Minilab3 MIDI
console.log(findPort(ports, 'nanoKONTROL' )); // null
console.log(findPort(ports, '/[/' )); // null: a broken regex finds nothing
module 印出 IAC Driver Bus 1、Minilab3 MIDI、null、null。
相關 pickOutput() 從清單裡挑要送到哪個 MIDI 輸出埠:先是你指定的名稱,其次是偏好清單的第一個符合者,最後才是第一個存活的埠。 Midi Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。 createController() 做出一台可以彈、可以聽、可以接硬體或 DAW 的 MIDI 控制器,不需要元素也不需要框架:createController('akai-lpd8')。 import { findPort } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 143 行
findProfile()#
函式 @openav/midi findProfile(ref, profiles)
在清單裡用 id 或短名稱找設定檔,找不到回傳 null,不驗證也不載入。
手上有自己的清單時用,例如選單提供的設定檔(controller.controllers.profiles),而你想用跟元素相同的「id 或短名稱」查法。
參數 名稱 型別 說明 refString設定檔的 id 或 short 短名稱。 profilesObject[]要搜尋的清單(這裡一定要給,不像 profileById() 固定用內建的)。
回傳 Object | null 清單裡的那份設定檔。
範例
js 複製
import { PROFILES } from '@openav/midi' ;
import { findProfile } from '@openav/midi/embed' ;
console.log(findProfile('lpd8' , PROFILES).id); // akai-lpd8
console.log(findProfile('akai-lpd8' , PROFILES).short); // lpd8
console.log(findProfile('nope' , PROFILES)); // null
module 印出 akai-lpd8、lpd8 和 null。
相關 profileById() 用 id('akai-lpd8')或訊號用的短名稱('lpd8')找一個內建的控制器設定檔,找不到回傳 null。 resolveProfile() 把設定檔物件、id 或短名稱,或指向設定檔 JSON 的網址,變成一份設定檔,物件或檔案不合法就 reject,並列出所有問題。 import { findProfile } from '@openav/midi/embed'; packages/midi/embed.js 第 105 行
genericSignals()#
函式 @openav/midi genericSignals(ev, { device = null , legacy = true , channel = true } = {})
列出一則 MIDI 事件會變成哪些跟裝置無關的訊號:midi/cc/74、midi/note/on、midi/bend,以及各頻道版本,例如 midi/ch/2/cc/74。
兩組名稱,每個硬體連接埠和每個虛擬控制器都會發佈。短名稱是大多數世界讀的:noteon 變成脈衝 midi/note/on,內容是 { note, vel(0..1), velocity(1..127), ch, device }。noteoff 變成 midi/note/off,內容是 { note, ch, device }。cc 變成 midi/cc/<n>(0..1),pitchbend 變成 midi/bend(−1..1)。
分頻道的那組給「兩台機器共用同一個 CC 編號」或「作品在意頻道」的時候用:midi/ch/<ch>/note/<n>(按著時是力度 0..1,放開是 0)、midi/ch/<ch>/cc/<n>、midi/ch/<ch>/bend、midi/ch/<ch>/pressure(頻道觸後)、midi/ch/<ch>/poly/<n>(複音觸後)。壓力和 poly 只存在於這一組。program、clock 和其他系統訊息不會產生訊號。
刻意沒有 midi/cc/<ch>/<n> 這種名稱:用前綴訂閱時,它會跟「任何頻道的 CC 1」搞混。
參數 名稱 型別 說明 evObjectparseMessage() 回傳的事件。給 null 會得到空陣列。 deviceString | null寫進 midi/note/on、midi/note/off 脈衝的 device 欄位(連接埠的 slug)。 可省略 預設 null legacyBoolean是否包含不分頻道的短名稱(midi/note/on、midi/cc/<n>、midi/bend)。 可省略 預設 true channelBoolean是否包含分頻道的名稱(midi/ch/<ch>/…)。 可省略 預設 true
回傳 { name, value, pulse?, min?, max? }[] 每個訊號一項。把整個陣列交給 publish() 即可。
範例
js 複製
import { genericSignals } from '@openav/midi' ;
const names = (ev) => genericSignals(ev).map((s) => s.name);
console.log(names({ type: 'cc' , ch: 2, cc: 74, value: 64 }));
console.log(names({ type: 'noteon' , ch: 10, note: 36, vel: 127 }));
console.log(names({ type: 'chanat' , ch: 1, value: 80 }));
module 印出 ['midi/cc/74', 'midi/ch/2/cc/74'],接著是 note-on 的兩個名稱,最後是只有分頻道版本的壓力名稱。
js 複製
import { genericSignals } from '@openav/midi' ;
const ev = { type: 'noteon' , ch: 1, note: 60, vel: 100 };
console.log(genericSignals(ev, { device: 'my-keys' })[0].value);
console.log(genericSignals(ev, { legacy: false }).map((s) => s.name));
module 第一行印出脈衝內容 { note: 60, vel: 0.787…, velocity: 100, ch: 1, device: 'my-keys' },第二行只剩分頻道的名稱。
相關 eventOfSignal() genericSignals() 的反向:把一個通用的 MIDI 訊號(midi/ch/10/note/36、midi/cc/74)轉回它原本的 MIDI 事件。publish() 把一串 { name, value, pulse?, min?, max? } 發佈到 Signals 登錄表,每個訊號第一次出現時會先宣告。 parseMessage() 把一則 MIDI 訊息的位元組轉成事件物件,例如 { type: 'noteon', ch: 1, note: 60, vel: 100 },不認得的位元組就回傳 null。 followMidiSignals() 監聽 Signals 匯流排,並把每則 MIDI 訊息「剛好一次」以事件的形式還給你,雖然硬體會用兩個名稱發佈同一則訊息。 import { genericSignals } from '@openav/midi'; packages/midi/parse.js 第 106 行
groupsOf()#
函式 @openav/midi groupsOf(p)
依 group(knob、pad、fader …)把設定檔的控制項分組,回傳 { group: [控制項 id] }。
分組方式跟 TouchDesigner 工具的輸出 CHOP(out_knob、out_slider、out_pad …)一致。控制項沒特別指定的話,就用它的類型當分組。MidiController 有個同名的方法,回傳的是目前的數值,不是 id。
參數 名稱 型別 說明 pObject整理過的設定檔,這樣每個控制項才有 group。
回傳 Object 由控制項 id 陣列組成的物件。
範例
js 複製
import { groupsOf, normalizeProfile, profileById } from '@openav/midi' ;
const groups = groupsOf(normalizeProfile(profileById('lpd8' )));
console.log(Object.keys(groups)); // [ 'pad', 'knob' ]
console.log(groups.knob); // [ 'k1', 'k2', ... 'k8' ]
module 印出分組名稱,再印出八個旋鈕的 id。
import { groupsOf } from '@openav/midi'; packages/midi/profiles.js 第 206 行
indexProfile()#
函式 @openav/midi indexProfile(p)
建立「MIDI 訊息 → 它會推動的控制項」的查詢表:"cc:1:74" 對應到聽頻道 1 的 CC 74 的那顆旋鈕。
MidiController 就是用它判斷一則訊息屬於哪個控制項。要自己做路由時可以拿來用。每個值都是清單,因為一則訊息可能推動不只一個控制項。
參數 名稱 型別 說明 pObject整理過的設定檔(每個控制項都要有 ch)。
回傳 Map<string, { control, note? }[]> 鍵的格式是 cc:<ch>:<cc>、note:<ch>:<note>、polyat:<ch>:<note>、pitchbend:<ch>、chanat:<ch>、program:<ch>。琴鍵控制項的音域裡每個音都有一筆。
範例
js 複製
import { indexProfile, normalizeProfile, profileById } from '@openav/midi' ;
const index = indexProfile(normalizeProfile(profileById('lpd8' )));
console.log(index.get ('cc:1:70' ).map((hit) => hit.control.id)); // [ 'k1' ]
console.log(index.get ('note:10:36' ).map((hit) => hit.control.id)); // [ 'pad1' ]
console.log(index.get ('cc:1:99' )); // undefined
module 印出 CC 70 對應的 ['k1']、頻道 10 音符 36 對應的 ['pad1'],以及這台機器不會送的訊息所對應的 undefined。
import { indexProfile } from '@openav/midi'; packages/midi/profiles.js 第 136 行
isLive(p)
判斷一個 MIDI 埠還能不能送資料:它存在,而且 state 不是 'disconnected'。
被拔掉的埠,在你手上仍然是個物件。對它送資料到不了任何地方,引擎的 try/catch 又會把錯誤吞掉,畫面上看不出原因。所以輸出的挑選規則(pickOutput() 、reselectOutput() )會跳過所有不存活的埠。
參數 名稱 型別 說明 pMIDIPort | null長得像 MIDIOutput 的埠,只需要有 state。
回傳 Boolean 給 null 或已斷線的埠回傳 false。
範例
js 複製
import { isLive } from '@openav/midi' ;
console.log(isLive({ name: 'A' , state: 'connected' })); // true
console.log(isLive({ name: 'B' , state: 'disconnected' })); // false
console.log(isLive(null )); // false
module 印出 true、false、false。
import { isLive } from '@openav/midi'; packages/midi/ports.js 第 10 行
isProfileUrl()#
函式 @openav/midi isProfileUrl(ref)
分辨「指向設定檔的網址」和「設定檔 id」:./pads.json、https://…、/p/x 回傳 true,'akai-lpd8' 回傳 false。
resolveProfile() 就是用它決定要「查 id」還是「抓檔案」。剛好以 .json 結尾的單純名稱也會被當成網址。
參數 名稱 型別 說明 refany頁面或屬性給的內容。不是字串一律是 false。
回傳 Boolean 字串以 http:、https:、blob:、data: 開頭,或以 /、./、../ 開頭,或以 .json 結尾(後面可接 ?… 或 #…)時為 true。
範例
js 複製
import { isProfileUrl } from '@openav/midi/embed' ;
for (const ref of ['akai-lpd8' , './pads.json' , 'https://x.org/my.json' , '/p/x' , 'my.json' ]) {
console.log(ref, isProfileUrl(ref));
}
module id 印出 false,其他四個印出 true。
相關 resolveProfile() 把設定檔物件、id 或短名稱,或指向設定檔 JSON 的網址,變成一份設定檔,物件或檔案不合法就 reject,並列出所有問題。 loadProfile() 用 id、短名稱或網址(任何 CORS 允許的 JSON 檔)載入控制器設定檔,你給的檔案或物件會先檢查,回傳 promise。 import { isProfileUrl } from '@openav/midi/embed'; packages/midi/embed.js 第 102 行
linkControllers()#
函式 @openav/midi linkControllers(relay, signals, controllers)
透過房間中繼把螢幕上的控制器和手機連起來:告訴手機現在螢幕上是哪台機器,把這台電腦上的動作回傳給手機,也把手機的動作鏡射到這裡。
手機的 MIDI 分頁自己會發佈機器的訊號(控制器就住在那裡),所以這一端只做三件事。你用 select 換了機器,就用 relay.config('midi', { profile }) 告訴手機。這台電腦上的旋鈕、推桿、編碼器、彎音輪、觸控條或切換按鈕被硬體或螢幕動了,就把 relay.feedback('midi/<short>/<id>', value) 排進佇列,讓手機上的旋鈕跟著動。手機彈了(midi/virtual),就鏡射在這個螢幕上,只更新狀態和畫面,因為手機已經發佈過訊號了。
@openav/remote 在演出端的主機,和實驗室的 midi 模組,用的就是它。只有在你自己把手機接進頁面時才需要。
參數 名稱 型別 說明 relayObject中繼客戶端,要有 config(name, value) 和 feedback(signalName, value)(演出執行器的 RelayClient)。 signalsSignals手機的 midi/virtual 訊息抵達的匯流排。 controllersMidiControllers要連起來的控制器所屬的管理器。
回傳 { flush(), dispose() } flush() 把佇列中的回饋送給中繼(以穩定的節奏呼叫,例如每個畫面一次)。dispose() 停止監聽。
範例
js 複製
import { Signals } from '@openav/core' ;
import { MidiControllers, PROFILES, linkControllers } from '@openav/midi' ;
const signals = new Signals();
const mcs = new MidiControllers({ profiles: PROFILES, signals, initial: 'akai-lpd8' });
// a stand-in for the relay client: it prints what would go to the phones
const relay = {
config: (name, value) => console.log('config' , name, value),
feedback: (name, value) => console.log('feedback' , name, value.toFixed(2)),
};
const link = linkControllers(relay, signals, mcs);
mcs.current.setValue('k1' , 0.5); // a knob moves on this machine
link.flush(); // feedback midi/lpd8/k1 0.50
mcs.select('korg-nanokontrol2' ); // config midi { profile: 'korg-nanokontrol2' }
link.dispose();
module flush 之後印出 feedback midi/lpd8/k1 0.50,螢幕上的裝置換掉時再印出 config midi { profile: 'korg-nanokontrol2' }。
相關 MidiControllers 把硬體埠跟控制器設定檔接起來:接上 MiniLab 3,螢幕上的 MiniLab 3 就出現並跟著你的手動,拔掉它,虛擬的那台仍然可以玩。 controllerRoutes() 建立「這個控制項推動那個參數」的 Mapper 路由:controllerRoutes(profile, { knob1: 'hue' }) 會把旋鈕的訊號接到參數 hue。 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 import { linkControllers } from '@openav/midi'; packages/midi/link.js 第 11 行
loadProfile()#
函式 @openav/midi loadProfile(ref, o = {})
用 id、短名稱或網址(任何 CORS 允許的 JSON 檔)載入控制器設定檔,你給的檔案或物件會先檢查,回傳 promise。
它就是 resolveProfile() ,只是已經幫你帶入內建設定檔和頁面的基底網址。<oav-controller profile="./my-device.json"> 就是呼叫它來讀你的檔案。檔案有問題時會說明原因,而不是什麼都不畫。
參數 名稱 型別 說明 refString | Objectid('akai-lpd8')、短名稱('lpd8')、設定檔 JSON 的網址或路徑,或是設定檔物件。 oObject轉交給 resolveProfile() 的選項:fetch、base,以及 profiles(預設是所有內建設定檔)。base 預設是頁面的 document.baseURI,所以相對路徑會以頁面為準。 可省略 預設 {}
回傳 Promise<Object> 原始的設定檔。檔案或物件不合法時 reject,並列出所有問題。id 不存在則 reject 為 unknown MIDI profile "x" (known: …)。
範例
js 複製
import { loadProfile } from '@openav/midi' ;
const lpd8 = await loadProfile('lpd8' );
console.log(lpd8.id, lpd8.controls.length);
try { await loadProfile('no-such-box' ); } catch (e) { console.log(e.message.slice(0, 70) + ' …' ); }
module 印出 akai-lpd8 16,再印出不存在的 id 所產生的錯誤訊息開頭(後面還會列出所有認得的 id)。
js 複製
import { loadProfile, profileById } from '@openav/midi' ;
// a profile file of your own; here a fake fetch stands in for the network
const mine = { ...structuredClone(profileById('lpd8' )), id: 'my-lpd8' , short: 'mylpd8' , name: 'My LPD8' };
const fetch = async (url) => { console.log('fetching' , url); return { ok: true , json: async () => mine }; };
const p = await loadProfile('./profiles/my-lpd8.json' , { fetch, base: 'https://example.com/show/' });
console.log(p.name);
module 印出它抓取的完整網址(https://example.com/show/profiles/my-lpd8.json),再印出 My LPD8。
import { loadProfile } from '@openav/midi'; packages/midi/index.js 第 261 行
matchProfile()#
函式 @openav/midi matchProfile(portName, profiles)
用各個設定檔的 match 規則,找出「這個 MIDI 埠名」屬於哪一台機器,例如 'Minilab3 MIDI'。
只考慮 device 設定檔(通用版面永遠不會靠名稱配對)。設定檔的某個 match.ports 正規表示式(不分大小寫)對得上名稱,就算符合。分數從 1 開始,match.prefer 也對上就加 1,match.avoid 對上就扣 2。取最高的正分,所以機器為了別的用途開出來的埠(Minilab3 DIN THRU、Minilab3 MCU/HUI)不會被配對。
MidiControllers 就是靠它把接上的機器變成正確的螢幕控制器。一台機器開出好幾個埠時,由 pickPort() 決定聽哪一個。
參數 名稱 型別 說明 portNameStringMIDI 輸入埠的名稱。 profilesObject[]要搜尋的設定檔,通常是 PROFILES 。
回傳 { profile, score } | null 最佳的符合者。沒有任何 device 設定檔符合時回傳 null。
範例
js 複製
import { matchProfile, PROFILES } from '@openav/midi' ;
for (const name of ['LPD8 mk2' , 'Minilab3 MIDI' , 'Minilab3 DIN THRU' , 'Some Unknown Synth' ]) {
const m = matchProfile(name, PROFILES);
console.log(name, '->' , m ? m.profile.id + ' (score ' + m.score + ')' : null );
}
module 印出 akai-lpd8 (score 1)、arturia-minilab3 (score 2),DIN THRU 埠和不認識的合成器則是 null。
相關 pickPort() 一台機器開出好幾個埠(「Minilab3 MIDI」、「Minilab3 DIN THRU」、「Minilab3 MCU/HUI」)時,挑出要聽的那一個。 MidiControllers 把硬體埠跟控制器設定檔接起來:接上 MiniLab 3,螢幕上的 MiniLab 3 就出現並跟著你的手動,拔掉它,虛擬的那台仍然可以玩。 PROFILES 套件內建的所有控制器設定檔,是一個由普通 JSON 物件組成的陣列:先是真實的機器,再來是通用版面。 import { matchProfile } from '@openav/midi'; packages/midi/profiles.js 第 163 行
new Midi({ signals = null , filterOut = 'IAC' , requestAccess = null , echoWindow = 40, prefer = [] } = {})
Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。
建立之後,await midi.enable()。瀏覽器給了存取權就回傳 true,沒有 Web MIDI(Safari、iPad)或權限被拒絕就回傳 false,原因會寫進記錄(onMessage)。在那之前不會聽也不會送任何東西。
輸入:每個被聆聽的輸入埠送來的每一則訊息,會先交給你用 listen(fn) 加的函式(原始位元組,包含時鐘),再被解析並發佈成 genericSignals() 的名稱。有兩個以上輸入埠時,另外會發佈 midi/<slug>/note/on、midi/<slug>/note/off、midi/<slug>/cc/<n> 和 midi/<slug>/bend,讓不同機器不會互相打架。slug 由埠名產生(Minilab3 MIDI 變成 minilab3-midi),所以機器斷線再接回來,身分不變。時鐘訊息不寫記錄,也不發佈訊號。
輸出:send(bytes)、noteOn()、noteOff()、cc() 都會送到 midi.out(選定的輸出埠),並記下來做回音過濾。那個埠被拔掉時,引擎會換到下一個最好的存活埠(先你用名稱指定的,再 prefer,最後第一個),你指定的埠回來就換回去,而且不相干的新裝置不會搶走輸出。每次切換都會寫一行記錄、一個 console.warn,並通知 onOutput 的訂閱者。
可以指定的掛鉤:onCC(cc, value01, ch)、onNote(note, velocity01, on, ch)、onMessage({ dir, text })(記錄)、onSend(bytes)(看得到所有送出的訊息,做音量表用)、onDeviceChange(devices)。欄位有:enabled、inputs、outputs、out、access、echoDropped。
呼叫之前
要先呼叫 await midi.enable()。有 Web MIDI 的瀏覽器(Chrome、Edge、Firefox)會在這個時候向訪客要權限,所以可以的話請在點擊裡呼叫。Safari 和 iPad 沒有 Web MIDI,enable() 會回傳 false。
參數 名稱 型別 說明 signalsSignals | null把每則輸入訊息發佈成 midi/… 訊號(見 genericSignals() )。不給的話,引擎仍然會呼叫 listen() 的函式和 on… 掛鉤。 可省略 預設 null filterOutString | RegExp | null永遠不聽的輸入埠「名稱」,寫成正規表示式(字串會當成它的內容,不分大小寫)。預設 'IAC' 是 macOS 上你用來送進 Ableton 或 TouchDesigner 的匯流排,這樣你送出去的東西不會又從輸入回來。給 null 就是全部都聽。 可省略 預設 'IAC' requestAccessFunction | nullMIDIAccess 從哪裡來,會以 requestAccess({ sysex: false }) 呼叫,要回傳 promise。預設是 navigator.requestMIDIAccess。測試、轉接層,或是替舊草圖包裝了 Web MIDI 的環境,可以傳自己的函式,並把「真的」那個交到這裡。 可省略 預設 null echoWindowNumber毫秒。輸入的訊息如果跟這個時間內剛「送出」的某一則完全相同,就視為自己的回音而丟掉(計入 echoDropped)。0 表示關閉。 可省略 預設 40 preferArray<String | RegExp>你用名稱指定的埠之後的輸出優先順序:第一個對得上存活埠的項目勝出(字串以子字串比對),都沒有就用第一個埠。例如 ['IAC']。 可省略 預設 []
成員 名稱 說明 devices()method 正在聆聽的輸入埠,給選單用:[{ slug, name, listening }]。名稱重複時,slug 後面會加上 -2、-3。 setListening(slug, on)method 讓某一台裝置靜音或取消靜音,不用拔掉,並通知 onDevices 的訂閱者。 onDevices(fn)method 訂閱裝置清單的變化(插上、拔掉、靜音):fn(devices)。回傳一個取消訂閱的函式。 log(dir, text)method 寫一行記錄,做法是呼叫 onMessage({ dir, text })。引擎用的 dir 是 'in' 和 'out'。 enable(preferredOut = '')method 向瀏覽器要 Web MIDI 並接上輸入埠。preferredOut 是你想用的輸出埠,先比對完整名稱,再比對子字串。成功回傳 true,沒有 Web MIDI 或被拒絕則回傳 false。 onOutput(fn)method 訂閱輸出埠的變化(熱插拔、換回來、你自己呼叫 selectOutput):fn({ text, reason, next, out })。reason 是 found、fallback、back、none 或 select。回傳取消訂閱的函式。 selectOutput(name)method 從現在起送到「名稱完全相同」的輸出埠,並記成「你的選擇」:它被拔掉又插回來時,輸出會回到它。找不到那個名稱就回傳 false。 outputFor(nameOrRe)method 某台裝置接收回應用的輸出埠(做 LED 回饋):名稱跟輸入埠相同的那個,或是名稱符合正規表示式的第一個。沒有就回傳 null。 listen(fn)method 訂閱每個被聆聽的輸入埠的原始位元組:fn(bytes, { slug, name, input })。在解析之前呼叫,時鐘也包含在內。回傳取消訂閱的函式。 send(bytes, { echo = true } = {})method 把原始位元組送到輸出埠和 onSend 掛鉤。除非 echo 是 false,它們也會被記下來,在 echoWindow 毫秒內又原樣回來的訊息會被丟掉。沒有輸出埠時什麼都不做。 noteOn(note, vel = 100, ch = 1)method 送出 note-on。note、vel 是 0..127,ch 是 1..16。 noteOff(note, ch = 1)method 送出 note-off(力度 0)。 cc(cc, val, ch = 1)method 送出控制變更。 dispose()method 放掉所有的埠和監聽函式(例如頁面上移除了一個嵌入)。MIDIAccess 本身屬於瀏覽器,會留著。 panic()method 讓所有聲音停下來:對 16 個頻道各送一次全部音符關閉、全部聲音關閉、延音踏板關閉,再對每個音送 note-off。它走 send(),所以音量表看得到這一輪清除。
注意 createShow() 會替你建立一個並啟用它。createController() 和 <oav-controller> 在同一個頁面上共用一個引擎,所以一頁放五個控制器也只會要一次 MIDI。引擎只送 MIDI 資料,自己不發聲。想聽到音符,另一端要有合成器(接在 IAC 匯流排上的 DAW、硬體合成器),或是用 @openav/sound。 範例
js 複製
import { Midi } from '@openav/midi' ;
// a stand-in for navigator.requestMIDIAccess, so this runs without a device
const output = { name: 'Demo out' , state: 'connected' , send: (bytes) => console.log('out' , bytes) };
const access = { inputs: new Map(), outputs: new Map([['o' , output]]) };
const midi = new Midi({ requestAccess: async () => access });
console.log(await midi.enable()); // true
midi.noteOn(60, 100); // out [144, 60, 100]
midi.noteOff(60); // out [128, 60, 0]
module 印出 true,再印出送到輸出埠的兩則訊息:中央 C 的 note-on 和 note-off。
js 複製
import { Signals } from '@openav/core' ;
import { Midi } from '@openav/midi' ;
const keys = { name: 'Demo Keys' , state: 'connected' , onmidimessage: null };
const access = { inputs: new Map([['k' , keys]]), outputs: new Map() };
const signals = new Signals();
const midi = new Midi({ signals, requestAccess: async () => access });
await midi.enable();
signals.on('midi/cc/74' , (v) => console.log('knob' , v.toFixed(2)));
keys.onmidimessage({ data: Uint8Array.of (0xb0, 74, 127) }); // the keyboard "plays" CC 74 = 127
module 印出 knob 1.00:輸入埠收到的訊息變成了訊號 midi/cc/74。
js 複製
import { Midi } from '@openav/midi' ;
const port = (name) => ({ name, state: 'connected' , send: () => {} });
const iac = port('IAC Driver Bus 1' ), synth = port('Synth' );
const access = { inputs: new Map(), outputs: new Map([['a' , iac], ['b' , synth]]) };
const midi = new Midi({ requestAccess: async () => access });
midi.onOutput(({ reason, text }) => console.log(reason, '-' , text));
await midi.enable('IAC' );
console.log(midi.out.name); // IAC Driver Bus 1
access.outputs.delete('a' ); iac.state = 'disconnected' ;
access.onstatechange(); // what the browser does when a port is unplugged
console.log(midi.out.name); // Synth
module 印出選定的埠,接著一行 fallback,說明輸出換到了「Synth」(引擎也會用 console.warn 印一次),最後印出 Synth。
js 複製
import { Midi } from '@openav/midi' ;
const midi = new Midi();
if (await midi.enable()) {
console.log('inputs:' , midi.devices().map((d) => d.name));
} else {
console.log('no Web MIDI here, or the permission was refused' );
}
static 真正的用法:瀏覽器會詢問 MIDI 權限,接著主控台列出你的輸入裝置(或告訴你沒有 Web MIDI)。
待確認
這一頁沒有實測:各個瀏覽器真實的權限詢問,以及真實的 MIDI 硬體。範例是用假的 MIDIAccess 跑的,瀏覽器的行為(Chrome、Edge、Firefox 會詢問權限,Safari 和 iPad 沒有 Web MIDI)是照套件 README 的說法寫的。
相關 EchoGuard 認出「自己送出的 MIDI 又回來了」:記住送出過什麼,短時間內抵達的相同訊息就是回音,不是演奏者彈的。 pickOutput() 從清單裡挑要送到哪個 MIDI 輸出埠:先是你指定的名稱,其次是偏好清單的第一個符合者,最後才是第一個存活的埠。 reselectOutput() 埠清單改變、或目前的埠斷線之後,決定現在該送到哪裡,並回傳一行要印出來的說明。 genericSignals() 列出一則 MIDI 事件會變成哪些跟裝置無關的訊號:midi/cc/74、midi/note/on、midi/bend,以及各頻道版本,例如 midi/ch/2/cc/74。 createController() 做出一台可以彈、可以聽、可以接硬體或 DAW 的 MIDI 控制器,不需要元素也不需要框架:createController('akai-lpd8')。 MidiControllers 把硬體埠跟控制器設定檔接起來:接上 MiniLab 3,螢幕上的 MiniLab 3 就出現並跟著你的手動,拔掉它,虛擬的那台仍然可以玩。 ScoreMidi 把演出的段落切換和 cue 變成某個頻道上的 MIDI 音符與 CC,讓 DAW 或燈光台能跟著演出走。 import { Midi } from '@openav/midi'; packages/midi/index.js 第 45 行
MidiController#
類別 @openav/midi new MidiController(profile, { signals = null , sink = null , short = null , onLed = null } = {})
把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。
核心想法:螢幕上的控制器和硬體說的是「同一種語言」,MIDI 位元組。setValue()、press() 等虛擬的手,會編出真實控制項會送的那則訊息,再從 ingest() 送進去,也就是硬體走的同一個入口。所以虛擬旋鈕和真的旋鈕不會漂開,作品收到的東西也一樣,不論是哪隻手動的。真實機器的位元組請用 ingest(bytes, { source: 'hw' }) 餵進去。
訊息的 source 決定發佈什麼,讓每個事件只發佈一次。'hw':機器就在這裡,Midi 已經發佈過通用的 midi/… 名稱,所以只多發佈 midi/<short>/…。'ui':虛擬的手,所以會發佈 midi/<short>/…、通用名稱,以及 midi/virtual(原始位元組,給 Web MIDI 轉接用)。'mirror':別人已經發佈過了(手機、回饋),所以只更新狀態和畫面。
每個控制項的訊號(數值已正規化,raw 保留 MIDI 的號碼):midi/<short>/<id>(0..1,雙向彎音輪是 −1..1)和 /raw。旋鈕有 /delta,打擊墊有 /hit 脈衝 { vel, velocity, note, ch } 和 /pressure,琴鍵控制項有 /on、/off 脈衝,按著某個鍵時有 midi/<short>/n<note>,另外還有 midi/<short>/last,這個脈衝說明最後碰了哪個控制項。profileSignals() 能列出某份設定檔的全部名稱。
每個控制項的狀態是 { value, raw, held, pressure, pos },存在 controller.state(用 get(id) 讀)。controller.keys 把每個按著的琴鍵音符對應到力度。controller.profile 是正在使用的副本(學習會改動它),controller.base 是還沒學習之前整理過的設定檔,controller.lastEvent 是最後一則訊息和它的文字,controller.learning 與 controller.learned 記錄學習模式。
參數 名稱 型別 說明 profileObject原始或整理過的設定檔(見 PROFILES )。會被複製一份,之後改動原物件不會有影響。 signalsSignals | null把控制器的訊號發佈到這裡。 可省略 預設 null sinkFunction | null每個訊號改成呼叫 (name, value, { pulse, min, max }) => …,不走 signals(例如中繼、手機)。兩個都給的話以 sink 為準。 可省略 預設 null shortString | null覆寫訊號的命名空間,這樣兩台同型號的控制器就能用不同名稱發佈。 可省略 預設 null onLedFunction | null控制項狀態改變時,用「讓硬體上 LED 亮起或變暗的 MIDI」呼叫 (bytes) => …。 可省略 預設 null
成員 名稱 說明 idgetter 設定檔的 id,例如 'akai-lpd8'。 control(id)method 回傳設定檔裡該控制項的定義(type、msg、ch、cc/note …),沒有這個 id 時回傳 null。 get(id)method 回傳該控制項的狀態 { value, raw, held, pressure, pos },id 不存在則是 undefined。 groups()method 依 TouchDesigner 工具的輸出 CHOP 分組的目前數值:{ knob: [v1, v2 …], pad: […] },琴鍵控制項不在其中。(不同於回傳 id 的 groupsOf() 。) onChange(fn)method 訂閱變化:fn(id, state, info),info 是 { source, ev, note, bytes }。對應或學習狀態改變時,改以 fn(null, null, { meta: true }) 呼叫。回傳取消訂閱的函式。 onMessage(fn)method 訂閱每一則經過的訊息,狀態更新之後只通知一次:fn({ bytes, ev, source, ids }),ids 是它推動的控制項(設定檔不認得的訊息則為空)。嵌入元件在這裡把 source: 'ui' 的訊息轉送到 MIDI 輸出。回傳取消訂閱的函式。 ingest(data, { source = 'hw' } = {})method 把 MIDI 位元組餵進來。source 是 'hw'(預設)、'ui' 或 'mirror'。回傳解析後的事件或 null。時鐘、sysex 和系統訊息會直接回傳,不會動到任何控制項。 setValue(id, v, { source = 'ui' } = {})method 把絕對值控制項(旋鈕、推桿、彎音輪、觸控條、絕對值編碼器)移到 v:0..1,雙向的是 −1..1。回傳送出的位元組,數值沒變則回傳 null。相對編碼器則會轉出對應的步數。 turn(id, steps, { source = 'ui' } = {})method 讓無限旋鈕轉動有正負號的 steps 步,用它設定檔指定的編碼送出。回傳最後送出的位元組。 press(id, vel = 1, note = null, { source = 'ui' } = {})method 用 0 到 1 的力度按下打擊墊、按鈕或琴鍵(打擊墊和琴鍵的力度會限制在 1..127,按鈕一律送 127)。琴鍵要給 note。切換式按鈕會翻轉。回傳位元組。 release(id, note = null, { source = 'ui' } = {})method 放開打擊墊、按鈕或琴鍵(琴鍵要給 note)。切換式按鈕不理會它。回傳位元組。 pressure(id, v, note = null, { source = 'ui' } = {})method 設定打擊墊或琴鍵的壓力(0..1),用複音觸後或頻道觸後,看設定檔怎麼寫。沒有 aftertouch 的控制項不會有動作。 rest(id, { source = 'ui' } = {})method 讓帶彈簧的彎音輪或觸控條回到休息位置:彎音輪回到中心。 releaseAll({ source = 'ui' } = {})method 放開虛擬的手按著的所有琴鍵、打擊墊和瞬間按鈕。視窗失焦、頁面隱藏或切換八度時呼叫,音就不會卡住。 mirror(id, value)method 把別處傳來的值(中繼的回饋)套用到狀態和畫面上,不發佈任何東西。 ledBytes(c, st = this.state.get(c.id), note = null)method 回傳讓該控制項在硬體上的 LED 亮起(或變暗)的 MIDI 位元組,依它的狀態而定。設定檔沒有寫 led 時回傳 null。 ledSnapshot()method 每顆 LED 在目前狀態下的位元組,在硬體連上的時候送一次(待機時的顏色)。 learn(id)method 讓某個控制項進入學習:下一則相容的硬體訊息會綁到它身上。對同一個 id 再呼叫一次就取消。回傳被準備好的 id 或 null。 bindMessage(id, ev)method 把控制項重新綁到訊息 ev,原本佔用那則訊息的控制項會跟它對調。 exportMapping()method 學到的對應,以 JSON 輸出,格式 { 控制項 id: { msg, ch, cc, note, from, to } },只包含跟設定檔不同的部分。可以依裝置存起來(localStorage、檔案)。 applyMapping(m = {})method 套用 exportMapping() 匯出的對應,並把那些控制項標成已學習。 resetMapping()method 回到設定檔原本的綁定,忘掉學過的內容。
範例
js 複製
import { MidiController, profileById } from '@openav/midi' ;
const lpd8 = new MidiController(profileById('lpd8' ));
lpd8.onChange((id, st, info) => id && console.log(info.source, id, st.value.toFixed(2), st.raw));
lpd8.ingest([0xb0, 70, 127], { source: 'hw' }); // the real knob 1 moved to the top
lpd8.setValue('k2' , 0.5); // a finger on the on-screen knob 2
lpd8.press('pad1' , 0.8); // a finger on pad 1
module 印出三行:hw k1 1.00 127、ui k2 0.50 64、ui pad1 0.80 102。同一個模型,三隻手。
js 複製
import { Signals } from '@openav/core' ;
import { MidiController, profileById } from '@openav/midi' ;
const signals = new Signals();
const mc = new MidiController(profileById('minilab3' ), { signals });
signals.on('midi/minilab3/pad3/hit' , (hit) => console.log('pad 3 hit' , hit));
mc.press('pad3' , 0.5);
console.log(signals.get ('midi/minilab3/pad3' )); // 0.50...: velocity while held
mc.release('pad3' );
console.log(signals.get ('midi/minilab3/pad3' )); // 0
module 印出 /hit 脈衝 { vel: 0.50…, velocity: 64, note: 38, ch: 10 },再印出打擊墊按著時與放開後的值。
js 複製
import { MidiController, profileById } from '@openav/midi' ;
// a generic layout learns whatever you move
const mc = new MidiController(profileById('generic-8k8p' ));
mc.learn('k3' ); // arm control k3
mc.ingest([0xb0, 50, 99], { source: 'hw' }); // the next CC from the hardware is bound to it
console.log(JSON.stringify(mc.exportMapping())); // {"k3":{"msg":"cc","ch":1,"cc":50}}
const again = new MidiController(profileById('generic-8k8p' ));
again.applyMapping(mc.exportMapping());
again.ingest([0xb0, 50, 10], { source: 'hw' });
console.log(again.get ('k3' ).raw); // 10
module 印出 k3 學到的綁定,再印出 10:套用了存起來的對應的第二個控制器,對同一個 CC 會有反應。
相關 MidiControllers 把硬體埠跟控制器設定檔接起來:接上 MiniLab 3,螢幕上的 MiniLab 3 就出現並跟著你的手動,拔掉它,虛擬的那台仍然可以玩。 createController() 做出一台可以彈、可以聽、可以接硬體或 DAW 的 MIDI 控制器,不需要元素也不需要框架:createController('akai-lpd8')。 controllerRoutes() 建立「這個控制項推動那個參數」的 Mapper 路由:controllerRoutes(profile, { knob1: 'hue' }) 會把旋鈕的訊號接到參數 hue。 profileSignals() 列出一份設定檔會發佈在它自己命名空間底下的所有訊號名稱(LPD8 是 midi/lpd8/…),給文件、測試和映射畫面用。 parseMessage() 把一則 MIDI 訊息的位元組轉成事件物件,例如 { type: 'noteon', ch: 1, note: 60, vel: 100 },不認得的位元組就回傳 null。 import { MidiController } from '@openav/midi'; packages/midi/controller.js 第 36 行
MidiControllers#
類別 @openav/midi new MidiControllers({ profiles, signals = null , sink = null , midi = null , initial = null , storage = safeStorage() } = {})
把硬體埠跟控制器設定檔接起來:接上 MiniLab 3,螢幕上的 MiniLab 3 就出現並跟著你的手動,拔掉它,虛擬的那台仍然可以玩。
每份設定檔只有一個 MidiController ,第一次被要求時才建立(get(id)),所以虛擬的 MiniLab 和真的 MiniLab 是「同一個物件」:不論哪隻手在動,作品收到的都是同樣的 midi/minilab3/… 訊號。current 是面板正在顯示的那個控制器。
接上 midi 之後,它會用名稱把每個輸入埠對到設定檔(matchProfile() ,一台機器只取一個埠,由 pickPort() 決定),把 current 切到第一台認得的機器,把 LED 顏色送到它的輸出埠,並把每個埠的訊息導向該設定檔的控制器。沒有人認得的埠,可以用 assign(slug, profileId) 手動指定給某份設定檔,這個選擇會依埠名記起來。
它會把資料記在 storage 的 openav.midi.map.<設定檔 id>(學到的對應,建立控制器時還原)和 openav.midi.assign(手動指定的埠)。
參數 名稱 型別 說明 profilesObject[]要管理的設定檔,通常是 PROFILES 。必填,沒給 initial 時會先顯示第一個。 可省略 signalsSignals | null交給它建立的每個 MidiController 。 可省略 預設 null sinkFunction | null交給每個控制器,用法同 MidiController。 可省略 預設 null midiMidi | null要跟隨的 Midi (已啟用或即將啟用)。不給就是沒有硬體,之後再呼叫 attach(midi)。 可省略 預設 null initialString | null硬體出現之前先顯示的設定檔 id。 可省略 預設 null storageStorage | null用來記住學到的對應和手動指定的埠:任何有 getItem、setItem 的東西。預設是頁面有的話就用 localStorage,沒有的話就不記。 可省略 預設 safeStorage()
範例
js 複製
import { Signals } from '@openav/core' ;
import { MidiControllers, PROFILES } from '@openav/midi' ;
const signals = new Signals();
const mcs = new MidiControllers({ profiles: PROFILES, signals, initial: 'akai-lpd8' });
mcs.current.setValue('k1' , 1);
console.log(signals.get ('midi/lpd8/k1' )); // 1
mcs.select('korg-nanokontrol2' );
console.log(mcs.current.id); // korg-nanokontrol2
module 印出 1(螢幕上 LPD8 旋鈕被手指推動,變成了訊號),切換螢幕裝置之後再印出 korg-nanokontrol2。
js 複製
import { Signals } from '@openav/core' ;
import { Midi, MidiControllers, PROFILES } from '@openav/midi' ;
// a fake Minilab3 on a fake MIDIAccess
const keys = { name: 'Minilab3 MIDI' , state: 'connected' , onmidimessage: null };
const access = { inputs: new Map([['k' , keys]]), outputs: new Map() };
const signals = new Signals();
const midi = new Midi({ signals, requestAccess: async () => access });
await midi.enable();
const mcs = new MidiControllers({ profiles: PROFILES, signals, midi });
console.log(mcs.current.id, '<-' , mcs.hardwareFor('arturia-minilab3' ));
keys.onmidimessage({ data: Uint8Array.of (0xb0, 74, 127) }); // the real knob 1
console.log(signals.get ('midi/minilab3/knob1' )); // 1
module 印出 arturia-minilab3 <- Minilab3 MIDI(埠名被認出來了),再印出 1:埠送來的訊息推動了螢幕上 MiniLab 3 的旋鈕 1。
待確認
LED 回饋(機器被認出時送回去的顏色)沒有在真實的硬體上實測。內建設定檔裡有些控制項標著 verified: false:它們的訊息來自說明書,但還沒有人在真實的機器上確認過,可以用學習重新綁定。
相關 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 Midi Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。 matchProfile() 用各個設定檔的 match 規則,找出「這個 MIDI 埠名」屬於哪一台機器,例如 'Minilab3 MIDI'。 pickPort() 一台機器開出好幾個埠(「Minilab3 MIDI」、「Minilab3 DIN THRU」、「Minilab3 MCU/HUI」)時,挑出要聽的那一個。 createController() 做出一台可以彈、可以聽、可以接硬體或 DAW 的 MIDI 控制器,不需要元素也不需要框架:createController('akai-lpd8')。 mountMidiPanel() 掛上一個控制器面板:上面一條工具列,有機器選單、硬體狀態、埠選單、學習按鈕和最後一則訊息的監看,下面是可以彈的面板,可以停靠在底部或填滿。 import { MidiControllers } from '@openav/midi'; packages/midi/manager.js 第 17 行
normalizeProfile()#
函式 @openav/midi normalizeProfile(raw)
驗證設定檔,回傳一份補好預設值的副本,包含頻道、標籤、分組、旋鈕模式和直立版面,傳進去的物件不會被改動。
設定檔不合法時,它會丟出 invalid MIDI profile "<id>":,後面接上每一個問題。合法的話會補上:channel(1)、learn(通用版面為 true)、stack(true)、sources 和 match(空清單),每個控制項的 label(用它的 id)、group(用它的類型)、ch(設定檔的頻道)、旋鈕的 relative(offset64)、按鈕的 mode(momentary)、彎音輪的 bipolar 和 spring(都是 true)。區塊會補上 label、flow(row)和 spans,沒寫 portrait 的話,會由各區塊從上到下排出來,高度當作權重。
MidiController 、ControllerCanvas 和畫面元件用的都是整理後的版本,也就是 controller.profile 給你的那份。
範例
js 複製
import { normalizeProfile, profileById } from '@openav/midi' ;
const raw = profileById('lpd8' );
const p = normalizeProfile(raw);
console.log(raw.channel, '->' , p.channel); // undefined -> 1
console.log(p.controls[0].id, p.controls[0].label, p.controls[0].group);
console.log('learn:' , p.learn, '| portrait bands:' , p.portrait.length);
try { normalizeProfile({ id: 'X' }); } catch (e) { console.log(e.message); }
module 印出它補上的預設值,再印出 { id: 'X' } 有哪些問題的錯誤訊息。
相關 validateProfile() 檢查一個控制器設定檔,把每個問題都寫成一句話回傳。空陣列表示沒問題。 PROFILES 套件內建的所有控制器設定檔,是一個由普通 JSON 物件組成的陣列:先是真實的機器,再來是通用版面。 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 indexProfile() 建立「MIDI 訊息 → 它會推動的控制項」的查詢表:"cc:1:74" 對應到聽頻道 1 的 CC 74 的那顆旋鈕。 import { normalizeProfile } from '@openav/midi'; packages/midi/profiles.js 第 117 行
parseMessage()#
函式 @openav/midi parseMessage(data)
把一則 MIDI 訊息的位元組轉成事件物件,例如 { type: 'noteon', ch: 1, note: 60, vel: 100 },不認得的位元組就回傳 null。
parseMessage([0x90, 60, 100]) 會得到 { type: 'noteon', ch: 1, note: 60, vel: 100 }。頻道編號是 1 到 16,跟機器上印的一樣(狀態位元組裡存的是 0 到 15),資料位元組會被限制在 0..127。這個套件裡其他處理 MIDI 的函式,講的都是這種事件物件。
type 可能是 noteon、noteoff(有 note、vel)、polyat(複音觸後:note、value)、cc(cc、value)、program(value)、chanat(頻道壓力:value)、pitchbend(value 為 0..16383,中心是 8192)。系統訊息則是 clock、start、continue、stop、sysex(附 data,整則訊息)或 system(附 status 位元組)。
力度為 0 的 note-on 會回傳成 noteoff,因為很多機器就是這樣表示「放開」。位元組開頭是資料位元組時回傳 null:瀏覽器在把訊息交給你之前,已經處理過 running status。
參數 名稱 型別 說明 dataArray | Uint8Array「一則」訊息的位元組,格式跟 Web MIDI 給你的一樣(event.data):先是狀態位元組,後面最多兩個資料位元組。
回傳 Object | null 事件物件。輸入是空的、以資料位元組(小於 0x80)開頭,或是它沒處理的頻道狀態時,回傳 null。
範例
js 複製
import { parseMessage } from '@openav/midi' ;
console.log(parseMessage([0x90, 60, 100])); // note on, middle C
console.log(parseMessage([0x90, 60, 0])); // velocity 0 reads as note off
console.log(parseMessage([0xe0, 0, 64])); // pitch bend, centre
module 打開主控台:印出三個事件物件,分別是 noteon、noteoff,以及 value: 8192 的 pitchbend。
js 複製
import { parseMessage } from '@openav/midi' ;
// what a Web MIDI port gives you: event.data is a Uint8Array
const ev = parseMessage(Uint8Array.of (0xb0, 74, 100));
if (ev && ev.type === 'cc' ) {
console.log('CC' , ev.cc, 'on channel' , ev.ch, '=' , (ev.value / 127).toFixed(2));
}
module 印出 CC 74 on channel 1 = 0.79:把控制變更的值換成草圖想要的 0 到 1。
相關 encodeMessage() 把事件物件轉回 MIDI 位元組,是 parseMessage() 的反向:{ type: 'cc', ch: 1, cc: 74, value: 64 } 會變成 [0xb0, 74, 64]。 describe() 把解析過的 MIDI 事件變成一行好讀的文字,給記錄和學習畫面用:CC ch1 #74=64、Bend ch1 0.50。 genericSignals() 列出一則 MIDI 事件會變成哪些跟裝置無關的訊號:midi/cc/74、midi/note/on、midi/bend,以及各頻道版本,例如 midi/ch/2/cc/74。 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 import { parseMessage } from '@openav/midi'; packages/midi/parse.js 第 18 行
pickOutput()#
函式 @openav/midi pickOutput(outputs, { preferred = '' , prefer = [] } = {})
從清單裡挑要送到哪個 MIDI 輸出埠:先是你指定的名稱,其次是偏好清單的第一個符合者,最後才是第一個存活的埠。
順序是:1)名稱符合 preferred 的埠,2)依序比對 prefer 每一項,取第一個符合的埠,3)第一個埠。只考慮存活的埠(見 isLive() ),所以斷線的埠不會被選到。
它是處理普通物件的純函式,所以不用任何硬體就能測這些規則。Midi 引擎是透過 reselectOutput() 來套用它們。
參數 名稱 型別 說明 outputsMIDIOutput[]候選的埠(有 name 和 state 的物件)。 preferredString你指定的埠:先比對完整名稱,再比對子字串。 可省略 預設 '' preferArray<String | RegExp>備用順序。第一個對得上存活埠的項目勝出(字串以子字串比對)。 可省略 預設 []
回傳 MIDIOutput | null 選到的埠。沒有任何存活的埠時回傳 null。
範例
js 複製
import { pickOutput } from '@openav/midi' ;
const port = (name, state = 'connected' ) => ({ name, state });
const outs = [port('Synth A' ), port('IAC Driver Bus 1' ), port('Synth B' )];
console.log(pickOutput(outs).name); // Synth A
console.log(pickOutput(outs, { preferred: 'IAC' }).name); // IAC Driver Bus 1
console.log(pickOutput(outs, { prefer: [/synth b/i] }).name); // Synth B
console.log(pickOutput([port('X' , 'disconnected' )])); // null
module 印出 Synth A、IAC Driver Bus 1、Synth B 和 null:分別是第一個埠、你指定的埠、偏好清單,以及沒有任何存活埠。
相關 reselectOutput() 埠清單改變、或目前的埠斷線之後,決定現在該送到哪裡,並回傳一行要印出來的說明。 isLive() 判斷一個 MIDI 埠還能不能送資料:它存在,而且 state 不是 'disconnected'。 Midi Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。 import { pickOutput } from '@openav/midi'; packages/midi/ports.js 第 18 行
pickPort()#
函式 @openav/midi pickPort(ports, profile)
一台機器開出好幾個埠(「Minilab3 MIDI」、「Minilab3 DIN THRU」、「Minilab3 MCU/HUI」)時,挑出要聽的那一個。
埠名符合設定檔的某個 match.ports 得 1 分(不符合就出局),符合 prefer 加 1 分,符合 avoid 扣 2 分。只算正分,最高分勝出。它是 matchProfile() 之後裝置偵測的第二步。
參數 名稱 型別 說明 portsObject[]候選的埠:有 name 的物件。 profileObject那台機器的設定檔(用它的 match.ports、prefer、avoid)。
回傳 Object | null 最合適的埠。沒有任何一個符合設定檔時回傳 null。
範例
js 複製
import { pickPort, profileById } from '@openav/midi' ;
const ports = [{ name: 'Minilab3 DIN THRU' }, { name: 'Minilab3 MIDI' }, { name: 'Minilab3 MCU/HUI' }];
console.log(pickPort(ports, profileById('minilab3' )).name); // Minilab3 MIDI
console.log(pickPort([{ name: 'Other' }], profileById('minilab3' ))); // null
module 印出 Minilab3 MIDI,埠都不屬於那台機器時印出 null。
相關 matchProfile() 用各個設定檔的 match 規則,找出「這個 MIDI 埠名」屬於哪一台機器,例如 'Minilab3 MIDI'。 MidiControllers 把硬體埠跟控制器設定檔接起來:接上 MiniLab 3,螢幕上的 MiniLab 3 就出現並跟著你的手動,拔掉它,虛擬的那台仍然可以玩。 import { pickPort } from '@openav/midi'; packages/midi/profiles.js 第 180 行
profileById()#
函式 @openav/midi profileById(id)
用 id('akai-lpd8')或訊號用的短名稱('lpd8')找一個內建的控制器設定檔,找不到回傳 null。
只會在 PROFILES 裡找。從網址載入的設定檔不在裡面,請用 resolveProfile() ,它也收 id。它不會整理也不會驗證,需要填好預設值的版本時,請把結果交給 normalizeProfile() 。
參數 名稱 型別 說明 idString設定檔的 id 或 short 短名稱(midi/<short>/… 裡的那一段)。
回傳 Object | null PROFILES 裡的原始設定檔,每次回傳同一個物件,找不到則是 null。
範例
js 複製
import { profileById } from '@openav/midi' ;
const lpd8 = profileById('lpd8' ); // short name
console.log(lpd8.id, '|' , lpd8.name, '|' , lpd8.controls.length, 'controls' );
console.log(profileById('akai-lpd8' ) === lpd8); // true: the same profile by its id
console.log(profileById('no-such-box' )); // null
module 印出 LPD8 的 id、名稱和 16 個控制項,接著 true,最後 null。
import { profileById } from '@openav/midi'; packages/midi/profiles/index.js 第 41 行
PROFILES = array, 27 items
套件內建的所有控制器設定檔,是一個由普通 JSON 物件組成的陣列:先是真實的機器,再來是通用版面。
設定檔把一台控制器寫成資料:長什麼樣子(面板的各個區塊)、上面每個控制項(類型,以及它送出的確切 MIDI 訊息)、機器有支援的 LED 回饋、怎麼認出它的埠名,以及每個數字的出處文件。每個設定檔是 packages/midi/profiles/ 裡的一個 JSON 檔,完整欄位在套件的 README 裡,控制器頁面 則列出全部。
device 設定檔要註明資料來源,並且用名稱跟埠配對,所以接上機器就會自動選到它。generic(通用)版面(generic-8k8p、generic-keys49 …)永遠不會自動配對:你把某台機器指定給它,它會照你轉動的順序學習控制項。
用 profileById() 挑一個(用 id 或短名稱),再交給 createController() 、MidiController 或 MidiControllers 。不要直接修改這些物件,要先複製(structuredClone),withChannel() 就是這樣做的。
型別 Object[]順序就是裝置選單顯示的順序。每一項是原始設定檔(填好預設值的版本見 normalizeProfile() ):有 id、short、name、kind('device' 或 'generic')、face、sections、controls,真實機器還有 match 和 sources。
注意 標著 verified: false 的控制項(並附上 note 說明原因),它的訊息來自說明書或推測,還沒有在真實的機器上確認過。面板上它的標籤旁會有一個橘色的 ?,用學習一下就能重新綁定。 範例
js 複製
import { PROFILES } from '@openav/midi' ;
for (const p of PROFILES.slice(0, 4)) {
console.log(p.id, '|' , p.short, '|' , p.kind, '|' , p.controls.length, 'controls' );
}
console.log(PROFILES.filter((p) => p.kind === 'generic' ).map((p) => p.id));
module 印出前四台機器(id、短名稱、類型、控制項數量),再印出通用版面的 id。
import { PROFILES } from '@openav/midi'; 也能從 @openav/midi/element packages/midi/profiles/index.js 第 35 行
profileSignals()#
函式 @openav/midi profileSignals(p)
列出一份設定檔會發佈在它自己命名空間底下的所有訊號名稱(LPD8 是 midi/lpd8/…),給文件、測試和映射畫面用。
每個控制項有 midi/<short>/<id> 和 /raw。旋鈕多一個 /delta,打擊墊多一個 /hit(有觸後的話再加 /pressure)。琴鍵控制項只有 /on、/off,以及每個琴鍵一個 midi/<short>/n<note>。這些就是 MidiController 發佈的名稱,所以 controllerRoutes() 的路由或 signals.on() 都能用。
參數 名稱 型別 說明 pObject設定檔(會讀它的 short 和 controls)。
回傳 string[] 訊號名稱,第一個是 midi/<short>/last。
範例
js 複製
import { profileSignals, profileById } from '@openav/midi' ;
const names = profileSignals(profileById('lpd8' ));
console.log(names.length, 'signals' );
console.log(names.slice(0, 5));
module 印出 LPD8 能發佈幾個訊號和前五個:midi/lpd8/last,然後是 pad 1 的值、原始值、hit,接著是 pad 2。
相關 controllerRoutes() 建立「這個控制項推動那個參數」的 Mapper 路由:controllerRoutes(profile, { knob1: 'hue' }) 會把旋鈕的訊號接到參數 hue。 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 groupsOf() 依 group(knob、pad、fader …)把設定檔的控制項分組,回傳 { group: [控制項 id] }。 import { profileSignals } from '@openav/midi'; packages/midi/profiles.js 第 193 行
publish()#
函式 @openav/midi publish(signals, list)
把一串 { name, value, pulse?, min?, max? } 發佈到 Signals 登錄表,每個訊號第一次出現時會先宣告。
遇到新的連續訊號,會用 min、max(沒寫就是 0 和 1)和 source: 'midi' 來宣告範圍。脈衝則宣告成 kind: 'pulse'。接著呼叫 signals.pulse(name, value) 或 signals.set(name, value)。
Midi 引擎和 MidiController 已經替你做了這件事。只有在你用別的方式讀 MIDI(例如從 WebSocket)、又想在作品裡得到同樣名稱的時候,才需要自己呼叫。
參數 名稱 型別 說明 signalsSignals@openav/core 的登錄表。給 null 就什麼都不做。 listObject[]通常是 genericSignals() 的結果。pulse: true 會觸發脈衝,其他的會設定數值。
範例
js 複製
import { Signals } from '@openav/core' ;
import { parseMessage, genericSignals, publish } from '@openav/midi' ;
const signals = new Signals();
signals.on('midi/cc/74' , (v) => console.log('cc 74 is now' , v.toFixed(2)));
// bytes from anywhere: a WebSocket, a file, a test
publish(signals, genericSignals(parseMessage([0xb0, 74, 100])));
console.log(signals.get ('midi/ch/1/cc/74' ));
module 印出 cc 74 is now 0.79,再印出從分頻道名稱讀到的同一個值。
相關 genericSignals() 列出一則 MIDI 事件會變成哪些跟裝置無關的訊號:midi/cc/74、midi/note/on、midi/bend,以及各頻道版本,例如 midi/ch/2/cc/74。 parseMessage() 把一則 MIDI 訊息的位元組轉成事件物件,例如 { type: 'noteon', ch: 1, note: 60, vel: 100 },不認得的位元組就回傳 null。 Midi Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 import { publish } from '@openav/midi'; packages/midi/parse.js 第 186 行
RELATIVE_MODES#
常數 @openav/midi RELATIVE_MODES = array, 4 items
無限旋鈕把一次轉動寫成 CC 值的四種方式:offset64、offset16、twos、signbit。
用來檢查或列出編碼方式,例如做設定檔編輯器的時候。validateProfile() 會回報 relative 不在這個清單裡的旋鈕。
型別 string[]
範圍 ['offset64', 'offset16', 'twos', 'signbit']一個普通陣列。relativeDelta() 和 relativeValue() 的 mode 要從裡面挑一個,設定檔的 relative 欄位也必須是其中之一(絕對值旋鈕寫 false)。
範例
js 複製
import { RELATIVE_MODES } from '@openav/midi' ;
console.log(RELATIVE_MODES);
module 印出四個模式名稱。
import { RELATIVE_MODES } from '@openav/midi'; packages/midi/parse.js 第 89 行
relativeDelta()#
函式 @openav/midi relativeDelta(value, mode = 'offset64' )
把無限旋鈕送來的 CC 值轉成有正負號的步數:用 offset64 時,65 是 +1,63 是 −1,64 是沒動。
無限旋鈕沒有位置,它送出的是「轉了多少」,而各家機器的寫法不一樣。offset64:64 是沒動,65 是 +1,63 是 −1。offset16:16 是沒動,17 是 +1,15 是 −1。twos:1..63 是 +1..+63,127..65 是 −1..−63(二的補數)。signbit:1..63 是正的,65..127 是 −1..−63(符號加大小)。
不認得的 mode 會丟出 unknown relative mode "x"。MidiController 對每個設定檔有寫 relative 的 encoder 控制項都用它,並把結果發佈成 midi/<short>/<id>/delta。
參數 名稱 型別 說明 valueNumberCC 的值,0..127(只讀低 7 位元)。 modeString機器怎麼表示一次轉動:RELATIVE_MODES 其中之一。設定檔在控制項的 relative 欄位裡寫明。 可省略 預設 'offset64'
範例
js 複製
import { relativeDelta } from '@openav/midi' ;
console.log(relativeDelta(65)); // 1 (offset64, the default)
console.log(relativeDelta(63)); // -1
console.log(relativeDelta(127, 'twos' )); // -1
console.log(relativeDelta(67, 'signbit' )); // -3
module 印出 1、−1、−1、−3:同一種轉動的四種寫法。
相關 relativeValue() relativeDelta() 的反向:在指定的旋鈕編碼下,代表「轉了這麼多步」的 CC 值。RELATIVE_MODES 無限旋鈕把一次轉動寫成 CC 值的四種方式:offset64、offset16、twos、signbit。 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 import { relativeDelta } from '@openav/midi'; packages/midi/parse.js 第 68 行
relativeValue()#
函式 @openav/midi relativeValue(delta, mode = 'offset64' )
relativeDelta() 的反向:在指定的旋鈕編碼下,代表「轉了這麼多步」的 CC 值。
虛擬的旋鈕送出跟實體旋鈕一樣的編碼,所以螢幕上的控制器和真實機器,在下游看起來完全一樣。relativeValue(-1) 是 63,relativeValue(3, 'twos') 是 3。offset16 的結果會停在 0,所以最多只能往下到 −16。
不認得的 mode 會丟出 unknown relative mode "x"。
參數 名稱 型別 說明 deltaNumber有正負號的步數,會四捨五入並限制在 −63..63。 modeStringRELATIVE_MODES 其中之一。 可省略 預設 'offset64'
範例
js 複製
import { relativeValue, relativeDelta, RELATIVE_MODES } from '@openav/midi' ;
for (const mode of RELATIVE_MODES) {
const cc = relativeValue(-3, mode);
console.log(mode, 'sends' , cc, 'which reads back as' , relativeDelta(cc, mode));
}
module 四行輸出:轉 −3 步在四種編碼下分別送出 61、13、125、67,讀回來都是 −3。
import { relativeValue } from '@openav/midi'; packages/midi/parse.js 第 79 行
reselectOutput()#
函式 @openav/midi reselectOutput({ current, outputs, preferred = '' , prefer = [] })
埠清單改變、或目前的埠斷線之後,決定現在該送到哪裡,並回傳一行要印出來的說明。
只有兩種情況會換:目前的埠不見了或斷線(換到最好的存活埠,或是變成沒有),或是你指定的埠回來了而現在用的是替代品(換回去)。其他情況一律不動:插上不相干的新裝置,不會搶走輸出。說明文字是純 ASCII,例如 MIDI out: switched to "Synth" ("IAC Driver Bus 1" was unplugged)。
Midi 引擎處理輸出熱插拔,靠的就是這條規則。它完全不碰 Web MIDI,所以測試時用普通物件呼叫就行。
參數 名稱 型別 說明 currentMIDIOutput | null現在正在用的埠。 可省略 outputsMIDIOutput[]現在有的埠。 可省略 preferredString你用名稱選的埠(要回去的那一個)。 可省略 預設 '' preferArray<String | RegExp>備用順序,跟 pickOutput() 一樣。 可省略 預設 []
回傳 { next, changed, reason, text } next 是該用的埠,changed 表示是否跟 current 不同,reason 是 same、found、fallback、back 或 none,text 是要記錄的一行(沒變時是空字串)。
範例
js 複製
import { reselectOutput } from '@openav/midi' ;
const iac = { name: 'IAC Driver Bus 1' , state: 'connected' };
const synth = { name: 'Synth' , state: 'connected' };
// the IAC bus is unplugged: only the synth is left
const r = reselectOutput({ current: iac, outputs: [synth], preferred: 'IAC Driver Bus 1' });
console.log(r.reason, r.changed, r.next.name);
console.log(r.text);
// the IAC bus comes back while the synth stands in: go back to your choice
const back = reselectOutput({ current: synth, outputs: [synth, iac], preferred: 'IAC Driver Bus 1' });
console.log(back.reason, back.next.name);
module 印出 fallback true Synth 和它的說明句,再印出 back IAC Driver Bus 1。
相關 pickOutput() 從清單裡挑要送到哪個 MIDI 輸出埠:先是你指定的名稱,其次是偏好清單的第一個符合者,最後才是第一個存活的埠。 isLive() 判斷一個 MIDI 埠還能不能送資料:它存在,而且 state 不是 'disconnected'。 Midi Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。 import { reselectOutput } from '@openav/midi'; packages/midi/ports.js 第 40 行
RESERVED_IDS#
常數 @openav/midi RESERVED_IDS = array, 7 items
設定檔裡不能拿來當控制項 id 的名字,因為它們是訊號的後綴:last、raw、delta、hit、on、off、pressure。
設定檔的 short 另有一份保留字清單(cc、note、bend、ch、virtual、raw、clock),因為它們是通用的 midi/… 家族名稱。兩份都由 validateProfile() 檢查。
型別 string[]
範圍 ['last', 'raw', 'delta', 'hit', 'on', 'off', 'pressure']一個普通陣列。last 是訊號 midi/<short>/last 的名字,其他的是控制項訊號會用的後綴(/raw、/delta、/hit、/on、/off、/pressure),所以 validateProfile() 不接受它們當控制項 id。
範例
js 複製
import { RESERVED_IDS, validateProfile, profileById } from '@openav/midi' ;
console.log(RESERVED_IDS);
const bad = structuredClone(profileById('lpd8' ));
bad.controls[0].id = 'hit' ;
console.log(validateProfile(bad).filter((p) => p.includes('reserved' )));
module 印出清單,再印出把某個控制項改名為 hit 時回報的問題。
import { RESERVED_IDS } from '@openav/midi'; packages/midi/profiles.js 第 30 行
resolveProfile()#
函式 @openav/midi async resolveProfile(ref, { profiles = [], fetch = globalThis.fetch, base = undefined } = {})
把設定檔物件、id 或短名稱,或指向設定檔 JSON 的網址,變成一份設定檔,物件或檔案不合法就 reject,並列出所有問題。
錯誤訊息是寫給人看的:no MIDI profile given、unknown MIDI profile "x" (known: …)、could not load MIDI profile <url> (404),以及 invalid MIDI profile (<where>): 後面接上 validateProfile() 的清單。
參數 名稱 型別 說明 refString | Object設定檔物件(檢查後直接回傳)、id 或短名稱(在 profiles 裡查),或網址(會去抓)。網址指的是以 http:、https:、blob:、data:、/、./、../ 開頭,或以 .json 結尾的字串。 profilesObject[]用來查 id 的清單。預設是空的,所以要傳入 PROFILES (或改用已經幫你傳好的 loadProfile() )。 可省略 預設 [] fetchFunction取代 fetch,給測試或特殊的載入方式用。必須回傳 { ok, status?, json() }。 可省略 預設 globalThis.fetch baseString解析相對路徑用的基底網址。 可省略 預設 undefined
回傳 Promise<Object> 原始的設定檔(沒有整理過)。
範例
js 複製
import { resolveProfile, PROFILES, profileById } from '@openav/midi' ;
console.log((await resolveProfile('nano2' , { profiles: PROFILES })).id); // korg-nanokontrol2
console.log((await resolveProfile(profileById('lpd8' ))).id); // an object is checked and returned
try { await resolveProfile({ id: 'Bad' }); } catch (e) { console.log(e.message.split('\n' ).slice(0, 3).join(' | ' )); }
module 印出 korg-nanokontrol2、akai-lpd8,再印出一個不合法物件的錯誤標題和前兩個問題。
import { resolveProfile } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 111 行
ScoreMidi#
類別 @openav/midi new ScoreMidi({ midi, channel = 15, segment = { note: 36 }, cue = { note: 84 }, length = 0.15, scrub = false , enabled = true , timer = (fn, ms) => setTimeout(fn, ms) } = {})
把演出的段落切換和 cue 變成某個頻道上的 MIDI 音符與 CC,讓 DAW 或燈光台能跟著演出走。
樂譜知道每個段落什麼時候開始、cue 什麼時候經過。外部程式讀不到樂譜,但讀得到 MIDI:用某個音符啟動片段,用某個 CC 切換場景。建立一個 ScoreMidi,然後呼叫 out.segment({ index, cause }) 和 out.cue({ name, n })(createShow({ score: { …, midi: { channel: 15 } } }) 會替你做這件事)。
只有演出在播放時才送標記(cause 是 play 或 release)。reset 永遠不送,seek 和 jump 只有在 scrub: true 時才送。所有訊息都走引擎自己的 noteOn、noteOff、cc,所以 MIDI OUT 音量表看得到,panic() 也關得掉。你不建立它就不會送。
參數 名稱 型別 說明 midiMidi要透過誰送出:一個 Midi (任何有 noteOn、noteOff、cc 的東西都行)。必填,沒給建構時會丟出錯誤。 可省略 channelNumberMIDI 頻道,1..16(其他值會丟出錯誤)。 可省略 預設 15 segmentfalse | Object{ note, cc, velocity }:第 i 段會送出音符 note + i 和(或)CC cc(值為 i)。velocity 預設 100,note 預設 36(只想送 CC 的話,給 note: null)。false 表示不送段落標記。 可省略 預設 { note: 36 } cuefalse | Object{ note, notes, cc, velocity }:每個 cue 會送出 note(或在 notes 對照表裡依 cue 名稱查到的專屬音符)和(或)CC cc,值是 cue 的編號。note 預設 84(只想送 CC 的話,給 note: null)。false 表示不送 cue 標記。 可省略 預設 { note: 84 } lengthNumber標記音符送出後,隔幾秒才送 note-off。標記音符是觸發用的,不是按著的音。 可省略 預設 0.15 scrubBoolean手動拖動播放頭造成的跳轉(seek、jump)也要送標記。預設關閉,這樣拖動時間軸不會在 DAW 裡誤觸發片段。 可省略 預設 false enabledBoolean設成 false(或之後改 out.enabled = false)就什麼都不送。 可省略 預設 true timerFunction排程 note-off 用的 (fn, ms) => …,預設是 setTimeout,測試時可以傳自己的。 可省略 預設 (fn, ms) => setTimeout(fn, ms)
成員 名稱 說明 segmentMessages(evt)method 回傳段落切換「會」送出什麼,[{ type: 'note' | 'cc', number, value }],但不實際送出。這個 cause 不送標記時是空陣列。 cueMessages(evt)method 回傳 cue「會」送出什麼,格式相同。evt 是 { name, n }。 segment(evt)method 送出「某段落開始」的標記。evt 是 { index, cause },cause 為 play、release、seek、jump 或 reset。回傳實際送出的訊息。 cue(evt)method 送出「某個 cue 經過」的標記。evt 是 { name, n }:cue 的名稱和它在剪輯裡的順序。回傳實際送出的訊息。
範例
js 複製
import { ScoreMidi } from '@openav/midi' ;
// anything with noteOn / noteOff / cc will do; here it just prints
const midi = {
noteOn: (note, vel, ch) => console.log('note on ' , note, vel, 'ch' , ch),
noteOff: (note, ch) => console.log('note off' , note, 'ch' , ch),
cc: (cc, val, ch) => console.log('cc' , cc, '=' , val, 'ch' , ch),
};
const out = new ScoreMidi({
midi, channel: 15,
segment: { note: 36, cc: 20 },
cue: { note: 84, notes: { 'dusk.glow' : 90 }, cc: 21 },
});
out.segment({ index: 2, cause: 'play' }); // note 38 and CC 20 = 2
out.cue({ name: 'dusk.glow' , n: 4 }); // note 90 and CC 21 = 4
out.segment({ index: 1, cause: 'seek' }); // nothing: scrub is off
module 印出第 2 段的音符 38 和 CC 20 = 2,cue 的音符 90 和 CC 21 = 4,跳轉不會有任何輸出,0.15 秒後再印出 note-off。
相關 Midi Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。 createController() 做出一台可以彈、可以聽、可以接硬體或 DAW 的 MIDI 控制器,不需要元素也不需要框架:createController('akai-lpd8')。 import { ScoreMidi } from '@openav/midi'; packages/midi/score-out.js 第 23 行
unitToBend()#
函式 @openav/midi unitToBend(u)
把 −1..1 的數字轉成 14 位元的彎音輪數值(0..16383),是 bendToUnit() 的反向。
它不會把 u 限制在 −1..1,可能超出範圍的話請先自己限制。不過 encodeMessage() 最後會把數值限制在 0..16383,所以用超出範圍的數字組成的彎音訊息仍然是合法的。
參數 名稱 型別 說明 uNumber位置,從 −1(最下)到 1(最上),0 是中心。
回傳 Number 四捨五入後的 14 位元數值:unitToBend(0) 是 8192,unitToBend(1) 是 16383。
範例
js 複製
import { unitToBend, encodeMessage } from '@openav/midi' ;
console.log(unitToBend(-1), unitToBend(0), unitToBend(1)); // 0 8192 16383
console.log(encodeMessage({ type: 'pitchbend' , ch: 1, value: unitToBend(0.5) }));
module 印出 0 8192 16383,再印出「推到一半」的彎音訊息的三個位元組。
相關 bendToUnit() 把 14 位元的彎音輪數值(0..16383)轉成 −1..1,中心(8192)剛好是 0。 BEND_CENTER 彎音輪休息時的數值:8192,也就是 14 位元範圍 0..16383 的中間。 encodeMessage() 把事件物件轉回 MIDI 位元組,是 parseMessage() 的反向:{ type: 'cc', ch: 1, cc: 74, value: 64 } 會變成 [0xb0, 74, 64]。 import { unitToBend } from '@openav/midi'; packages/midi/parse.js 第 93 行
validateProfile()#
函式 @openav/midi validateProfile(p)
檢查一個控制器設定檔,把每個問題都寫成一句話回傳。空陣列表示沒問題。
它會一次回報所有問題,不是只講第一個。檢查的項目有:id 和 short 是小寫的 [a-z0-9_-],而且 short 不是保留的家族名稱(cc、note、bend、ch、virtual、raw、clock)。有 name 和 kind。device 至少註明一個來源。match 的正規表示式能編譯。face 有尺寸。每個控制項的 id 不重複也不是保留字(見 RESERVED_IDS ),有認得的 type 和 msg,數字在範圍內,且沒有兩個控制項聽同一則訊息。旋鈕要寫明 relative 模式。sections(和有給的話 portrait)要讓每個控制項剛好出現一次,位在面板內,並且塞得進它的格子。
normalizeProfile()(用這份清單丟出錯誤)、resolveProfile(),以及驗證所有內建設定檔的測試,背後都是這個檢查。把自己的檔案放進網頁之前,先跑一次。
參數 名稱 型別 說明 pObject原始的設定檔物件(例如從你的 JSON 檔解析出來的)。
回傳 string[] 用白話寫的問題清單。[] 表示合法。
範例
js 複製
import { validateProfile, profileById } from '@openav/midi' ;
console.log(validateProfile(profileById('lpd8' ))); // [] : valid
const mine = {
id: 'My Box' , short: 'cc' , name: 'My box' , kind: 'device' , face: { w: 10, h: 10 },
controls: [{ id: 'k1' , type: 'knob' , msg: 'cc' , cc: 200 }], sections: [],
};
for (const problem of validateProfile(mine)) console.log('-' , problem);
module 內建設定檔印出 [],接著列出自己亂寫的那個有什麼問題(id 不合法、短名稱是保留字、沒有來源、CC 超出範圍、控制項沒有被顯示)。
相關 normalizeProfile() 驗證設定檔,回傳一份補好預設值的副本,包含頻道、標籤、分組、旋鈕模式和直立版面,傳進去的物件不會被改動。 resolveProfile() 把設定檔物件、id 或短名稱,或指向設定檔 JSON 的網址,變成一份設定檔,物件或檔案不合法就 reject,並列出所有問題。 CONTROL_TYPES 設定檔裡可以出現的控制項種類:knob、encoder、fader、pad、button、keys、wheel、strip。 RESERVED_IDS 設定檔裡不能拿來當控制項 id 的名字,因為它們是訊號的後綴:last、raw、delta、hit、on、off、pressure。 import { validateProfile } from '@openav/midi'; packages/midi/profiles.js 第 38 行
virtualRequestMIDIAccess()#
函式 @openav/midi virtualRequestMIDIAccess({ real = null , make, waitMs = 1200 })
包裝 navigator.requestMIDIAccess,讓每個呼叫者都拿到虛擬的 access:真實的埠有及時回應就併進來,就算沒有 Web MIDI 或被拒絕,虛擬輸入也還在。
真實請求在 waitMs(預設 1200 毫秒)內回應的話,它的埠一開始就在。晚一點回應,就以 statechange 事件熱插拔進來。被拒絕或不存在(Safari、iPad)時,虛擬輸入還是在。沒有人回答的權限詢問不會卡死草圖:waitMs 一到它就會拿到 access。
參數 名稱 型別 說明 realFunction | null原本的 requestMIDIAccess,要先綁好 this(navigator.requestMIDIAccess.bind(navigator)),沒有的話給 null。真實的請求仍然會進行,權限詢問也一樣。 可省略 預設 null makeFunction(opts) => …,回傳 createVirtualMIDIAccess() 的物件。必填。每次回傳同一個就能共用。 可省略 waitMsNumber最多等真實請求多久,時間到就先回覆。 可省略 預設 1200
回傳 Function 一個函式 (opts?) => Promise<MIDIAccess>,放到原本 navigator.requestMIDIAccess 的位置。
範例
js 複製
import { createVirtualMIDIAccess, virtualRequestMIDIAccess } from '@openav/midi' ;
const virtual = createVirtualMIDIAccess({ name: 'Demo MIDI' });
const requestMIDIAccess = virtualRequestMIDIAccess({ real: null , make: () => virtual });
// this is what an old sketch does, unchanged
const access = await requestMIDIAccess();
console.log([...access.inputs.values()].map((p) => p.name)); // [ 'Demo MIDI' ]
console.log(access === virtual.access, virtual.requests); // true 1
module 印出 ['Demo MIDI'],再印出 true 1:呼叫者拿到的是虛擬的 access,而且只被要求過一次。
js 複製
import { createVirtualMIDIAccess, virtualRequestMIDIAccess } from '@openav/midi' ;
const virtual = createVirtualMIDIAccess();
navigator.requestMIDIAccess = virtualRequestMIDIAccess({
real: navigator.requestMIDIAccess ? navigator.requestMIDIAccess.bind(navigator) : null ,
make: () => virtual,
});
// from now on every sketch on the page that asks for MIDI gets the virtual input too
static 真正的接法:放在草圖執行之前,它舊的 MIDI 程式碼就不用改。
相關 createVirtualMIDIAccess() 建立一個 Web MIDI 的 MIDIAccess,它永遠多出一組自己的輸入和輸出,讓寫給 MIDI 鍵盤的草圖在沒有接任何機器時也能動。 Midi Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。 import { virtualRequestMIDIAccess } from '@openav/midi'; packages/midi/virtual-access.js 第 203 行
withChannel()#
函式 @openav/midi withChannel(profile, ch)
回傳一份設定檔的副本,所有控制項都改到另一個 MIDI 頻道,給「機器設定的頻道跟設定檔不同」的情況用。
明確寫出的 LED 頻道(led.ch)會維持原樣,因為 LED 回饋用的頻道可能跟控制項不同。createController() 的 channel 選項和 <oav-controller> 的 channel 屬性,呼叫的就是它。
參數 名稱 型別 說明 profileObject設定檔。 chNumber頻道,1..16。給假值(0、null)就原封不動回傳同一個設定檔物件。
回傳 Object 深層複製的副本,已通過驗證,channel 和每個控制項的 ch 都改好了。原本的不會被改動。
範例
js 複製
import { profileById } from '@openav/midi' ;
import { withChannel } from '@openav/midi/embed' ;
const lpd8 = profileById('lpd8' );
const moved = withChannel(lpd8, 3);
console.log(lpd8.controls[0].ch, '->' , moved.controls[0].ch); // 10 -> 3
console.log(moved.controls.every((c) => c.ch === 3), moved.channel); // true 3
module 印出 10 -> 3:內建的設定檔頻道不變,副本的每個控制項都在頻道 3。
相關 createController() 做出一台可以彈、可以聽、可以接硬體或 DAW 的 MIDI 控制器,不需要元素也不需要框架:createController('akai-lpd8')。 OavController <oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。normalizeProfile() 驗證設定檔,回傳一份補好預設值的副本,包含頻道、標籤、分組、旋鈕模式和直立版面,傳進去的物件不會被改動。 import { withChannel } from '@openav/midi/embed'; packages/midi/embed.js 第 133 行
控制器面板與嵌入#
<oav-controller>、iframe 嵌入、在畫布上畫控制器。 39
@openav/midi
CANVAS_THEME#
常數 @openav/midi CANVAS_THEME = object, 16 keys
ControllerCanvas 的預設外觀:背景、機身、文字、待機顏色、accent、glow 和字型。把任何子集當作 theme 選項傳入即可。
顏色都是 CSS 顏色字串。bg: null 讓底下的影片或頁面透出來,accent: '#7ea6ff' 則讓整個面板只用一個顏色,很適合有品牌色的影片。
型別 Object凍結的物件(不能修改,要用 theme 選項覆寫)。鍵有:bg(機器周圍的區域,null 表示保留底下原本的畫面)、body(面板,null 用設定檔的 face.body)、ink(大字)、label(控制項標籤)、dim(面板與區塊的文字)、idle(待機的打擊墊)、button、cap(旋鈕蓋)、well(推桿溝槽和觸控條底)、screen、screenInk、screenDim、accent(所有會動的東西用同一個顏色,null 保留各控制項自己的顏色)、glow(0 是平面,1 是跟螢幕控制器一樣的光暈),以及字型 font 和 mono。
範例
js 複製
import { CANVAS_THEME, ControllerCanvas } from '@openav/midi' ;
console.log(CANVAS_THEME.bg, CANVAS_THEME.accent, CANVAS_THEME.glow);
// a transparent, one-colour, flat panel
const panel = new ControllerCanvas({ profile: 'lpd8' , theme: { bg: null , accent: '#ffb547' , glow: 0 } });
console.log(panel.theme.bg, panel.theme.accent, panel.theme.glow, panel.theme.ink);
module 印出預設值(#060709 null 1),再印出面板的外觀:你指定的三個鍵被取代,其他(例如 ink)維持原樣。
import { CANVAS_THEME } from '@openav/midi'; packages/midi/canvas.js 第 43 行
controlDetail()#
函式 @openav/midi controlDetail(mc, id, st, info = {})
為一次控制項變化組出 control 事件的內容:id、value、raw、signal、解析後的 message、bytes,以及它從哪裡來。
一定會有的欄位:profile(設定檔 id)、id、type、label、value、raw、signal、message、bytes、source、time(performance.now(),取整數)。value 是 0..1(雙向彎音輪是 −1..1),打擊墊在按著時是力度、放開是 0,按鈕是 0 或 1,琴鍵則是那個音的力度。raw 是 MIDI 的號碼(0..127,彎音是 0..16383)。signal 是 open-audiovisual 其他部分用的名稱(midi/lpd8/k1,琴鍵是 midi/minilab3/n60)。message 是解析後的 MIDI({ type: 'cc', ch: 1, cc: 70, value: 53 }),bytes 是原始位元組。source 是 'ui'(手指、滑鼠、set())、'hardware'(真實的埠,或你 ingest() 的位元組)或 'mirror'。
有時才有的欄位:note 和 velocity(音符訊息,放開時 velocity 為 0)、delta(相對旋鈕:有正負號的步數)、held(打擊墊或按鈕)、pressure(打擊墊,大於 0 時才有)。control、noteon、noteoff 事件把它當作 detail。
參數 名稱 型別 說明 mcMidiController發生變化的控制器。 idString控制項 id。 stObject變化之後的狀態(mc.get(id))。 infoObjectonChange 給的內容:{ source, ev, note, bytes }。 可省略 預設 {}
回傳 Object 事件內容,是可以安全 postMessage 的純 JSON。
範例
js 複製
import { MidiController, profileById } from '@openav/midi' ;
import { controlDetail, describeDetail } from '@openav/midi/embed' ;
const mc = new MidiController(profileById('minilab3' ));
mc.onChange((id, st, info) => {
if (!id) return ;
const detail = controlDetail(mc, id, st, info);
console.log(describeDetail(detail));
console.log(detail.bytes, detail.source, detail.signal);
});
mc.setValue('knob1' , 0.42);
module 印出讀數列的文字 midi/minilab3/knob1 = 0.42 ← CC 74 = 53 · ch 1,再印出位元組 [176, 74, 53]、ui 和訊號名稱。
相關 describeDetail() 為 control 事件的內容寫出讀數列的文字:midi/minilab3/knob1 = 0.42 ← CC 74 = 53 · ch 1。 describeMessage() 把一則解析過的 MIDI 訊息寫成人讀得懂的樣子:CC 70 = 53、Note 36 · vel 90、Bend 12288。 OavController <oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。ControllerHost createController() 回傳的物件:一台控制器,包含事件、可以彈的面板、硬體連線和 MIDI 輸出。toParent() iframe 這一側:為一個事件組出 iframe 要送給外層頁面的訊息:{ source: 'openav', v: 1, type, frame, detail }。 import { controlDetail } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 160 行
ControllerCanvas#
類別 @openav/midi new ControllerCanvas({ profile = 'arturia-minilab3' , signals = null , controller = null , mapping = null , theme = {}, notes = 'auto' , screen = true , padding = 0.05, now = defaultNow } = {})
把控制器的面板畫進任何 2D 畫布,每個畫面都畫,任何大小都行:演出影片的下半部、疊圖、縮圖。
螢幕上的控制器是 DOM:很適合彈,卻沒辦法錄,因為網頁不能把自己的 DOM 錄進影片。這個類別把同一個面板直接畫進 2D 畫布,每個區塊都在機器上原本的位置,用同樣的顏色。它是向量,直接以輸出的大小繪製,所以 4K 畫面跟 1080 一樣清楚,每個畫面約花幾百次路徑呼叫。@openav/record 的 Compositor#setPanel 就是接收它。
每個畫面呼叫 panel.draw(ctx, { x, y, w, h }),或用 panel.renderTo(canvas) 畫滿一張自己的畫布。影片需要而螢幕不需要的東西:一次觸碰要在畫面上留幾格(30 fps 是 33 毫秒,很短的斷音可能比這還短),所以放開的琴鍵和打擊墊會淡出而不是直接熄掉,剛被手動過的控制項會發光,鍵盤也會跟著機器的八度按鈕移動。panel.aspect 是面板的寬除以高,可用來預留相同形狀的區域。signals 和 controller 都沒給時,面板就只是靜止不動。
參數 名稱 型別 說明 profileString | Object設定檔 id、短名稱或物件。不認得的 id 會丟出 ControllerCanvas: unknown profile "x"。 可省略 預設 'arturia-minilab3' signalsSignals | null跟隨這條匯流排上的通用 MIDI 名稱(midi/ch/<ch>/…、midi/note/on|off、midi/cc/<n>、midi/bend):實際的硬體、螢幕控制器、電腦鍵盤鋼琴、手機,以及正在回放的錄音,都會讓面板動起來。硬體的一則訊息雖然用兩個名稱發佈,也只會算一次。 可省略 預設 null controllerMidiController | null改為跟隨這個現有的控制器,不聽匯流排:正好就是那一台,連同它學到的對應。錄音回放不會傳到它。有給的話會忽略 signals。 可省略 預設 null mappingObject | null要套用到面板自己的控制器上的學習結果:就是實機控制器的 exportMapping()。 可省略 預設 null themeObjectCANVAS_THEME 的任何子集:顏色、glow、字型。 可省略 預設 {} notesBoolean | 'auto'把按著的音大大地寫在面板下面。'auto' 是區域有空間時才寫,true 會擠出空間,false 永遠不寫。 可省略 預設 'auto' screenBoolean機器自己的螢幕(MiniLab 3、KeyStep)顯示最後一次碰了什麼,跟實機一樣:Knob 3 · 87、C4 · 100。 可省略 預設 true paddingNumber面板周圍的留白,以區域短邊的比例表示。 可省略 預設 0.05 nowFunction淡出效果用的時鐘(毫秒),預設是 performance.now()。傳給 draw() 的 now 要用同一個時鐘。 可省略 預設 defaultNow
注意 它是個安靜的模型:不會往匯流排發佈任何東西。panel.dispose() 會停止聽匯流排或控制器(你傳進來的控制器不會被動到)。 範例
js 複製
import { Signals } from '@openav/core' ;
import { ControllerCanvas, genericSignals, publish } from '@openav/midi' ;
const signals = new Signals();
const panel = new ControllerCanvas({ profile: 'minilab3' , signals });
// a chord, a pad and a knob, published as the hardware would
const play = (ev) => publish(signals, genericSignals(ev));
play({ type: 'noteon' , ch: 1, note: 60, vel: 100 });
play({ type: 'noteon' , ch: 1, note: 64, vel: 100 });
play({ type: 'noteon' , ch: 10, note: 36, vel: 127 });
play({ type: 'cc' , ch: 1, cc: 74, value: 100 });
panel.renderTo(document.getElementById('c' )); // fills the 400 x 300 canvas
module Arturia MiniLab 3 被畫進畫布,C4 和 E4 亮著,pad 1 亮著,旋鈕 1 轉大了。機器的螢幕寫著 Knob 1 · 100。
js 複製
import { Signals } from '@openav/core' ;
import { ControllerCanvas, genericSignals, publish } from '@openav/midi' ;
const signals = new Signals();
const panel = new ControllerCanvas({ profile: 'lpd8' , signals, theme: { accent: '#7ea6ff' } });
const ctx = document.getElementById('c' ).getContext('2d' );
const send = (ev) => publish(signals, genericSignals(ev));
let frame = 0;
function tick() {
const pad = 36 + Math.floor(frame / 20) % 8;
if (frame % 20 === 0) send({ type: 'noteon' , ch: 10, note: pad, vel: 110 }); // hit a pad
if (frame % 20 === 8) send({ type: 'noteoff' , ch: 10, note: pad }); // let go: it fades out
panel.draw(ctx, { x: 0, y: 0, w: 400, h: 300 });
if (++frame < 120) requestAnimationFrame(tick);
}
tick();
module LPD8 的打擊墊每三分之一秒換一個亮起,然後淡出,全部是同一個強調色,就像錄進影片時的樣子。
相關 controllerGeometry() 算出設定檔的面板畫進某個像素矩形時,每樣東西的位置:面板、每個區塊、每個控制項的格子,以及音名條。 CANVAS_THEME ControllerCanvas 的預設外觀:背景、機身、文字、待機顏色、accent、glow 和字型。把任何子集當作 theme 選項傳入即可。followMidiSignals() 監聽 Signals 匯流排,並把每則 MIDI 訊息「剛好一次」以事件的形式還給你,雖然硬體會用兩個名稱發佈同一則訊息。 ControllerView 控制器在螢幕上可以彈的面板:旋鈕、推桿、打擊墊、琴鍵,可以用滑鼠、手指或鍵盤操作,畫面隨著控制器的狀態重畫。 eventOfSignal() genericSignals() 的反向:把一個通用的 MIDI 訊號(midi/ch/10/note/36、midi/cc/74)轉回它原本的 MIDI 事件。MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 import { ControllerCanvas } from '@openav/midi'; packages/midi/canvas.js 第 257 行
controllerGeometry()#
函式 @openav/midi controllerGeometry(profile, rect, { padding = 0.05, notes = 'auto' } = {})
算出設定檔的面板畫進某個像素矩形時,每樣東西的位置:面板、每個區塊、每個控制項的格子,以及音名條。
面板維持自己的比例,置中在矩形裡。區塊的排法跟螢幕控制器的 CSS grid 一致(橫向或直向排列、跨格),所以畫布上的繪製和 DOM 版本一致。它是純函式,適合拿來測試,也適合在你想在某個控制項上疊自己畫的東西時使用。
參數 名稱 型別 說明 profileObject整理過的設定檔(MidiController#profile)。 rect{ x, y, w, h }區域,單位是像素。 paddingNumber面板周圍的留白,以矩形短邊的比例表示(0..1)。 可省略 預設 0.05 notesBoolean | 'auto'在面板下面預留一條放按著的音的位置:true 一定有,false 一定沒有,'auto' 是矩形有空間時才有。 可省略 預設 'auto'
回傳 { u, face, notes, sections, controls, deco } u 是一個面板單位的像素大小。face 是 { x, y, w, h }。notes 是那一條或 null。sections 是帶有 id、label、外框和 grid 的清單。controls 是從控制項 id 對到 { id, type, section, x, y, w, h } 的 Map。deco 列出以像素表示的裝飾(螢幕、標誌、文字)。
範例
js 複製
import { controllerGeometry, normalizeProfile, profileById } from '@openav/midi' ;
const profile = normalizeProfile(profileById('lpd8' ));
const g = controllerGeometry(profile, { x: 0, y: 0, w: 400, h: 300 });
console.log(g.face); // the faceplate, centred, in pixels
console.log(g.controls.get ('pad1' )); // where pad 1 is
console.log(g.controls.size, 'controls' , g.notes ? 'with a notes strip' : 'without a notes strip' );
module 印出面板的矩形(寬 370,置中)、pad 1 的格子位置(像素),再印出 16 controls with a notes strip。
import { controllerGeometry } from '@openav/midi'; packages/midi/canvas.js 第 81 行
ControllerHost#
類別 @openav/midi new ControllerHost(profile = 'arturia-minilab3' , { profiles = PROFILES, signals = null , sink = null , channel = null , follow = false , midi = null , requestAccess = null , filterOut = 'IAC' , storage = undefined , telemetry = true , embedKind = 'headless' } = {})
createController() 回傳的物件:一台控制器,包含事件、可以彈的面板、硬體連線和 MIDI 輸出。
事件用 ctl.on(name, fn) 訂閱(回傳取消訂閱的函式):control(任何旋鈕、打擊墊、琴鍵、按鈕的變化)、noteon 和 noteoff(內容相同,給打擊墊、琴鍵和音符按鈕)、connect 和 disconnect({ port, profile }:某個硬體埠開始或停止驅動它)、status、profilechange({ profile, name }),以及 '*',所有事件都會以 (name, detail) 通知它。control 的內容見 controlDetail() 。ready 和 error 只有 <oav-controller> 元素才有。
ctl.status 是 { hardware, input, output, outputState }。hardware 可能是 off、asking、waiting(在聽,但機器沒接上)、connected、unavailable(瀏覽器沒有 Web MIDI)或 denied(權限被拒絕)。outputState 可能是 off、asking、open、missing(沒有符合的埠)、unavailable 或 denied。ctl.profileId、ctl.controller、ctl.profile 告訴你螢幕上是哪台機器,ctl.controllers、ctl.view、ctl.midi、ctl.output 則是底下的物件。
螢幕上的手彈出來的,會從輸出埠以真正的 MIDI 送出。真實硬體的動作不會被轉送(你的軟體本來就聽得到那台機器),名稱跟所選輸出埠相同的輸入(loopMIDI 的回音)會被靜音。
呼叫之前
只有 connectHardware()、setOutput()、requestMidi() 會向瀏覽器要 MIDI。Chrome、Edge、Firefox 會跳出權限詢問,所以可以的話請在點擊裡呼叫。沒有 Web MIDI(Safari、iPad)時,控制器仍然能彈、能觸發事件,status 會顯示 unavailable。
參數 名稱 型別 說明 profileString | Object設定檔 id、短名稱或物件。 可省略 預設 'arturia-minilab3' profilesObject[]選單或 follow 可以切換到的設定檔。預設是所有內建的。 可省略 預設 PROFILES signalsSignals | null同時把 midi/<short>/… 發佈進演出。 可省略 預設 null sinkFunction | null改由這個函式發佈(中繼),用法同 MidiController 。 可省略 預設 null channelNumber | null把所有控制項移到這個 MIDI 頻道(你的機器設定在別的頻道時)。見 withChannel() 。 可省略 預設 null followBoolean接上另一台認得的機器時,就改顯示那一台。 可省略 預設 false midiMidi | null改用這個引擎(演出的),不用頁面共用的。 可省略 預設 null requestAccessFunction | nullMIDIAccess 從哪裡來,用法同 Midi 。給了它,控制器就會用自己的引擎。 可省略 預設 null filterOutString | RegExpMidi 的輸入埠過濾。任何不是預設 'IAC' 的值,也會讓控制器用自己的引擎。 可省略 預設 'IAC' storageStorage | null學到的對應存在哪裡,用法同 MidiControllers 。預設是有 localStorage 就用它。 可省略 預設 undefined telemetryBooleanfalse 就不送匿名的頁面載入資料。 可省略 預設 true embedKindString這次嵌入屬於哪一種,供那筆資料用:'headless'(預設)、'element' 或 'iframe'。 可省略 預設 'headless'
成員 名稱 說明 controllergetter 螢幕上那台機器的活模型(MidiController )。 profilegetter 它整理過的設定檔:控制項、面板、資料來源。 on(name, fn)method 訂閱 control、noteon、noteoff、connect、disconnect、status、profilechange 或 '*'。fn(detail)('*' 則是 fn(name, detail))。回傳取消訂閱的函式。 set(id, value, { note = null, quiet = false } = {})method 像一隻手去動控制項:旋鈕和推桿 0..1,雙向彎音輪 −1..1,打擊墊和按鈕大於 0 就按下(用那個力度),0 就放開,琴鍵用 set('keys', velocity, { note: 60 })。quiet: true 時只有面板會動:沒有事件,也不送 MIDI(雙向綁定用)。回傳送出的位元組,沒有變化則回傳 null。見 setControl() 。 press(id, velocity = 1, note = null)method 按下打擊墊、按鈕或琴鍵。 release(id, note = null)method 放開它。 get(id)method 某個控制項目前的值(0..1,雙向是 −1..1),id 不存在則回傳 null。 values()method 每個控制項的 { id: value }(琴鍵:任何一個鍵按著就是 1)。見 valuesOf() 。 reset({ quiet = false } = {})method 放開所有東西,讓每個控制項回到原位(旋鈕和推桿回到 0,雙向彎音輪回到中心),並送出一隻手會送出的訊息。quiet: true 時只動面板。 releaseAll()method 放開所有按著的打擊墊和琴鍵,用在視窗失焦或頁面隱藏時,這樣 DAW 才收得到 note-off。 ingest(bytes, { source = 'hardware' } = {})method 從任何地方來的 MIDI(WebSocket、另一個頁面、你自己管理的埠):面板會跟著動。可以是一則訊息([0xb0, 74, 64])或一串訊息。source 是 'hardware'(預設)、'ui' 或 'quiet'。回傳解析後的事件,或事件的陣列。 send(bytes)method 把原始位元組送出 MIDI 輸出埠(有開的話)。送出了回傳 true,否則 false。 mount(el, { layout = 'auto' } = {})method 把可以彈的面板畫進 el,它會填滿 el 並跟著它的大小走,所以 el 要有高度。回傳 ControllerView 。layout 是 auto、face 或 stack。 setLayout(layout)method 改變已掛載畫面的版面:auto、face 或 stack。 learn(on = !this.learning)method 學習模式:點畫面上的一個控制項,再動硬體上對應的那一個(或用 ingest() 餵)。預設是切換,learn(true)、learn(false) 則直接設定。回傳新的狀態。 setProfile(ref)method 改顯示另一台機器:id、短名稱、網址或設定檔物件。按著的音會先被放開。完成時回傳新的控制器並觸發 profilechange。設定檔不存在或不合法時 reject 並附上原因。 connectHardware(port = null)method 跟隨真實的機器:它的埠會驅動螢幕上那一台(向瀏覽器要 Web MIDI)。port 是名稱或 /regex/,用來手動指定改過名字或不認得的機器。有 MIDI 就回傳 true,否則 false,再看 status.hardware 是 connected 還是 waiting。 disconnectHardware()method 停止跟隨硬體(status.hardware 變成 off)。頁面共用的 MIDI 引擎,在頁面上沒有別的東西需要它時會被放掉。 requestMidi()method 向瀏覽器要 Web MIDI 但不連接任何東西,因為輸出選單需要埠的清單。完成時回傳輸出埠名稱。 outputs()method Web MIDI 開啟之後的輸出埠名稱,之前是空陣列。 setOutput(query)method 把這裡彈的內容送到某個 MIDI 輸出埠:名稱、子字串('IAC'、'loopMIDI')或 /regex/,比對方式同 findPort() 。給 null 就關閉。完成時回傳埠名,找不到則為 null(status.outputState 會說明原因)。 dispose()method 移除這個控制器加上去的所有東西:監聽、畫面、它在 MIDI 引擎中的份。按著的音會先被放開。
範例
js 複製
import { ControllerHost } from '@openav/midi' ;
// same as createController('lpd8'); the class is exported for instanceof and subclassing
const ctl = new ControllerHost('lpd8' , { channel: 3 });
console.log(ctl instanceof ControllerHost, ctl.profileId, ctl.status.hardware);
console.log(ctl.set ('k1' , 1)); // [178, 70, 127]: channel 3 is status byte 0xb2
await ctl.setProfile('nano2' ); // show another device
console.log(ctl.profileId, ctl.profile.controls.every((c) => c.ch === 3));
module 印出 true akai-lpd8 off,再印出旋鈕 1 在頻道 3 的位元組,最後是換過的新機器,頻道覆寫仍然有效。
相關 createController() 做出一台可以彈、可以聽、可以接硬體或 DAW 的 MIDI 控制器,不需要元素也不需要框架:createController('akai-lpd8')。 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 ControllerView 控制器在螢幕上可以彈的面板:旋鈕、推桿、打擊墊、琴鍵,可以用滑鼠、手指或鍵盤操作,畫面隨著控制器的狀態重畫。 OavController <oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。controlDetail() 為一次控制項變化組出 control 事件的內容:id、value、raw、signal、解析後的 message、bytes,以及它從哪裡來。 import { ControllerHost } from '@openav/midi'; packages/midi/index.js 第 277 行
ControllerView#
類別 @openav/midi new ControllerView(container, controller, { mode = 'auto' , minTouch = 38 } = {})
控制器在螢幕上可以彈的面板:旋鈕、推桿、打擊墊、琴鍵,可以用滑鼠、手指或鍵盤操作,畫面隨著控制器的狀態重畫。
兩種形狀,依它所在的盒子大小決定。face:機器原本的樣子,各區塊在真實面板上的位置,縮放到剛好放進去(桌機、iPad、橫拿的手機)。stack:同樣的控制項,照設定檔的 portrait 重新由上到下排列,給高的盒子用(直拿的手機)。不會捲動,也不會縮到比手指小。方形格子的機器(Launchpad)一律維持 face。mode 可以固定其中一種,'auto' 則跟著盒子走。
每個控制項都從控制器的狀態重畫,所以螢幕上的手指和桌上的旋鈕看起來一樣,硬體動作則會有一圈短暫的光環,讓你看見自己的手「抵達」螢幕。操作:拖曳旋鈕(往右或往上是增加)、拖曳或點推桿與觸控條、敲打擊墊(敲在上半部較輕,下半部較重)、按住按鈕、彈琴鍵(支援多點觸控)。滑鼠滾輪可以轉旋鈕和推桿。
鍵盤:Tab 可以走到每個控制項(琴鍵除外),方向鍵、Page Up/Down、Home、End 移動旋鈕、推桿和觸控條(加 Shift 是微調),空白鍵或 Enter 敲打擊墊和按鈕,按著鍵就一直按著。學習模式(setLearning(true))會讓每一次點擊都變成「綁定這個控制項」,view.onLearnTap = (id) => … 會告訴你是哪一個。
呼叫之前
要有瀏覽器的 DOM,而且容器要有高度。平常用 createController(…).mount(el) 取得它。
參數 名稱 型別 說明 containerHTMLElement要填滿的元素。它要有大小:畫面是 100% 寬、100% 高,所以要給高度。可以放在 shadow root 裡,畫面的 CSS 會放進那個 root,不會碰到頁面。 controllerMidiController要顯示並驅動的模型。 modeString'auto'、'face' 或 'stack'(見下)。 可省略 預設 'auto' minTouchNumber控制項至少要有幾個像素大,face 才不會被換成 stack。 可省略 預設 38
範例
js 複製
import { ControllerView, MidiController, profileById } from '@openav/midi' ;
const app = document.getElementById('app' );
app.style.height = '240px' ;
const lpd8 = new MidiController(profileById('lpd8' ));
const view = new ControllerView(app, lpd8); // fills #app
lpd8.press('pad1' ); // pad 1 lights up
setTimeout(() => lpd8.release('pad1' ), 600);
module 頁面上出現 AKAI LPD8,pad 1 亮了一下。可以試著拖曳旋鈕或敲打擊墊。
js 複製
import { ControllerView, MidiController, profileById } from '@openav/midi' ;
const app = document.getElementById('app' );
app.style.height = '360px' ;
app.style.width = '220px' ; // a tall, narrow box
const view = new ControllerView(app, new MidiController(profileById('lpd8' )));
console.log(view.mode); // 'stack': re-gridded for a tall box
view.setMode('face' );
console.log(view.mode); // 'face': the device as it looks
module 印出 stack,強制指定版面之後印出 face:同樣的控制項,兩種形狀。
相關 createController() 做出一台可以彈、可以聽、可以接硬體或 DAW 的 MIDI 控制器,不需要元素也不需要框架:createController('akai-lpd8')。 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 ControllerCanvas 把控制器的面板畫進任何 2D 畫布,每個畫面都畫,任何大小都行:演出影片的下半部、疊圖、縮圖。 mountMidiPanel() 掛上一個控制器面板:上面一條工具列,有機器選單、硬體狀態、埠選單、學習按鈕和最後一則訊息的監看,下面是可以彈的面板,可以停靠在底部或填滿。 OavController <oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。VIEW_CSS 螢幕控制器(ControllerView)的樣式表文字,是一個字串。 import { ControllerView } from '@openav/midi'; packages/midi/view.js 第 177 行
createController()#
函式 @openav/midi createController(profile = 'arturia-minilab3' , o = {})
做出一台可以彈、可以聽、可以接硬體或 DAW 的 MIDI 控制器,不需要元素也不需要框架:createController('akai-lpd8')。
四行就夠:const ctl = createController('akai-lpd8'),ctl.mount(el) 畫出可以彈的面板(可有可無,沒有畫面也能用),ctl.on('control', fn) 聽每一個旋鈕、打擊墊、琴鍵,不論來自螢幕還是硬體,ctl.connectHardware() 跟隨真實的機器。ctl.set(id, value) 則像一隻手一樣去動某個控制項。
同一個頁面上的控制器共用一個 Midi 引擎,所以瀏覽器只會詢問一次 MIDI 權限。在你呼叫 connectHardware()、setOutput() 或 requestMidi() 之前,什麼都不會詢問。傳入 signals 就能把 midi/<short>/… 發佈進演出。
參數 名稱 型別 說明 profileString | Object設定檔 id('akai-lpd8')、短名稱('lpd8')或設定檔物件。要用網址的話,先用 loadProfile() 載入。不認得的 id 會丟出 unknown MIDI profile "x" (known: …)。 可省略 預設 'arturia-minilab3' oObject選項,跟 ControllerHost 的第二個參數一樣:profiles、signals、sink、channel、follow、midi、requestAccess、filterOut、storage、telemetry、embedKind。 可省略 預設 {}
注意 跟 <oav-controller> 一樣,每次載入頁面會對專案的分析送出一筆匿名資料(哪一種嵌入、哪一台機器,不含任何人彈的內容),從 localhost 或區域網路則不送。用 createController(profile, { telemetry: false }) 可以關掉。細節在 README 的 Telemetry 一節。 範例
js 複製
import { createController } from '@openav/midi' ;
const ctl = createController('akai-lpd8' );
ctl.on('control' , (e) => console.log(e.signal, e.value.toFixed(2), '<-' , e.source));
ctl.set ('k1' , 0.5); // a hand on knob 1
ctl.set ('pad1' , 1); // press pad 1
ctl.set ('pad1' , 0); // let go
console.log(ctl.values().k1, ctl.get ('nope' ));
module 印出三個 control 事件(旋鈕 0.50、打擊墊 1.00、打擊墊 0.00,來源都是 'ui'),再印出旋鈕的值,以及不存在的 id 回傳的 null。
js 複製
import { createController } from '@openav/midi' ;
// MIDI out to a DAW: a stand-in for the browser's Web MIDI shows what would be sent
const iac = { name: 'IAC Driver Bus 1' , state: 'connected' , send: (bytes) => console.log('to the DAW' , bytes) };
const requestAccess = async () => ({ inputs: new Map(), outputs: new Map([['o' , iac]]) });
const ctl = createController('akai-lpd8' , { requestAccess });
console.log(await ctl.setOutput('IAC' )); // IAC Driver Bus 1
ctl.set ('pad1' , 1); // to the DAW [153, 36, 127]
ctl.set ('pad1' , 0); // to the DAW [137, 36, 0]
ctl.ingest([0x99, 37, 90]); // hardware-style input: the faceplate follows, nothing is sent out
module 印出埠名,再印出螢幕上的手送出的兩則訊息。最後一行不會有輸出:從硬體進來的東西不會被轉送。
js 複製
import { Signals } from '@openav/core' ;
import { createController } from '@openav/midi' ;
const signals = new Signals();
const ctl = createController('minilab3' , { signals });
const app = document.getElementById('app' );
app.style.height = '240px' ;
ctl.mount(app); // the playable faceplate fills #app
ctl.set ('knob1' , 0.8); // the knob on screen turns
console.log(signals.get ('midi/minilab3/knob1' ));
module 頁面上出現 MiniLab 3,旋鈕 1 被轉大了,主控台印出它的訊號值(0.80…)。可以用滑鼠玩。
相關 ControllerHost createController() 回傳的物件:一台控制器,包含事件、可以彈的面板、硬體連線和 MIDI 輸出。OavController <oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 loadProfile() 用 id、短名稱或網址(任何 CORS 允許的 JSON 檔)載入控制器設定檔,你給的檔案或物件會先檢查,回傳 promise。 Midi Web MIDI 引擎:向瀏覽器要存取權,聽每一個輸入埠,把收到的訊息發佈成訊號,並送到一個輸出埠。 ControllerView 控制器在螢幕上可以彈的面板:旋鈕、推桿、打擊墊、琴鍵,可以用滑鼠、手指或鍵盤操作,畫面隨著控制器的狀態重畫。 import { createController } from '@openav/midi'; packages/midi/index.js 第 275 行
defineController()#
函式 @openav/midi defineController(tag = 'oav-controller' )
把 <oav-controller> 元素註冊成另一個標籤名稱(匯入 @openav/midi/element 時已經註冊好 <oav-controller> 了)。
一個標籤名稱在同一個頁面只能註冊一次,所以當 oav-controller 跟別的函式庫衝突,或你想要兩個不同名字的副本時,就用它。預設標籤註冊的是匯出的 OavController 類別本身,其他名稱註冊的是它的子類別。
參數 名稱 型別 說明 tagString新的標籤名稱,必須包含連字號,所有自訂元素都是如此。 可省略 預設 'oav-controller'
回傳 Function | null 註冊在該標籤上的元素類別(標籤已經註冊過就回傳現有的)。頁面沒有 customElements 時回傳 null。
範例
js 複製
import { defineController } from '@openav/midi/element' ;
defineController('my-pads' ); // now <my-pads> works too
const app = document.getElementById('app' );
app.innerHTML = '<my-pads profile="generic-16p"></my-pads>' ;
const el = app.firstElementChild;
await el.ready;
console.log(el.tagName, el.profileData.id); // MY-PADS generic-16p
module 一台 16 個打擊墊的通用控制器,從你自己取名的標籤出現,主控台印出 MY-PADS generic-16p。
相關 OavController <oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。import { defineController } from '@openav/midi/element'; packages/midi/element.js 第 352 行
describeDetail()#
函式 @openav/midi describeDetail(d)
為 control 事件的內容寫出讀數列的文字:midi/minilab3/knob1 = 0.42 ← CC 74 = 53 · ch 1。
<oav-controller readout> 在面板下面顯示的就是這段文字。拿去做你自己的記錄或字幕也很方便。沒有 message 的內容只會顯示訊號和數值。
參數 名稱 型別 說明 dObject | nullcontrolDetail() 的內容(或 control 事件的 detail)。給 null 回傳空字串。
範例
js 複製
import { createController } from '@openav/midi' ;
import { describeDetail } from '@openav/midi/embed' ;
const ctl = createController('minilab3' );
ctl.on('control' , (d) => console.log(describeDetail(d)));
ctl.set ('knob1' , 0.42);
ctl.set ('pitch' , 1);
ctl.set ('pad1' , 1);
module 印出 midi/minilab3/knob1 = 0.42 ← CC 74 = 53 · ch 1,接著是彎音輪和 pad 1 的同類文字。
相關 controlDetail() 為一次控制項變化組出 control 事件的內容:id、value、raw、signal、解析後的 message、bytes,以及它從哪裡來。 describeMessage() 把一則解析過的 MIDI 訊息寫成人讀得懂的樣子:CC 70 = 53、Note 36 · vel 90、Bend 12288。 OavController <oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。import { describeDetail } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 199 行
describeMessage()#
函式 @openav/midi describeMessage(m)
把一則解析過的 MIDI 訊息寫成人讀得懂的樣子:CC 70 = 53、Note 36 · vel 90、Bend 12288。
這是 <oav-controller> 讀數列用的較短文字。describe() 寫的是較長的記錄文字(CC ch1 #70=53)。
回傳 String CC 70 = 53、Note 36 · vel 90、Note 36 off、Bend 12288(原始的 14 位元數值)、Pressure 50、Poly AT 36 = 12、Program 5,其他類型則回傳類型名稱。
範例
js 複製
import { parseMessage } from '@openav/midi' ;
import { describeMessage } from '@openav/midi/embed' ;
for (const bytes of [[0xb0, 70, 53], [0x99, 36, 90], [0x89, 36, 0], [0xe0, 0, 96]]) {
console.log(describeMessage(parseMessage(bytes)));
}
module 印出 CC 70 = 53、Note 36 · vel 90、Note 36 off 和 Bend 12288。
相關 describeDetail() 為 control 事件的內容寫出讀數列的文字:midi/minilab3/knob1 = 0.42 ← CC 74 = 53 · ch 1。 describe() 把解析過的 MIDI 事件變成一行好讀的文字,給記錄和學習畫面用:CC ch1 #74=64、Bend ch1 0.50。 parseMessage() 把一則 MIDI 訊息的位元組轉成事件物件,例如 { type: 'noteon', ch: 1, note: 60, vel: 100 },不認得的位元組就回傳 null。 import { describeMessage } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 185 行
EMBED_ATTRS#
常數 @openav/midi EMBED_ATTRS = array, 11 items
<oav-controller> 讀取的屬性,也同時是 iframe 頁面的查詢參數:profile、layout、hardware、midi-out、channel 等等。
iframe 頁面會把網址裡的這些參數逐一複製到元素上。另外的 bg、pad、frame、origin、lang 是 iframe 頁面專用的,由 parseEmbedOptions() 讀取。每個屬性的意思寫在 OavController 底下。
型別 string[]
範圍 ['profile', 'layout', 'hardware', 'midi-out', 'channel', 'learn', 'picker', 'readout', 'follow', 'theme', 'telemetry']一個普通陣列,順序如上。元素會監看這些屬性(加上 lang)有沒有改變。
範例
js 複製
import { EMBED_ATTRS } from '@openav/midi/embed' ;
console.log(EMBED_ATTRS.join(' ' ));
module 印出十一個屬性名稱。
相關 OavController <oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。parseEmbedOptions() 從元素、查詢字串、物件或取值函式讀出一個嵌入的選項,整理過並補上預設值後回傳。 EMBED_EVENTS <oav-controller> 會觸發、iframe 也會送給外層頁面的事件:ready、control、noteon、noteoff、connect、disconnect、status、profilechange、error。import { EMBED_ATTRS } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 22 行
EMBED_EVENTS#
常數 @openav/midi EMBED_EVENTS = array, 9 items
<oav-controller> 會觸發、iframe 也會送給外層頁面的事件:ready、control、noteon、noteoff、connect、disconnect、status、profilechange、error。
iframe 還會送出第十種類型 values,那是對 get 指令的回答,不是元素事件,所以不在這份清單裡。各事件的內容寫在 OavController 和 controlDetail() 底下。
型別 string[]
範圍 ['ready', 'control', 'noteon', 'noteoff', 'connect', 'disconnect', 'status', 'profilechange', 'error']一個普通陣列,順序如上。元素把每一個都當成會冒泡、可穿過 shadow root 的 CustomEvent 觸發,iframe 頁面則用 toParent() 把每一個轉送給 window.parent。
範例
js 複製
import { EMBED_EVENTS } from '@openav/midi/embed' ;
console.log(EMBED_EVENTS.join(' ' ));
module 印出九個事件名稱。
相關 OavController <oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。toParent() iframe 這一側:為一個事件組出 iframe 要送給外層頁面的訊息:{ source: 'openav', v: 1, type, frame, detail }。 controlDetail() 為一次控制項變化組出 control 事件的內容:id、value、raw、signal、解析後的 message、bytes,以及它從哪裡來。 EMBED_ATTRS <oav-controller> 讀取的屬性,也同時是 iframe 頁面的查詢參數:profile、layout、hardware、midi-out、channel 等等。import { EMBED_EVENTS } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 24 行
EMBED_LAYOUTS#
常數 @openav/midi EMBED_LAYOUTS = array, 3 items
控制器的三種版面:auto、face(機器原本的樣子)和 stack(為高的盒子重新排列)。
設定檔可以不要 stack(stack: false),像 Launchpad 這種方形的格子機器永遠維持 face。見 preferredAspect() 。
型別 string[]
範圍 ['auto', 'face', 'stack']一個普通陣列。parseEmbedOptions() 會把其他的 layout 值變成 'auto',iframe 的 layout 指令也只接受這三個。
範例
js 複製
import { EMBED_LAYOUTS, parseEmbedOptions } from '@openav/midi/embed' ;
console.log(EMBED_LAYOUTS);
console.log(parseEmbedOptions({ layout: 'stack' }).layout); // stack
console.log(parseEmbedOptions({ layout: 'sideways' }).layout); // auto: not in the list
module 印出三個名稱,再印出 stack,最後印出 auto。
import { EMBED_LAYOUTS } from '@openav/midi/embed'; packages/midi/embed.js 第 25 行
embedChannel()#
函式 @openav/midi embedChannel(v)
讀取 channel 屬性:1 到 16 的整數,其他任何值都是 null。
null 代表「維持設定檔本來的頻道」。有數字的話,withChannel() 會把所有控制項移到那個頻道。
回傳 Number | null 頻道,沒寫、空字串、不是整數或不在 1..16 時是 null。
範例
js 複製
import { embedChannel } from '@openav/midi/embed' ;
console.log(embedChannel('10' ), embedChannel(3)); // 10 3
console.log(embedChannel('0' ), embedChannel('17' ), embedChannel('x' ), embedChannel('' )); // null null null null
module 印出 10 3,再印出四個 null。
import { embedChannel } from '@openav/midi/embed'; packages/midi/embed.js 第 49 行
embedColor()#
函式 @openav/midi embedColor(v)
檢查 iframe 頁面的 bg 顏色,回傳安全的值:transparent、十六進位顏色或單純的顏色名稱,其他一律是 null。
iframe 頁面會把結果放進一個 CSS 變數,所以可能跳出 CSS 的值(red;}body{)會被拒絕。它不檢查名稱是不是真的顏色:任何 3 到 20 個字母都會通過,CSS 不認得的就會被忽略。
回傳 String | null 'transparent',或 '#' 加上 3、4、6 或 8 個十六進位數字(輸入裡的 # 可有可無,所以 abc 會變成 #abc),或 3 到 20 個英文字母(轉成小寫,'RED' 變成 'red'),其他是 null。
範例
js 複製
import { embedColor } from '@openav/midi/embed' ;
console.log(embedColor('transparent' ), embedColor('#0b0b0c' ), embedColor('000' ), embedColor('RED' ));
console.log(embedColor('red;}body{' ), embedColor('#12345' ), embedColor('' )); // null null null
module 印出 transparent #0b0b0c #000 red,再印出三個 null。
import { embedColor } from '@openav/midi/embed'; packages/midi/embed.js 第 56 行
embedFlag()#
函式 @openav/midi embedFlag(v)
讀取布林屬性:有寫就是開,除非內容是 false、0、off 或 no。
所以 <oav-controller readout> 和 readout="true" 都會開啟讀數列,readout="off" 則會關掉。其他任何字都算開,所以 readout="ask" 也是 true。
參數 名稱 型別 說明 vany屬性的值:null、undefined、false(沒寫)或字串。只寫屬性名稱時是空字串。
回傳 Boolean 沒寫是 false,true 是 true,字串除了(不分大小寫、去掉空白後)false、0、off、no 之外都是 true。
範例
js 複製
import { embedFlag } from '@openav/midi/embed' ;
console.log(embedFlag('' ), embedFlag('true' ), embedFlag('ask' )); // true true true
console.log(embedFlag(null ), embedFlag('off' ), embedFlag('No' )); // false false false
module 印出三個 true,再印出三個 false。
import { embedFlag } from '@openav/midi/embed'; packages/midi/embed.js 第 30 行
embedMode()#
函式 @openav/midi embedMode(v, { bare = 'on' } = {})
讀取 hardware 和 midi-out:關閉、ask(顯示按鈕,讓權限詢問等到點擊)或開啟,以及指定哪個埠。
沒寫或是 false/0/off/no 就是關閉。ask 就是 ask。on、true、yes、auto 是開啟但不指定埠。其他任何文字都是開啟,而那段文字就是要找的埠名或 /regex/(見 findPort() ):embedMode('IAC') 是 { mode: 'on', port: 'IAC' }。
參數 名稱 型別 說明 vany屬性的值。 bareString只寫屬性、沒有值時的意思:hardware 是 'on'(預設),midi-out 是 'ask',因為送出去沒有合理的預設埠。 可省略 預設 'on'
回傳 { mode, port } mode 是 'off'、'ask' 或 'on'。只有值裡有指定埠時,port 才是字串。
範例
js 複製
import { embedMode } from '@openav/midi/embed' ;
console.log(embedMode(null )); // { mode: 'off', port: null }
console.log(embedMode('' )); // { mode: 'on', port: null } (a bare hardware attribute)
console.log(embedMode('' , { bare: 'ask' })); // { mode: 'ask', port: null } (a bare midi-out attribute)
console.log(embedMode('loopMIDI' )); // { mode: 'on', port: 'loopMIDI' }
module 印出四個 { mode, port } 物件:關閉、開啟、ask,以及指定了埠名的開啟。
import { embedMode } from '@openav/midi/embed'; packages/midi/embed.js 第 38 行
fadeLevel()#
函式 @openav/midi fadeLevel(now, at, ms)
一次淡出中的觸碰目前有多亮:發生的那一刻是 1,經過 ms 之後是 0,並做了緩和,所以在 30 fps 的畫面上仍然看得出來。
ControllerCanvas 用它讓放開的琴鍵(180 毫秒)、打擊墊(450 毫秒)、被碰到的控制項光暈(650 毫秒)、面板下方的最後觸碰文字(1600 毫秒)和機器螢幕上的文字(3200 毫秒)淡出。30 fps 的影片一格是 33 毫秒,沒有淡出的話,20 毫秒長的斷音在下一格之前就消失了。
參數 名稱 型別 說明 nowNumber目前的時間(毫秒)。 atNumber | null觸碰發生的時間,用同一個時鐘。null 或 undefined 會得到 0。 msNumber淡出持續多久。不大於 0 會得到 0。
回傳 Number 從 1 降到 0。曲線是剩餘時間的平方,所以一開始掉得比較快。
範例
js 複製
import { fadeLevel } from '@openav/midi' ;
for (const now of [100, 200, 300, 400, 500]) console.log(now, fadeLevel(now, 100, 400));
module 觸碰當下印出 1,之後是 0.5625、0.25、0.0625,淡出結束時是 0。
import { fadeLevel } from '@openav/midi'; packages/midi/canvas.js 第 64 行
followMidiSignals()#
函式 @openav/midi followMidiSignals(signals, onEvent, { channel = 1, defer = (fn) => queueMicrotask(fn) } = {})
監聽 Signals 匯流排,並把每則 MIDI 訊息「剛好一次」以事件的形式還給你,雖然硬體會用兩個名稱發佈同一則訊息。
硬體和螢幕控制器對每則訊息都會發佈兩次:先是短名稱(midi/note/on),緊接著在同一次呼叫裡是分頻道的名稱(midi/ch/1/note/60)。電腦鍵盤鋼琴和某些錄音只發佈短名稱。分頻道的名稱會立刻算數,短名稱則等一個 microtask,如果它分頻道的孿生兄弟先到了就丟掉。所以相對旋鈕不會轉兩次,沒有頻道的短名稱 CC 也不會落到錯誤的頻道,順序也會保持。
ControllerCanvas 用的就是它。想把匯流排變回 MIDI 事件時可以自己用,例如用錄下的演奏去驅動你自己的畫面。
參數 名稱 型別 說明 signalsSignals匯流排,需要有 onAny(cb)。 onEventFunction每則訊息都以 parseMessage() 的事件格式呼叫 (ev) => …。 channelNumber指定給短名稱 CC 和彎音用的頻道,因為它們本身沒有頻道資訊。 可省略 預設 1 deferFunction排程「只有短名稱」那批訊息的 flush 用的 (fn) => …。預設是 queueMicrotask,測試時可以換掉。 可省略 預設 (fn) => queueMicrotask(fn)
回傳 { flush(), dispose() } flush() 送出還在等待的「只有短名稱」訊息。dispose() 停止監聽並丟掉它們。
範例
js 複製
import { Signals } from '@openav/core' ;
import { followMidiSignals, genericSignals, publish } from '@openav/midi' ;
const signals = new Signals();
const follower = followMidiSignals(signals, (ev) => console.log(ev.type, ev.note ?? ev.cc, ev.ch));
// the hardware publishes a message under both names; the follower reports it once
publish(signals, genericSignals({ type: 'noteon' , ch: 2, note: 60, vel: 100 }));
publish(signals, genericSignals({ type: 'cc' , ch: 2, cc: 74, value: 64 }));
// the QWERTY piano publishes only the short name: it arrives after a microtask, or at flush()
signals.pulse('midi/note/on' , { note: 64, vel: 0.8, ch: 0 });
follower.flush();
follower.dispose();
module 印出三行:noteon 60 2、cc 74 2,然後是鍵盤鋼琴的 noteon 64 1(頻道 0 變成預設的頻道 1)。
相關 eventOfSignal() genericSignals() 的反向:把一個通用的 MIDI 訊號(midi/ch/10/note/36、midi/cc/74)轉回它原本的 MIDI 事件。genericSignals() 列出一則 MIDI 事件會變成哪些跟裝置無關的訊號:midi/cc/74、midi/note/on、midi/bend,以及各頻道版本,例如 midi/ch/2/cc/74。 ControllerCanvas 把控制器的面板畫進任何 2D 畫布,每個畫面都畫,任何大小都行:演出影片的下半部、疊圖、縮圖。 import { followMidiSignals } from '@openav/midi'; packages/midi/canvas.js 第 194 行
FRAME_COMMANDS#
常數 @openav/midi FRAME_COMMANDS = array, 8 items
外層頁面可以送進嵌入 iframe 的指令:set、ingest、reset、profile、layout、get、press、release。
各指令的作用:set { id, value, note?, quiet? } 移動控制項,press { id, velocity?, note? } 與 release { id, note? } 按住與放開,ingest { bytes } 餵入 MIDI(一則或一串),reset 讓所有東西回到原位,profile { profile } 切換機器,layout { layout } 改變版面,get 要求所有數值(iframe 會用一則 values 訊息回答)。
型別 string[]
範圍 ['set', 'ingest', 'reset', 'profile', 'layout', 'get', 'press', 'release']一個普通陣列。fromParent() 會忽略 type 不在這份清單裡的訊息。
範例
js 複製
import { FRAME_COMMANDS, toFrame } from '@openav/midi/embed' ;
console.log(FRAME_COMMANDS.join(' ' ));
console.log(toFrame('set' , { id: 'k1' , value: 0.5 })); // the message you post to the iframe
module 印出八個指令,再印出訊息 { target: 'openav', v: 1, type: 'set', id: 'k1', value: 0.5 }。
相關 fromParent() iframe 這一側:驗證外層頁面送來的指令,回傳整理乾淨的指令,不合格就回傳 null(訊息被忽略)。 toFrame() 外層頁面這一側:組出要送進嵌入 iframe 的指令:{ target: 'openav', v: 1, type, ...args }。 EMBED_EVENTS <oav-controller> 會觸發、iframe 也會送給外層頁面的事件:ready、control、noteon、noteoff、connect、disconnect、status、profilechange、error。import { FRAME_COMMANDS } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 271 行
fromFrame()#
函式 @openav/midi fromFrame(data)
外層頁面這一側:讀取從嵌入 iframe 來的訊息,回傳 { type, frame, detail, v },不是我們的訊息就回傳 null。
別的腳本和瀏覽器擴充功能隨時都在對頁面送訊息,所以一定要過濾。頁面在意是誰送的話,也要檢查 event.origin:這個函式檢查的是格式,不是寄件者。
參數 名稱 型別 說明 dataanymessage 事件的 event.data。
回傳 { type, frame, detail, v } | null 除非 data 是帶有 source: 'openav' 和字串 type 的物件,否則回傳 null。
範例
js 複製
import { fromFrame, toParent } from '@openav/midi/embed' ;
// what a page does with every 'message' event; here the message is made by hand
const data = toParent('control' , { id: 'k1' , signal: 'midi/lpd8/k1' , value: 1 }, { frame: 'pads' });
const msg = fromFrame(data);
if (msg && msg.type === 'control' ) console.log(msg.frame, msg.detail.signal, msg.detail.value);
console.log(fromFrame({ type: 'control' }), fromFrame('hello' )); // null null
module 印出 pads midi/lpd8/k1 1,再對不是我們的訊息印出 null null。
js 複製
window.addEventListener('message' , (e) => {
const msg = fromFrame(e.data);
if (!msg) return ; // not from an openav iframe
if (msg.frame === 'pads' && msg.type === 'control' ) {
console.log(msg.detail.id, msg.detail.value);
}
});
static 放在包著 iframe 的那個頁面上的監聽程式。
相關 toParent() iframe 這一側:為一個事件組出 iframe 要送給外層頁面的訊息:{ source: 'openav', v: 1, type, frame, detail }。 toFrame() 外層頁面這一側:組出要送進嵌入 iframe 的指令:{ target: 'openav', v: 1, type, ...args }。 fromParent() iframe 這一側:驗證外層頁面送來的指令,回傳整理乾淨的指令,不合格就回傳 null(訊息被忽略)。 POST_SOURCE 標記一則訊息屬於 open-audiovisual 嵌入的字:'openav',iframe 送出時放在 source,外層頁面送進去時放在 target。 import { fromFrame } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 280 行
fromParent()#
函式 @openav/midi fromParent(data)
iframe 這一側:驗證外層頁面送來的指令,回傳整理乾淨的指令,不合格就回傳 null(訊息被忽略)。
它很嚴格,因為網路上任何東西都能對 iframe 送訊息。訊息必須有 target: 'openav',type 要在 FRAME_COMMANDS 裡。控制項 id 必須是小寫的 [a-z0-9_-]。value 必須是有限的數字。note 必須是 0..127 的整數(否則是 null)。press 的力度會限制在 0..1。ingest 接受一則 1 到 3 個位元組、第一個位元組是狀態位元組(128 以上)的訊息,或最多 256 則這樣的訊息。profile 接受 id 或 https:// 網址(不接受 http://)。layout 必須在 EMBED_LAYOUTS 裡。
參數 名稱 型別 說明 dataanymessage 事件的 event.data。
回傳 Object | null 像 { type: 'set', id: 'k1', value: 0.5, note: null, quiet: false } 這樣的指令,或 null。
範例
js 複製
import { fromParent, toFrame } from '@openav/midi/embed' ;
const show = (data) => console.log(JSON.stringify(fromParent(data)));
show(toFrame('set' , { id: 'k1' , value: '0.5' })); // value is read as a number
show(toFrame('press' , { id: 'pad1' , velocity: 2 })); // velocity held to 1
show(toFrame('ingest' , { bytes: [0xb0, 70, 64] }));
// all of these are ignored: null
show({ source: 'openav' , type: 'set' , id: 'k1' , value: 1 }); // wrong key: source, not target
show(toFrame('set' , { id: '<img>' , value: 1 })); // bad id
show(toFrame('profile' , { profile: 'http://x.org/p.json' })); // http, not https
show(toFrame('eval' , { code: 'alert(1)' })); // not a command
module 印出三個整理乾淨的指令,再對 frame 會忽略的四則訊息印出四個 null。
相關 toFrame() 外層頁面這一側:組出要送進嵌入 iframe 的指令:{ target: 'openav', v: 1, type, ...args }。 fromFrame() 外層頁面這一側:讀取從嵌入 iframe 來的訊息,回傳 { type, frame, detail, v },不是我們的訊息就回傳 null。 toParent() iframe 這一側:為一個事件組出 iframe 要送給外層頁面的訊息:{ source: 'openav', v: 1, type, frame, detail }。 FRAME_COMMANDS 外層頁面可以送進嵌入 iframe 的指令:set、ingest、reset、profile、layout、get、press、release。 import { fromParent } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 291 行
keyboardGeometry()#
函式 @openav/midi keyboardGeometry(from , to, rect)
算出從某個音到另一個音的琴鍵在矩形裡的位置:白鍵並排,黑鍵蓋在縫上,高度是 60%。
音域不會從黑鍵開始:前面沒有白鍵的黑鍵會被省略,螢幕上的鍵盤也是這樣。白鍵平分寬度,所以 48 到 72 是 15 個白鍵加 10 個黑鍵。
參數 名稱 型別 說明 fromNumber第一個 MIDI 音符。 toNumber最後一個 MIDI 音符。 rect{ x, y, w, h }琴鍵要填滿的區域。
回傳 { note, white, x, y, w, h }[] 每個琴鍵一項,先是白鍵再是黑鍵(也就是繪製順序,這樣黑鍵會疊在上面)。
範例
js 複製
import { keyboardGeometry } from '@openav/midi' ;
const keys = keyboardGeometry(60, 72, { x: 0, y: 0, w: 400, h: 100 });
console.log(keys.filter((k) => k.white).length, 'white,' , keys.filter((k) => !k.white).length, 'black' );
console.log(keys[0].note, keys[0].x, keys[0].w); // 60 (C4) is the first white key
module 印出 8 white, 5 black(一個八度加上下一個 C),再印出第一個鍵的音符和位置。
import { keyboardGeometry } from '@openav/midi'; packages/midi/canvas.js 第 138 行
keyWindow()#
函式 @openav/midi keyWindow(from , to, base, note)
一個超出印刷音域的音符到來之後,算出畫出來的鍵盤該從哪個音開始,這樣它就能跟著機器的八度按鈕走。
琴鍵控制項會接住超出印刷音域的音,因為機器的 Oct+/Oct− 按鈕會改變琴鍵送出的音。這種音到來時,畫面會移動並停在那裡,就像實機一樣。48 到 72 的 MiniLab 按下 Oct+(音符 76)後,畫面會顯示 60 到 84。窄於一個八度的音域,則是直接移動到夠到那個音為止。
參數 名稱 型別 說明 fromNumber印刷音域的第一個音。 toNumber印刷音域的最後一個音。 baseNumber目前顯示的第一個音。 noteNumber剛剛按下的音。
回傳 Number 新的第一個音:音符放得進去就回傳 base,否則是把 base 移動剛好夠的最少八度,並限制在 MIDI 範圍內。
範例
js 複製
import { keyWindow } from '@openav/midi' ;
console.log(keyWindow(48, 72, 48, 60)); // 48: inside the window, unchanged
console.log(keyWindow(48, 72, 48, 76)); // 60: Oct+ on the MiniLab
console.log(keyWindow(48, 72, 48, 40)); // 36: Oct-
console.log(keyWindow(48, 72, 48, 127)); // 103: held to the MIDI range
module 印出 48、60、36、103。
import { keyWindow } from '@openav/midi'; packages/midi/canvas.js 第 157 行
midiNoteName()#
函式 @openav/midi midiNoteName(n)
把 MIDI 音符號碼轉成帶八度的音名,中央 C 是 C4:midiNoteName(60) 是 'C4',midiNoteName(61) 是 'C#4'。
這是螢幕鍵盤採用的寫法。有些軟體(Ableton Live、Yamaha)把 60 叫做「C3」:號碼是一樣的,只有八度的標示不同。在 p5 草圖裡,noteName() 做同樣的事,而且需要時可以寫成降記號。
參數 名稱 型別 說明 nNumberMIDI 音符號碼,0..127。
回傳 String 只用升記號('C#4',不會是 'Db4')。0 是 'C-1',127 是 'G9'。
範例
js 複製
import { midiNoteName } from '@openav/midi' ;
console.log([60, 61, 69, 0, 127].map(midiNoteName)); // [ 'C4', 'C#4', 'A4', 'C-1', 'G9' ]
module 印出五個音名。
import { midiNoteName } from '@openav/midi'; packages/midi/canvas.js 第 40 行
mountMidiPanel()#
函式 @openav/midi mountMidiPanel(parent, mcs, { mode = 'full' , open = true , id = null , onToggle = null , closeButton = mode === 'dock' , profiles = null , contained = false } = {})
掛上一個控制器面板:上面一條工具列,有機器選單、硬體狀態、埠選單、學習按鈕和最後一則訊息的監看,下面是可以彈的面板,可以停靠在底部或填滿。
工具列顯示螢幕上是哪台機器(可以用選單切換)、一個說明有沒有硬體埠在驅動它的標籤、接了硬體時的埠選單、學習按鈕(點一個控制項,再動硬體上對應的那一個,另一個按鈕可以還原對應),以及一個小監看視窗印出最後一則訊息,讓你在看到它推動什麼之前,先看到硬體在說話。接上認得的機器時,選單和面板都會跟著切換。
參數 名稱 型別 說明 parentHTMLElement要加進面板的元素。full 模式的面板是 position: absolute; inset: 0,所以 parent 要設 position(relative)並有大小。 mcsMidiControllers管理裝置與硬體的管理器。 modeString'full' 填滿 parent。'dock' 是固定在視窗底部的抽屜,預設佔視窗高度的 40%,有把手可以拖曳調整高度(記在 localStorage)。 可省略 預設 'full' openBoolean一開始是否打開。關著的停靠面板要到打開時才建立畫面。 可省略 預設 true idString | null面板的元素 id。 可省略 預設 null onToggleFunction | null面板打開或關閉時呼叫 (open) => …。 可省略 預設 null closeButtonBoolean是否顯示 ✕ 按鈕。預設只有 dock 模式會顯示。 可省略 預設 mode === 'dock' profilesString[] | null把機器選單限制在這些設定檔 id。 可省略 預設 null containedBoolean停靠在 parent 裡面(absolute),不是停靠在視窗(fixed)。 可省略 預設 false
回傳 { el, view, isOpen, open(), close(), toggle(), dispose() } el 是面板元素,view 是 ControllerView (建立之後才有),isOpen 是布林值。open()、close()、toggle() 回傳同一個物件,close() 還會放開按著的音。
注意 工具列上的文字是中文(學習、還原、硬體 …),不隨頁面語言改變。<oav-controller> 的工具列有英文和中文版,這個面板則是實驗室和手機分頁在用的那個。 範例
js 複製
import { MidiControllers, PROFILES, mountMidiPanel } from '@openav/midi' ;
const app = document.getElementById('app' );
app.style.cssText = 'position: relative; height: 320px' ;
const mcs = new MidiControllers({ profiles: PROFILES, initial: 'akai-lpd8' });
const panel = mountMidiPanel(app, mcs, { mode: 'full' });
console.log(panel.isOpen, panel.view.constructor.name); // true ControllerView
mcs.current.press('pad2' ); // the pad lights up in the panel
setTimeout(() => mcs.current.release('pad2' ), 500);
module 頁面上出現帶工具列的面板和 AKAI LPD8,pad 2 亮了半秒。可以在選單裡切換機器。
相關 MidiControllers 把硬體埠跟控制器設定檔接起來:接上 MiniLab 3,螢幕上的 MiniLab 3 就出現並跟著你的手動,拔掉它,虛擬的那台仍然可以玩。 ControllerView 控制器在螢幕上可以彈的面板:旋鈕、推桿、打擊墊、琴鍵,可以用滑鼠、手指或鍵盤操作,畫面隨著控制器的狀態重畫。 PANEL_CSS 控制器面板(mountMidiPanel)的樣式表文字,是一個字串。 createController() 做出一台可以彈、可以聽、可以接硬體或 DAW 的 MIDI 控制器,不需要元素也不需要框架:createController('akai-lpd8')。 import { mountMidiPanel } from '@openav/midi'; packages/midi/panel.js 第 62 行
OavController#
類別 @openav/midi new OavController()
<oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。
載入一次 @openav/midi/element(https://openaudiovisual.com/packages/midi/element.js),然後寫 <oav-controller profile="akai-lpd8" readout></oav-controller>。匯入這個檔案時會自動註冊標籤。它是 shadow DOM 元素,所以頁面的 CSS 影響不到它,它也不會影響頁面。滑鼠、手指、觸控筆和鍵盤都能彈,有沒有 Web MIDI 都行:在 Safari 和 iPad 上仍然能彈、能觸發事件,只有硬體和 MIDI 輸出的部分會說「不能用」。
屬性,每一個也是同名的屬性(property)(midi-out 對應 el.midiOut):profile(id、短名稱,或指向設定檔 JSON 的網址,預設 arturia-minilab3)、layout(auto,face 是原本的樣子,stack 是為高的盒子重新排列)、hardware(預設關閉,只寫屬性或 on 是跟隨真實機器,ask 會顯示按鈕,讓權限詢問等到點擊才跳出,也可以寫埠名或 /regex/)、midi-out(預設關閉,只寫屬性或 ask 讓訪客自己挑,也可以寫埠名、子字串或 /regex/,例如 IAC、loopMIDI)、channel(1 到 16:把所有控制項移到那個頻道)、picker、readout(面板下面顯示最後一則訊息)、learn(學習按鈕)、follow(接上哪台認得的機器就顯示哪一台)、theme(dark 或 light:只影響工具列,機器還是機器的樣子)、telemetry(off 就不送匿名資料)和 lang(工具列語言:zh… 是中文,其他是英文,會讀頁面上最近的 lang)。
事件是會冒泡、也會穿過 shadow root 的 CustomEvent,所以在它上面任何一層用 el.addEventListener('control', e => e.detail) 都收得到:ready { profile, name, controls }、control(見 controlDetail() )、noteon、noteoff、connect、disconnect、status、profilechange 和 error { message }。EMBED_EVENTS 列出了它們。方法有:set()、get()、values()、reset()、press()、release()、ingest()、send()、on()、connectHardware()、disconnectHardware()、setOutput()、toggleLearn()。
它的寬度填滿容器,高度則跟著面板走(手機上會用比較高的直排),除非你自己設高度。CSS 變數:--oav-bg、--oav-fg、--oav-accent、--oav-line、--oav-font,以及 --oav-ratio(寬除以高)。可用的 part:::part(toolbar)、::part(body)、::part(readout)。
呼叫之前
要等控制器建好:await el.ready(會以元素本身完成的 promise),或是監聽 ready 事件。在那之前,set()、press()、release()、ingest() 會回傳 null 並在主控台印出一次警告,get() 也回傳 null。除非用了 hardware、midi-out 或學習,否則它不會要 MIDI 權限。
成員 名稱 說明 connectedCallback()method 自訂元素的標準回呼:元素加進頁面時,會建立它的控制器(在 microtask 裡)。 disconnectedCallback()method 自訂元素的標準回呼:元素離開頁面時,會銷毀它的控制器並放開按著的音。 attributeChangedCallback(name, was, now)method 自訂元素的標準回呼。改變 profile、layout、hardware、midi-out、follow、readout 或 theme,會直接更新正在跑的元素。改變 channel 或 lang 則會重建。 set(id, value, opts = {})method 像一隻手去動控制項:旋鈕和推桿 0..1,打擊墊和按鈕大於 0 是按下,0 是放開,琴鍵用 set('keys', velocity, { note: 60 })。quiet: true 只動面板:不觸發事件,也不送 MIDI。第三個參數給數字會被當成 note。回傳送出的位元組或 null。 get(id)method 某個控制項目前的值,還沒準備好或 id 不存在時為 null。 values()method 每個控制項的 { id: value },元素還沒準備好時是 {}。 reset(opts)method 放開所有東西,讓每個控制項回到原位。reset({ quiet: true }) 只動面板。 press(id, velocity = 1, note = null)method 按下打擊墊、按鈕或琴鍵。 release(id, note = null)method 放開它。 ingest(bytes, opts)method 從任何地方進來的 MIDI(WebSocket、另一個分頁):面板會跟著動,control 事件以 source: 'hardware' 觸發。可以是一則訊息或一串訊息。 send(bytes)method 把原始位元組送到 MIDI 輸出埠(midi-out 開著的時候)。送出了回傳 true。 on(name, fn)method 依名稱訂閱事件,fn(detail, event)。回傳取消訂閱的函式。跟 addEventListener 一樣,只是直接給你內容。 connectHardware()method 跟隨真實的機器(向瀏覽器要 Web MIDI),如果 hardware 屬性寫了埠名就用它。完成時回傳 true 或 false。 disconnectHardware()method 停止跟隨硬體。 setOutput(query)method 把彈出來的內容送到某個 MIDI 輸出埠:名稱、子字串或 /regex/,null 是關閉。完成時回傳埠名或 null。 toggleLearn(on)method 開啟或關閉學習模式(不給參數就是切換),並在讀數列顯示提示。開啟時也會開始聽硬體。回傳新的狀態。 coregetter 底下的無畫面控制器:跟 createController() 同一套 API(準備好之前是 null)。 controllergetter 螢幕上那台機器的活模型(MidiController )。 controllersgetter 這個元素能顯示的所有機器(MidiControllers )。 viewgetter 面板(ControllerView )。 midigetter Web MIDI 引擎(Midi ),在硬體或 MIDI 輸出開啟之後才有。 statusgetter { hardware, input, output, outputState, profile },可能的值見 ControllerHost 。profileDatagetter 螢幕上那份整理過的設定檔。 learngetter 對應 learn 屬性的布林值。 pickergetter 對應 picker 屬性的布林值。 readoutgetter 對應 readout 屬性的布林值。 followgetter 對應 follow 屬性的布林值。 profilegetter 對應 profile 屬性的字串。設定它就會切換螢幕上的機器。 layoutgetter 對應 layout 屬性的字串:auto、face 或 stack。 themegetter 對應 theme 屬性的字串:dark 或 light。 hardwaregetter 對應 hardware 屬性的字串。 midiOutgetter 對應 midi-out 屬性的字串。 channelgetter 對應 channel 屬性的數字:1 到 16,屬性沒寫或超出範圍時是 null。 observedAttributesstatic property 元素會回應的屬性:EMBED_ATTRS 再加上 lang。
注意 只能放 iframe 的地方,同一個元素也放在 /embed/controller/(查詢參數對應到屬性)。要在 frame 裡用硬體和 MIDI 輸出,<iframe> 要加上 allow="midi"。見 parseEmbedOptions() 和 toParent() 。 如果頁面同時匯入 @openav/midi 和 @openav/midi/element,瀏覽器會把 index.js 載入兩次,因為元素匯入的是自己那份、網址帶著 ?v= 版本戳記。跨這兩份的檢查會失敗:el.core instanceof ControllerHost 是 false。請改用元素的方法和事件跟它溝通。 跟 createController() 一樣,每次載入頁面會送一筆匿名資料,從 localhost 或區域網路則不送。telemetry="off" 可以關掉。 範例
js 複製
// importing the file registers <oav-controller>
import '@openav/midi/element' ;
const app = document.getElementById('app' );
app.innerHTML = '<oav-controller profile="akai-lpd8" readout></oav-controller>' ;
const el = app.firstElementChild;
await el.ready; // the controller exists now
el.on('control' , (d) => console.log(d.signal, d.value.toFixed(2), d.source));
el.set ('k1' , 0.5); // turns knob 1 on the screen
el.ingest([0xb0, 71, 127]); // MIDI from "elsewhere": knob 2 follows
module 出現一台 LPD8,下面有一行訊息列。主控台印出 midi/lpd8/k1 0.50 ui,接著是 midi/lpd8/k2 1.00 hardware。可以拖曳旋鈕或敲打擊墊。
js 複製
import '@openav/midi/element' ;
const app = document.getElementById('app' );
app.innerHTML = '<oav-controller profile="lpd8" picker style="max-width: 420px"></oav-controller>' ;
const el = app.firstElementChild;
await el.ready;
el.addEventListener('profilechange' , (e) => console.log('now showing' , e.detail.name));
el.profile = 'nano2' ; // the same as setAttribute('profile', 'nano2')
console.log(el.status.hardware, el.channel); // off null
module 工具列上有機器選單。設定 el.profile 會把面板換成 KORG nanoKONTROL2,主控台印出它的名稱,再印出 off null。
html 複製
<script type="module" src="https://openaudiovisual.com/packages/midi/element.js" ></script >
<!-- plays on its own; the real device and MIDI out stay off until you ask -->
<oav-controller profile="akai-lpd8" readout></oav-controller >
<!-- a button asks for the real device on click; a MIDI output named like IAC receives what you play -->
<oav-controller profile="arturia-minilab3" hardware="ask" midi-out="IAC" picker learn></oav-controller >
static 貼進任何頁面的就是這幾行。第一台用滑鼠就能彈,第二台會顯示連接按鈕和學習按鈕。
待確認
這一頁沒有實測:各個瀏覽器的 iframe 裡的 hardware 和 midi-out,以及元素背後接真實的 MIDI 機器。allow="midi" 的要求是照套件 README 的說法寫的。
相關 defineController() 把 <oav-controller> 元素註冊成另一個標籤名稱(匯入 @openav/midi/element 時已經註冊好 <oav-controller> 了)。 createController() 做出一台可以彈、可以聽、可以接硬體或 DAW 的 MIDI 控制器,不需要元素也不需要框架:createController('akai-lpd8')。 ControllerHost createController() 回傳的物件:一台控制器,包含事件、可以彈的面板、硬體連線和 MIDI 輸出。parseEmbedOptions() 從元素、查詢字串、物件或取值函式讀出一個嵌入的選項,整理過並補上預設值後回傳。 EMBED_ATTRS <oav-controller> 讀取的屬性,也同時是 iframe 頁面的查詢參數:profile、layout、hardware、midi-out、channel 等等。EMBED_EVENTS <oav-controller> 會觸發、iframe 也會送給外層頁面的事件:ready、control、noteon、noteoff、connect、disconnect、status、profilechange、error。controlDetail() 為一次控制項變化組出 control 事件的內容:id、value、raw、signal、解析後的 message、bytes,以及它從哪裡來。 import { OavController } from '@openav/midi/element'; packages/midi/element.js 第 82 行
PANEL_CSS#
常數 @openav/midi PANEL_CSS = "\n.mcp { position: absolute; inset: 0; display: flex; flex-direction: column; …"
控制器面板(mountMidiPanel)的樣式表文字,是一個字串。
進階用途:匯出給想讀或覆寫規則的頁面。掛上面板時完全用不到它。
型別 String.mcp… 那些 class 的 CSS。mountMidiPanel() 會自己注入,每個文件一次(放在 head 的 <style id="openav-midi-panel-css">)。
範例
js 複製
import { PANEL_CSS } from '@openav/midi' ;
console.log(PANEL_CSS.includes('.mcp.dock' )); // the drawer rules
module 印出 true:面板的規則是看得懂的 CSS 文字。
相關 mountMidiPanel() 掛上一個控制器面板:上面一條工具列,有機器選單、硬體狀態、埠選單、學習按鈕和最後一則訊息的監看,下面是可以彈的面板,可以停靠在底部或填滿。 VIEW_CSS 螢幕控制器(ControllerView)的樣式表文字,是一個字串。 import { PANEL_CSS } from '@openav/midi'; packages/midi/panel.js 第 15 行
parseEmbedOptions()#
函式 @openav/midi parseEmbedOptions(src)
從元素、查詢字串、物件或取值函式讀出一個嵌入的選項,整理過並補上預設值後回傳。
元素和 iframe 的查詢字串各代表什麼意思,都由它一個地方決定,所以兩邊一定一致。預設值:profile 是 arturia-minilab3,layout 是 auto,theme 是 dark,hardware 和 midiOut 是關閉(所以嵌入不會要 MIDI 權限),布林值都是 false,telemetry 是 true,channel、bg、frame、origin、lang 是 null。
值得知道的規則:不認得的 layout 變成 auto。theme 只有 light 或 dark。midi-out 也可以寫成 midiout 或 midiOut,單獨寫表示 ask。frame 也可以寫成 id,並且截成 64 個字元。origin 只接受沒有路徑的 https://host 或 http://host(其他的,像 javascript:,會變成 null)。lang 截成 16 個字元。bg 會經過 embedColor() 。
參數 名稱 型別 說明 srcElement | URLSearchParams | Object | Function選項放在哪裡:元素(用 getAttribute 讀)、URLSearchParams(iframe 頁面傳自己的)、普通物件 { profile: 'lpd8' },或是函式 (name) => value | null。
回傳 Object { profile, layout, hardware, midiOut, channel, learn, picker, readout, follow, telemetry, theme, bg, frame, origin, lang }。hardware 和 midiOut 的格式是 embedMode() 那樣的 { mode, port }。
範例
js 複製
import { parseEmbedOptions } from '@openav/midi/embed' ;
const q = new URLSearchParams('profile=lpd8&layout=stack&hardware&midi-out=IAC&readout&bg=transparent&origin=https://example.com' );
const o = parseEmbedOptions(q);
console.log(o.profile, o.layout, o.hardware, o.midiOut);
console.log(o.readout, o.bg, o.origin, o.channel);
module 印出 lpd8 stack、hardware 的 { mode: 'on', port: null }、midiOut 的 { mode: 'on', port: 'IAC' },再印出 true transparent https://example.com null。
js 複製
import { parseEmbedOptions } from '@openav/midi/embed' ;
console.log(JSON.stringify(parseEmbedOptions({}))); // every default
const o = parseEmbedOptions({ 'midi-out' : '' , hardware: 'off' });
console.log(JSON.stringify(o.midiOut), JSON.stringify(o.hardware)); // midiOut asks, hardware stays off
console.log(parseEmbedOptions({ origin: 'javascript:alert(1)' }).origin); // null
module 以 JSON 印出所有預設值,再印出 {"mode":"ask","port":null} 的 midiOut(旁邊是關閉的 hardware),最後是 null。
相關 embedMode() 讀取 hardware 和 midi-out:關閉、ask(顯示按鈕,讓權限詢問等到點擊)或開啟,以及指定哪個埠。 embedFlag() 讀取布林屬性:有寫就是開,除非內容是 false、0、off 或 no。 embedChannel() 讀取 channel 屬性:1 到 16 的整數,其他任何值都是 null。 embedColor() 檢查 iframe 頁面的 bg 顏色,回傳安全的值:transparent、十六進位顏色或單純的顏色名稱,其他一律是 null。 EMBED_ATTRS <oav-controller> 讀取的屬性,也同時是 iframe 頁面的查詢參數:profile、layout、hardware、midi-out、channel 等等。OavController <oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。import { parseEmbedOptions } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 72 行
POST_SOURCE#
常數 @openav/midi POST_SOURCE = "openav"
標記一則訊息屬於 open-audiovisual 嵌入的字:'openav',iframe 送出時放在 source,外層頁面送進去時放在 target。
型別 String
範圍 'openav'常數。iframe 送出的訊息有 source: 'openav',送進 iframe 的指令有 target: 'openav'。兩邊都會忽略其他任何訊息。
範例
js 複製
import { POST_SOURCE, toParent, toFrame } from '@openav/midi/embed' ;
console.log(POST_SOURCE); // openav
console.log(toParent('ready' ).source, toFrame('get' ).target); // openav openav
module 印出 openav,再印出 openav openav。
相關 POST_VERSION iframe 訊息格式的版本,1,會寫成每則訊息裡的 v。 toParent() iframe 這一側:為一個事件組出 iframe 要送給外層頁面的訊息:{ source: 'openav', v: 1, type, frame, detail }。 toFrame() 外層頁面這一側:組出要送進嵌入 iframe 的指令:{ target: 'openav', v: 1, type, ...args }。 fromFrame() 外層頁面這一側:讀取從嵌入 iframe 來的訊息,回傳 { type, frame, detail, v },不是我們的訊息就回傳 null。 fromParent() iframe 這一側:驗證外層頁面送來的指令,回傳整理乾淨的指令,不合格就回傳 null(訊息被忽略)。 import { POST_SOURCE } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 268 行
POST_VERSION#
常數 @openav/midi POST_VERSION = 1
iframe 訊息格式的版本,1,會寫成每則訊息裡的 v。
型別 Number
範圍 1常數,讓頁面在格式改變時能分辨。fromFrame() 會把 v 還給你(訊息沒有時是 1)。
範例
js 複製
import { POST_VERSION, toParent } from '@openav/midi/embed' ;
console.log(POST_VERSION, toParent('ready' ).v); // 1 1
module 印出 1 1。
相關 POST_SOURCE 標記一則訊息屬於 open-audiovisual 嵌入的字:'openav',iframe 送出時放在 source,外層頁面送進去時放在 target。 toParent() iframe 這一側:為一個事件組出 iframe 要送給外層頁面的訊息:{ source: 'openav', v: 1, type, frame, detail }。 fromFrame() 外層頁面這一側:讀取從嵌入 iframe 來的訊息,回傳 { type, frame, detail, v },不是我們的訊息就回傳 null。 import { POST_VERSION } from '@openav/midi/embed'; packages/midi/embed.js 第 269 行
preferredAspect()#
函式 @openav/midi preferredAspect(profile, width, { layout = 'auto' , minTouch = 38 } = {})
算出控制器在某個寬度的盒子裡想要的寬高比:每個控制項都夠手指按的話用面板本身的比例,否則用比較高的直排。
<oav-controller> 用它來決定自己的高度,所以控制器會依寬度自己決定大小。在 auto 下,只要在那個寬度時最小的控制項仍有至少 minTouch 像素,就用面板本身的比例,否則改成直排,高度大約是寬度的 0.8 到 1.5 倍,視設定檔 portrait 有幾條而定。方形格子的機器(Launchpad,stack: false)永遠維持 face。
參數 名稱 型別 說明 profileObject整理過的設定檔(face、sections、portrait、stack)。 widthNumber盒子的寬度(像素)。 layoutString'auto'、'face' 或 'stack'。 可省略 預設 'auto' minTouchNumber還算夠手指按的最小控制項尺寸(像素)。 可省略 預設 38
回傳 { mode, aspect } mode 是 'face' 或 'stack',aspect 是要給盒子的寬高比。
範例
js 複製
import { normalizeProfile, profileById } from '@openav/midi' ;
import { preferredAspect } from '@openav/midi/embed' ;
const lpd8 = normalizeProfile(profileById('lpd8' ));
console.log(preferredAspect(lpd8, 1200)); // { mode: 'face', aspect: 3.70... }
console.log(preferredAspect(lpd8, 340)); // { mode: 'stack', aspect: 0.92... } : a phone
console.log(preferredAspect(lpd8, 340, { layout: 'face' })); // forced back to the face
const launchpad = normalizeProfile(profileById('lpmini' ));
console.log(preferredAspect(launchpad, 300)); // { mode: 'face', aspect: 1 } : always square
module 印出四個結果:寬的面板、窄盒子用的高直排、強制指定時的面板,以及方形的 Launchpad。
相關 OavController <oav-controller> 自訂元素:用一行就能貼進任何頁面的、可以彈的 MIDI 控制器,不需要 import map,也不需要建置。EMBED_LAYOUTS 控制器的三種版面:auto、face(機器原本的樣子)和 stack(為高的盒子重新排列)。 ControllerView 控制器在螢幕上可以彈的面板:旋鈕、推桿、打擊墊、琴鍵,可以用滑鼠、手指或鍵盤操作,畫面隨著控制器的狀態重畫。 import { preferredAspect } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 251 行
restValue()#
函式 @openav/midi restValue(c)
說明控制項休息時在哪裡:旋鈕和推桿在預設值(0),雙向彎音輪在中心,打擊墊、按鈕和琴鍵在 0。
ControllerHost 的 reset() 用它讓每個控制項回到原位。無限旋鈕沒有真正的位置,所以只有畫面上的位置會回到中間,而且不會送出 MIDI。
參數 名稱 型別 說明 cObject整理過的設定檔中的控制項定義。
回傳 Number 琴鍵、打擊墊、按鈕和雙向控制項是 0。其他用控制項的 def,沒有的話無限旋鈕是 0.5,其餘是 0。
範例
js 複製
import { normalizeProfile, profileById } from '@openav/midi' ;
import { restValue } from '@openav/midi/embed' ;
const p = normalizeProfile(profileById('minilab3' ));
for (const id of ['knob1' , 'pitch' , 'enc' , 'pad1' , 'keys' ]) {
console.log(id, restValue(p.controls.find((c) => c.id === id)));
}
module 旋鈕、音高觸控條(中心)、打擊墊和琴鍵印出 0,無限旋鈕印出 0.5。
相關 setControl() 像一隻手一樣去動某個控制項,不論它是什麼類型,並回傳它送出的 MIDI 位元組:setControl(mc, 'k1', 0.5)。 ControllerHost createController() 回傳的物件:一台控制器,包含事件、可以彈的面板、硬體連線和 MIDI 輸出。import { restValue } from '@openav/midi/embed'; packages/midi/embed.js 第 238 行
setControl()#
函式 @openav/midi setControl(mc, id, value, { note = null , source = 'ui' } = {})
像一隻手一樣去動某個控制項,不論它是什麼類型,並回傳它送出的 MIDI 位元組:setControl(mc, 'k1', 0.5)。
value 的意思依類型而定。旋鈕、推桿、彎音輪、觸控條、編碼器:0..1(雙向的是 −1..1)。打擊墊和瞬間按鈕:大於 0 是按下(用那個力度,上限 1),0 是放開。切換按鈕:0.5 以上是開。琴鍵:value 是力度,大於 0 按下那個 note,0 放開它。ControllerHost 和 <oav-controller> 的 set() 呼叫的就是它。
參數 名稱 型別 說明 mcMidiController控制器。 idString控制項 id。不存在的 id 會丟出 <profile> has no control "x" (controls: …)。 valueNumber數值(見下)。不是有限數字的值會丟出 set("id"): value must be a number。 noteNumber | null琴鍵控制項要給:哪個音。預設是音域的第一個鍵。 可省略 預設 null sourceString'ui'(預設)會發佈並送到 MIDI 輸出。'mirror' 只動面板(雙向綁定用)。 可省略 預設 'ui'
回傳 number[] | null 送出的位元組,沒有變化時(例如放開一個沒按著的打擊墊)回傳 null。
範例
js 複製
import { MidiController, profileById } from '@openav/midi' ;
import { setControl } from '@openav/midi/embed' ;
const mc = new MidiController(profileById('lpd8' ));
console.log(setControl(mc, 'k1' , 0.5)); // [176, 70, 64] : a knob
console.log(setControl(mc, 'pad1' , 1)); // [153, 36, 127] : a pad pressed
console.log(setControl(mc, 'pad1' , 0)); // [137, 36, 0] : released
console.log(setControl(mc, 'pad1' , 0)); // null : nothing to release
module 印出幾種控制項送出的三則訊息,最後是 null。
相關 valuesOf() 從控制器讀出每個控制項目前的值,格式是 { id: number },琴鍵控制項在有任何一個鍵按著時是 1。 MidiController 把一台 MIDI 控制器(一份設定檔)做成活的模型:MIDI 位元組進去,控制項的值和訊號出來,而螢幕上的手會送出跟實體機器一樣的位元組。 ControllerHost createController() 回傳的物件:一台控制器,包含事件、可以彈的面板、硬體連線和 MIDI 輸出。restValue() 說明控制項休息時在哪裡:旋鈕和推桿在預設值(0),雙向彎音輪在中心,打擊墊、按鈕和琴鍵在 0。 import { setControl } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 221 行
toFrame()#
函式 @openav/midi toFrame(type, args = {})
外層頁面這一側:組出要送進嵌入 iframe 的指令:{ target: 'openav', v: 1, type, ...args }。
它不檢查指令內容:iframe 會用 fromParent() 檢查,不懂的就忽略。可以的話請送給 frame 真正的來源,不要用 *。
參數 名稱 型別 說明 typeString指令:FRAME_COMMANDS 其中之一。 argsObject指令的欄位(set 是 { id, value })。會經過 JSON 複製。 可省略 預設 {}
回傳 Object 可以直接給 iframe.contentWindow.postMessage(msg, origin) 的訊息。
範例
js 複製
import { toFrame } from '@openav/midi/embed' ;
console.log(JSON.stringify(toFrame('set' , { id: 'k1' , value: 0.5 })));
console.log(JSON.stringify(toFrame('ingest' , { bytes: [0xb0, 70, 64] })));
console.log(JSON.stringify(toFrame('profile' , { profile: 'korg-nanokontrol2' })));
// iframe.contentWindow.postMessage(toFrame('get'), 'https://openaudiovisual.com');
module 印出三則指令訊息,最後一行(被註解掉的)是實際送出一則的做法。
相關 fromParent() iframe 這一側:驗證外層頁面送來的指令,回傳整理乾淨的指令,不合格就回傳 null(訊息被忽略)。 toParent() iframe 這一側:為一個事件組出 iframe 要送給外層頁面的訊息:{ source: 'openav', v: 1, type, frame, detail }。 FRAME_COMMANDS 外層頁面可以送進嵌入 iframe 的指令:set、ingest、reset、profile、layout、get、press、release。 POST_SOURCE 標記一則訊息屬於 open-audiovisual 嵌入的字:'openav',iframe 送出時放在 source,外層頁面送進去時放在 target。 import { toFrame } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 285 行
toParent()#
函式 @openav/midi toParent(type, detail = null , { frame = null } = {})
iframe 這一側:為一個事件組出 iframe 要送給外層頁面的訊息:{ source: 'openav', v: 1, type, frame, detail }。
iframe 頁面對每個事件都呼叫 parent.postMessage(toParent(name, detail, { frame }), target),target 是 ?origin= 參數,沒有就是 *。加上 ?origin=https://your.site 之後,frame 只會送給那個來源,也只接受從它來的指令。
參數 名稱 型別 說明 typeString事件名稱(見 EMBED_EVENTS ),或 values。 detailany事件內容。會經過 JSON 複製,所以訊息一定能安全地 postMessage(undefined 變成 null)。 可省略 預設 null frameString | null這個 iframe 的名字(?frame= 參數),每則訊息都會帶著,這樣頁面上有好幾個 frame 時才分得出來。 可省略 預設 null
回傳 Object 訊息物件 { source: 'openav', v: 1, type, frame, detail }。
範例
js 複製
import { toParent } from '@openav/midi/embed' ;
const msg = toParent('control' , { id: 'k1' , value: 0.5 }, { frame: 'pads' });
console.log(JSON.stringify(msg)); // {"source":"openav","v":1,"type":"control","frame":"pads","detail":{"id":"k1","value":0.5}}
console.log(JSON.stringify(toParent('ready' ))); // frame and detail are null
module 印出 control 事件的完整訊息,再印出沒有 frame 和 detail 的單純 ready 訊息(兩者都是 null)。
相關 fromFrame() 外層頁面這一側:讀取從嵌入 iframe 來的訊息,回傳 { type, frame, detail, v },不是我們的訊息就回傳 null。 toFrame() 外層頁面這一側:組出要送進嵌入 iframe 的指令:{ target: 'openav', v: 1, type, ...args }。 fromParent() iframe 這一側:驗證外層頁面送來的指令,回傳整理乾淨的指令,不合格就回傳 null(訊息被忽略)。 POST_SOURCE 標記一則訊息屬於 open-audiovisual 嵌入的字:'openav',iframe 送出時放在 source,外層頁面送進去時放在 target。 EMBED_EVENTS <oav-controller> 會觸發、iframe 也會送給外層頁面的事件:ready、control、noteon、noteoff、connect、disconnect、status、profilechange、error。import { toParent } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 276 行
touchText()#
函式 @openav/midi touchText(c, st, note = null )
寫出機器的螢幕會怎麼描述一次觸碰:Knob 1 · 87、C4 · 100、Pad 3 · 96、Pitch +0.42。
ControllerCanvas 把這段文字畫在機器的螢幕上(MiniLab 3、KeyStep),讓影片裡看得到實機螢幕會顯示的內容。它是純函式,拿去當字幕或記錄也很方便。
參數 名稱 型別 說明 cObject控制項的定義(來自整理過的設定檔)。給 null 會回傳空字串。 stObject它的狀態:{ raw, value, held, pos }。 noteNumber | null琴鍵控制項要給:被碰到的音符。 可省略 預設 null
回傳 String 那一行文字。標籤只是數字的控制項,會用類型來命名(Knob 1)。打擊墊只有按著時才顯示力度,按鈕顯示 on 或 off,相對旋鈕以百分比顯示位置,雙向彎音輪則顯示有正負號的值。
範例
js 複製
import { touchText, normalizeProfile, profileById } from '@openav/midi' ;
const p = normalizeProfile(profileById('minilab3' ));
const c = (id) => p.controls.find((x) => x.id === id);
console.log(touchText(c('knob1' ), { raw: 87 })); // Knob 1 · 87
console.log(touchText(c('keys' ), { raw: 100 }, 60)); // C4 · 100
console.log(touchText(c('pad3' ), { held: true , raw: 96 })); // Pad 3 · 96
console.log(touchText(c('pitch' ), { value: -0.5 })); // Pitch −0.50
module 印出註解裡的那四行。
相關 ControllerCanvas 把控制器的面板畫進任何 2D 畫布,每個畫面都畫,任何大小都行:演出影片的下半部、疊圖、縮圖。 midiNoteName() 把 MIDI 音符號碼轉成帶八度的音名,中央 C 是 C4:midiNoteName(60) 是 'C4',midiNoteName(61) 是 'C#4'。 import { touchText } from '@openav/midi'; packages/midi/canvas.js 第 167 行
valuesOf()#
函式 @openav/midi valuesOf(mc)
從控制器讀出每個控制項目前的值,格式是 { id: number },琴鍵控制項在有任何一個鍵按著時是 1。
ControllerHost 和 <oav-controller> 的 values() 回傳的就是它,iframe 對 get 指令的回答也是它。
參數 名稱 型別 說明 mcMidiController控制器。
回傳 Object 每個控制項 id 一個數字,順序跟設定檔一致。
範例
js 複製
import { MidiController, profileById } from '@openav/midi' ;
import { valuesOf } from '@openav/midi/embed' ;
const mc = new MidiController(profileById('lpd8' ));
mc.setValue('k1' , 1);
mc.press('pad2' , 0.5);
const values = valuesOf(mc);
console.log(Object.keys(values).length, 'controls' );
console.log('k1 =' , values.k1, '| pad2 =' , values.pad2.toFixed(2), '| pad1 =' , values.pad1);
module 印出 16 controls,再印出 k1 = 1 | pad2 = 0.50 | pad1 = 0:LPD8 的每個控制項各有一個數字。
相關 setControl() 像一隻手一樣去動某個控制項,不論它是什麼類型,並回傳它送出的 MIDI 位元組:setControl(mc, 'k1', 0.5)。 ControllerHost createController() 回傳的物件:一台控制器,包含事件、可以彈的面板、硬體連線和 MIDI 輸出。import { valuesOf } from '@openav/midi'; 也能從 @openav/midi/embed packages/midi/embed.js 第 206 行
VIEW_CSS = "\n.mcv { position: relative; width: 100%; height: 100%; overflow: hidden; touc…"
螢幕控制器(ControllerView)的樣式表文字,是一個字串。
進階用途。要顯示控制器的話完全用不到它。它是匯出給「自己建 shadow DOM 的頁面、伺服器端渲染的預覽、想讀規則的佈景」用的。顏色用的 CSS 變數有 --oav-focus、--body、--c 和 --cap,其中後三個由畫面針對每個控制項設定。
型別 String.mcv… 那些 class 的 CSS。畫面會自己注入,每個文件注入一次,放在 shadow root 裡時則是每個 root 一次(一個 <style id="openav-midi-view-css">)。
範例
js 複製
import { VIEW_CSS } from '@openav/midi' ;
console.log(typeof VIEW_CSS, VIEW_CSS.length > 1000);
console.log(VIEW_CSS.includes('.mcv-pad' )); // the rules for pads
module 印出 string true,再印出 true:它就是純 CSS 文字,可以自己讀或自己注入。
import { VIEW_CSS } from '@openav/midi'; packages/midi/view.js 第 32 行
Leap Motion(@openav/leap)#
把手當訊號:影格、手勢、橋接程式。 27
@openav/leap
boneBasis()#
函式 @openav/leap boneBasis(prev, next, palmNormal, left)
進階用的輔助函式:用兩個關節和掌心法線重建一根骨頭的正交基底 [x, y, z],做法符合 LeapJS 的期待。
感測器給的是關節,不是旋轉,但 LeapJS 的草圖會對每根骨頭讀一個旋轉基底,所以 V6Converter 用它來填 bases。只有自己寫轉換器的時候才會需要它。
參數 名稱 型別 說明 prevArray<Number>骨頭根部的關節 [x, y, z],單位是公釐。 nextArray<Number>骨頭前端的關節。 palmNormalArray<Number>掌心法線,-palmNormal 會成為「手背」那一軸(y)。 leftBoolean左手就傳 true:x 軸會反向,行列式變成負的,LeapJS 就是靠這個分辨左右手。
回傳 Array 三列 [x, y, z],各是四捨五入到小數三位的單位向量。z 從前端指回根部(LeapJS 的 Bone.direction() 是 -z)。
範例
js 複製
import { boneBasis } from '@openav/leap' ;
// a bone pointing straight ahead (-z), palm facing down: the basis is the identity
console.log(boneBasis([0, 200, 0], [0, 200, -40], [0, -1, 0], false ));
// the same bone on a left hand: x flips
console.log(boneBasis([0, 200, 0], [0, 200, -40], [0, -1, 0], true )[0]);
module 主控台印出單位矩陣 [ [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ],然後左手的 [ -1, 0, 0 ]。
相關 palmBasis() 進階用的輔助函式:由手的方向和法線算出掌心的正交基底 [x, y, z],列的排法就是 LeapJS 當作 hand.r 來讀的那種。 V6Converter 把原始的手資料行轉成 LeapJS v6 影格,並保存通訊協定需要的少量狀態:可見時間、平滑過的位置和指尖速度。 rawToV6() 把一個原始影格一次轉成 LeapJS v6 影格,每次都用全新的轉換器(沒有歷史,所以沒有速度)。 import { boneBasis } from '@openav/leap'; packages/leap/frame.js 第 48 行
DEFAULT_PORT#
常數 @openav/leap DEFAULT_PORT = 6437
Leap 橋接程式預設的連接埠:6437,是原本的 Leap 服務用的連接埠,舊的 leap.js 草圖不用改就找得到。
startBridge({ port }) 和 --port 的預設值。環境變數 LEAP_BRIDGE_PORT 可以改命令列用的值。這是 Leap 橋接程式的連接埠。輸出橋接有自己的(@openav/out 的 DEFAULT_PORT ,6450)。
相關 DEFAULT_URL LeapInput 預設連的位址:ws://127.0.0.1:6437/v6.json,也就是這台機器上、講 LeapJS 通訊協定的橋接程式。import { DEFAULT_PORT } from '@openav/leap/bridge'; packages/leap/bridge/leap-bridge.mjs 第 30 行
DEFAULT_URL#
常數 @openav/leap DEFAULT_URL = "ws://127.0.0.1:6437/v6.json"
LeapInput 預設連的位址:ws://127.0.0.1:6437/v6.json,也就是這台機器上、講 LeapJS 通訊協定的橋接程式。
6437 是原本的 Leap 服務用的連接埠,所以舊的 leap.js 草圖不用改任何東西就找得到橋接程式。同一個連接埠的 …/raw 提供的是原始行格式。在演出裡,網址加上 ?leap=ws://host:port/v6.json 可以讓頁面改連別的橋接程式,?leap=sim 則會啟動模擬器。
相關 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 import { DEFAULT_URL } from '@openav/leap'; packages/leap/index.js 第 36 行
deviceEvent()#
函式 @openav/leap deviceEvent({ device = null , service = true } = {})
一則 LeapJS 裝置事件,告訴用戶端裝置有沒有接上、有沒有在串流,另外附上我們自己加的 service 旗標。
LeapJS(通訊協定 5 以上)會把它轉成 deviceAttached、deviceStreaming 和 streamingStarted。橋接程式在連線時,以及每次狀態改變時都會送一則。LeapInput 把它對應成自己的狀態:沒有服務是 'no-service',有服務沒裝置是 'no-device',裝置在串流就是 'tracking'。
參數 名稱 型別 說明 deviceString | null裝置 id,沒有裝置就是 null。 可省略 預設 null serviceBoolean追蹤服務有沒有在跑。LeapJS 會忽略它。LeapInput 用它區分「沒有追蹤服務」和「沒有裝置」。 可省略 預設 true
回傳 Object { event: { type: 'deviceEvent', state: { attached, streaming, id, type: 'Peripheral', service } } },沒有裝置時 id 是 'none'。
範例
js 複製
import { Signals } from '@openav/core' ;
import { LeapInput, deviceEvent } from '@openav/leap' ;
const signals = new Signals();
const leap = new LeapInput({ signals });
for (const event of [deviceEvent({ device: null , service: false }), deviceEvent({ device: null }), deviceEvent({ device: 'LE51' })]) {
leap.ingest(event);
console.log(leap.status, signals.get ('leap/status' ));
}
module 把三則裝置事件餵給 LeapInput:主控台印出 no-service 1、no-device 2、tracking 3。
相關 v6Header() LeapJS v6 連線的第一則訊息:{ serviceVersion, version },version 被限制在 1..6,因為 LeapJS 超過 6 會丟出錯誤。 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 STATUS leap/status 訊號的四個等級,用名字表示:off 0、bridge 1、service 2、tracking 3。import { deviceEvent } from '@openav/leap'; packages/leap/frame.js 第 197 行
FINGERS = array, 5 items
依序排好的五個手指名稱 thumb index middle ring pinky:hand.fingers 的順序,也是指尖訊號用的名字。
hand.fingers[0] 是拇指,hand.fingers[4] 是小指,所以 FINGERS[i] 就是 hand.fingers[i] 的名字。指尖訊號是 leap/hand/<side>/<finger>/x|y|z,用的就是這些名字,例如 leap/hand/right/index/y。
相關 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 handsOf() 把兩種傳輸格式(原始行或 LeapJS v6 影格)都讀成套件其他部分共用的同一種手的形狀,其他任何東西都回傳 []。 leapSignals() LeapInput 發布的所有訊號清單,連同範圍,格式是 [[name, meta], …],可以直接交給 signals.define()。import { FINGERS } from '@openav/leap'; packages/leap/frame.js 第 21 行
handAngles()#
函式 @openav/leap handAngles(direction, normal)
把手的方向和掌心法線轉成以弧度計的 { pitch, yaw, roll },沿用 LeapJS 的慣例。
手平放、掌心朝下、手指朝向遠離你的方向,就是 { pitch: 0, yaw: 0, roll: 0 }。這三個角度就是 leap/hand/<side>/pitch|yaw|roll 這幾個訊號,LeapInput 對每隻手都是用這個函式算出來的。
參數 名稱 型別 說明 directionArray<Number>手指指的方向 [x, y, z](單位向量)。 normalArray<Number>掌心法線 [x, y, z],從手掌往外指(掌心朝向感測器時是向下)。
回傳 Object { pitch, yaw, roll },各自在 −π..π:pitch 是手指往上翹,yaw 是轉向 +x(右邊),roll 是手掌翻轉。
注意 手指正對著天花板的時候,沒有 yaw 或 roll 可量,函式會回答 π(3.14)。手勢做到最高的時候會遇到,手垂直時請不要去映射那兩個訊號。 範例
js 複製
import { handAngles } from '@openav/leap' ;
const round = (a) => ({ pitch: +a.pitch.toFixed(2), yaw: +a.yaw.toFixed(2), roll: +a.roll.toFixed(2) });
console.log(round(handAngles([0, 0, -1], [0, -1, 0]))); // flat, palm down: all 0
console.log(round(handAngles([0, 0.5, -0.866], [0, -0.866, -0.5]))); // fingers tipped up 30 degrees: pitch 0.52
console.log(round(handAngles([0.707, 0, -0.707], [0, -1, 0]))); // fingers turned 45 degrees to the right: yaw 0.79
console.log(round(handAngles([0, 0, -1], [0.5, -0.866, 0]))); // palm turned over 30 degrees: roll 0.52
module 主控台印出四隻手的角度:全是 0,然後是 pitch: 0.52、yaw: 0.79 和 roll: 0.52,每次只有一個角度在變。
相關 normalizePoint() 把一個以公釐計的點,換算成互動範圍盒裡的 0..1,預設會夾住:leap/* 訊號用的就是這個正規化。 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 palmBasis() 進階用的輔助函式:由手的方向和法線算出掌心的正交基底 [x, y, z],列的排法就是 LeapJS 當作 hand.r 來讀的那種。 import { handAngles } from '@openav/leap'; packages/leap/frame.js 第 69 行
handBones()#
函式 @openav/leap handBones(hand)
一隻手要畫的骨頭,用成對的點表示:每根手指的關節依序相連、橫過指節的那條線,以及前臂。
它只列出成對的點,怎麼畫由你決定。LeapInput.skeleton() 會替你呼叫它。用 p5、three.js 或自己的投影來畫的時候,就直接用它。
參數 名稱 型別 說明 handObject來自 handsOf() 或 leap.hands 的一隻手。
回傳 Array [pointA, pointB] 成對的陣列,每個點是 [x, y, z],單位是公釐。一隻有五根各 5 個關節的手指、手腕和手肘的手,會得到 25 條。
範例
js 複製
import { INTERACTION_BOX, handBones, handsOf, mockFrame, normalizePoint } from '@openav/leap' ;
const ctx = document.getElementById('c' ).getContext('2d' );
ctx.fillStyle = '#111' ; ctx.fillRect(0, 0, 400, 300);
ctx.strokeStyle = '#ffd166' ; ctx.lineWidth = 2;
const at = (p) => { const n = normalizePoint(p, INTERACTION_BOX, false ); return [n[0] * 400, (1 - n[1]) * 300]; }; // front view
for (const hand of handsOf(mockFrame(2.0))) {
for (const [a, b] of handBones(hand)) {
ctx.beginPath(); ctx.moveTo(...at(a)); ctx.lineTo(...at(b)); ctx.stroke();
}
}
module 兩隻假手用正視圖畫在畫布上,一根骨頭一根骨頭地畫。
相關 handsOf() 把兩種傳輸格式(原始行或 LeapJS v6 影格)都讀成套件其他部分共用的同一種手的形狀,其他任何東西都回傳 []。 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 normalizePoint() 把一個以公釐計的點,換算成互動範圍盒裡的 0..1,預設會夾住:leap/* 訊號用的就是這個正規化。 import { handBones } from '@openav/leap'; packages/leap/frame.js 第 248 行
HandGestures#
類別 @openav/leap new HandGestures(thresholds = THRESHOLDS)
把捏合和握拳的力度變成乾淨的開始與結束事件,每隻手各一組,另外還有雙手捏合,用遲滯處理,所以抖動的值只會觸發一次。
手指「快要碰到」的時候,捏合力度會在任何單一門檻附近晃來晃去,只有一個門檻的話,就會每秒六十次地開始、結束、開始、結束。所以每個手勢都有兩個門檻:高過 on 才開始 ,低於 off 才結束 。預設值是:捏合高於 0.86 開始、低於 0.5 結束。雙手捏合在兩隻手都高於 0.8 時進入,任何一隻低於 0.5 就離開。握拳高於 0.8 開始、低於 0.5 結束。手消失的時候,它所有的手勢都會結束。
LeapInput 已經內建一個(leap.gestures),並把它的事件變成 leap/…/pinch-start 這些脈衝。你的手資料如果是從別的地方來,才需要單獨使用 HandGestures。
參數 名稱 型別 說明 thresholdsObject覆寫 THRESHOLDS 的預設值:{ pinch: { on, off }, bothPinch: { on, off }, grab: { on, off } }。沒寫的鍵維持預設。 可省略 預設 THRESHOLDS
成員 名稱 說明 update(hands)method update(hands) 接收 [{ side: 'left' | 'right', pinch, grab, … }],回傳剛剛發生的事件,順序固定:{ type: 'pinch-start' | 'pinch-end' | 'grab-start' | 'grab-end', side, hand },接著是 { type: 'both-pinch-start' | 'both-pinch-end', side: 'both', hands }。每個畫面呼叫一次。stategetter 現在被按住的是什麼:{ left: { pinch, grab }, right: { pinch, grab }, both },全都是布林值。
範例
js 複製
import { HandGestures } from '@openav/leap' ;
const gestures = new HandGestures();
const frame = (pinch) => gestures.update([{ side: 'right' , pinch, grab: 0 }]).map((e) => e.type);
console.log(frame(0.85)); // [] below 0.86: not yet
console.log(frame(0.9)); // [ 'pinch-start' ]
console.log(frame(0.6)); // [] still held: only below 0.5 ends it
console.log(frame(0.4)); // [ 'pinch-end' ]
module 主控台印出 []、[ 'pinch-start' ]、[]、[ 'pinch-end' ]:捏合高於 0.86 才開始,一直撐到低於 0.5。
js 複製
import { HandGestures } from '@openav/leap' ;
const gestures = new HandGestures({ pinch: { on: 0.7, off: 0.3 } }); // easier to start, harder to let go
const hand = (side, pinch) => ({ side, pinch, grab: 0 });
const names = (events) => events.map((e) => e.type + ':' + e.side);
console.log(names(gestures.update([hand('right' , 0.75), hand('left' , 0.82)]))); // single pinches only: both need 0.8
console.log(names(gestures.update([hand('right' , 0.9), hand('left' , 0.9)]))); // now both hands pinch
console.log(names(gestures.update([]))); // both hands leave: everything ends
module 主控台先印出 [ 'pinch-start:left', 'pinch-start:right' ],接著是 [ 'both-pinch-start:both' ],兩隻手離開時則印出三個結束事件。
相關 Hysteresis 一個施密特觸發器:數值升到 on 以上,update(v) 回報 'start',要降到 off 以下才會回報 'end'。 THRESHOLDS 手勢預設的遲滯門檻:捏合 0.86 / 0.5、雙手捏合 0.8 / 0.5、握拳 0.8 / 0.5(高於 on 開始,低於 off 結束)。 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 mockFrame() 時間 t 的一個影格的假手,會動:右手繞圈、每 3 秒捏合一次,左手升降,並且慢慢握拳。 import { HandGestures } from '@openav/leap'; packages/leap/gesture.js 第 39 行
handsOf()#
函式 @openav/leap handsOf(msg)
把兩種傳輸格式(原始行或 LeapJS v6 影格)都讀成套件其他部分共用的同一種手的形狀,其他任何東西都回傳 []。
這就是世界用來畫手的形狀(leap.hands)。原始影格把手指放在手的裡面。v6 影格把手指放成 pointables,用 handId 接到它們的手,handsOf() 負責做這個合併。兩者給出同一隻手,所以針對 leap.hands 寫的程式不在乎橋接程式是從哪個網址連的。
參數 名稱 型別 說明 msgObject一則解析好的訊息:原始影格(t: 'f')或 v6 影格(有 hands 和 pointables)。
回傳 Array 手的陣列,每隻是 { id, side: 'left' | 'right', confidence, pinch, grab, palm, velocity, normal, direction, width, fingers, extended, elbow, wrist, timeVisible }。位置單位是公釐。fingers 是 5 個關節陣列(最後一個是指尖),extended 是 5 個布林值。
範例
js 複製
import { mockFrame, rawToV6, handsOf } from '@openav/leap' ;
const raw = mockFrame(2.0);
const fromRaw = handsOf(raw)[0];
const fromV6 = handsOf(rawToV6(raw))[0]; // the same frame as LeapJS would send it
console.log(fromRaw.side, fromRaw.palm, fromRaw.pinch); // right [43.6, 233.2, 53.6] 1
console.log(fromV6.side, fromV6.palm, fromV6.pinch); // the same hand
console.log(handsOf(null ), handsOf({})); // [] []
module 兩種格式給出同一隻手:主控台兩次印出 right [ 43.6, 233.2, 53.6 ] 1,然後是 [] []。
相關 handBones() 一隻手要畫的骨頭,用成對的點表示:每根手指的關節依序相連、橫過指節的那條線,以及前臂。 rawToV6() 把一個原始影格一次轉成 LeapJS v6 影格,每次都用全新的轉換器(沒有歷史,所以沒有速度)。 mockFrame() 時間 t 的一個影格的假手,會動:右手繞圈、每 3 秒捏合一次,左手升降,並且慢慢握拳。 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 import { handsOf } from '@openav/leap'; packages/leap/frame.js 第 211 行
Hysteresis#
類別 @openav/leap new Hysteresis(on, off)
一個施密特觸發器:數值升到 on 以上,update(v) 回報 'start',要降到 off 以下才會回報 'end'。
on 和 off 之間的空隙,就是讓在某個門檻附近徘徊的數值不會閃爍的原因。每次呼叫 update() 會回傳 'start'(剛變成啟動)、'end'(剛停止)或 null(沒有變化)。啟動中遇到缺值或 NaN 會當作結束,所以感測器掉了就會放開。active 告訴你它現在是不是開著。
參數 名稱 型別 說明 onNumber數值要高過 它才會開始。 offNumber數值要低於 它才會結束。必須比 on 低,否則建構函式會丟出 "Hysteresis: off must be below on"。
成員 名稱 說明 update(v)method update(v) 回傳 'start'、'end' 或 null。release()method 強制結束,例如手不見的時候。只有它原本是啟動的才回傳 'end',否則是 null。
範例
js 複製
import { Hysteresis } from '@openav/leap' ;
const trigger = new Hysteresis(0.8, 0.4);
const values = [0.5, 0.9, 0.6, 0.3, 0.5];
console.log(values.map((v) => trigger.update(v))); // [ null, 'start', null, 'end', null ]
module 主控台印出 [ null, 'start', null, 'end', null ]:0.6 低於 0.8,但高於 0.4,所以還是按住的狀態。
相關 HandGestures 把捏合和握拳的力度變成乾淨的開始與結束事件,每隻手各一組,另外還有雙手捏合,用遲滯處理,所以抖動的值只會觸發一次。 THRESHOLDS 手勢預設的遲滯門檻:捏合 0.86 / 0.5、雙手捏合 0.8 / 0.5、握拳 0.8 / 0.5(高於 on 開始,低於 off 結束)。 import { Hysteresis } from '@openav/leap'; packages/leap/gesture.js 第 20 行
INTERACTION_BOX#
常數 @openav/leap INTERACTION_BOX = object, 2 keys
經典的 Leap 互動範圍盒,{ center: [0, 200, 0], size: [235.247, 235.247, 147.751] },單位是公釐,位置就是對它做正規化。
一個凍結的物件:中心在感測器上方 200 公釐,三個軸的尺寸是感測器自己的座標軸:x 是 235 公釐、y 是 235 公釐、z 是 148 公釐。normalizePoint() 把它對應到 0..1,主控台面板把它畫成外框,這樣你就看得到手什麼時候離開。要改 0 和 1 在哪裡,就傳自己的盒子給 new LeapInput({ box })。
相關 normalizePoint() 把一個以公釐計的點,換算成互動範圍盒裡的 0..1,預設會夾住:leap/* 訊號用的就是這個正規化。 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 mountLeapPanel() 掛上一個小面板,顯示 Leap 有沒有活著(橋接、服務、裝置、手)並畫出它看到的東西,還有一個模擬手的按鈕。 import { INTERACTION_BOX } from '@openav/leap'; packages/leap/frame.js 第 24 行
new LeapInput({ signals = null , url = DEFAULT_URL, reconnect = true , fingers = true , box = INTERACTION_BOX, thresholds = THRESHOLDS, WebSocket: WS = null } = {})
從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。
瀏覽器沒辦法直接跟 Leap Motion 感測器溝通,所以同一台機器上有個小程式,也就是橋接程式 (startBridge() ,ws://127.0.0.1:6437),負責讀取它,再用 WebSocket 把手送出來。LeapInput 連到那個橋接程式,只做一件事:把手變成正規化的訊號 ,絕不碰參數或世界。在演出裡,createShow({ modules: { leap: true } }) 會建好一個,用 show.leap 回傳。主控台的 L1 面板會告訴你哪一段連線不通。
每一邊(left、right)它會發布:leap/hand/<side>/present(0 或 1)、x、y、z(掌心在互動範圍盒裡的位置,0..1,y 向上,z 朝向玩家)、pinch 和 grab(0..1)、roll、pitch、yaw(−π..π 弧度)、speed(0..1,1 = 每秒 1 公尺),還有五個指尖的 <finger>/x|y|z。共用的有:leap/status(0 沒有橋接、1 有橋接但追蹤服務沒開、2 服務在但沒有裝置、3 追蹤中)、leap/hands(0..2)和 leap/both/distance(兩掌之間的距離,1 = 40 公分)。完整的清單和範圍就是 leapSignals() 的回傳值。
手勢是帶有 { side, x, y, z, strength } 的脈衝:leap/hand/<side>/pinch-start|pinch-end 和 grab-start|grab-end,以及 leap/both/pinch-start|pinch-end(內容是中點的 { x, y, z, distance })。它們有遲滯,所以在門檻附近徘徊的捏合只會觸發一次,不會一秒鐘觸發六十次(見 HandGestures )。手離開畫面就會放開:它的 present、pinch、grab 和 speed 歸零,手勢也跟著結束。
連續控制請把訊號映射到參數({ source: 'leap/hand/right/y', target: 'energy' }),這樣時間軸、旋鈕和手就可以互換。leap.hands(以公釐計的關節)只用來把手本身畫出來。
呼叫之前
要接真的手:橋接程式必須在跑(node packages/leap/bridge/leap-bridge.mjs,或加 --mock 得到會動的假手),而且你要呼叫 connect()。第一個影格到達之前不會發布任何東西。沒有感測器也沒有橋接程式:用 ingest() 餵手給它,或呼叫 simulate() 把滑鼠變成手。
參數 名稱 型別 說明 signalsSignals要發布進去的 Signals 登錄表。沒有的話,這個輸入仍然能用(hands、onFrame),只是不發布任何訊號。 可省略 預設 null urlString橋接程式的位址:LeapJS 通訊協定(預設,DEFAULT_URL ),或 …/raw 的原始行格式。任何還在講 LeapJS v6 通訊協定的服務也行。 可省略 預設 ws://127.0.0.1:6437/v6.json reconnectBoolean連線中斷之後要不要重試:每次等待 0.5 秒、1 秒、2 秒……最長 5 秒。 可省略 預設 true fingersBoolean要不要發布指尖訊號(每根手指 3 個,兩隻手共 30 個)。false 會省略它們,訊號清單比較短。 可省略 預設 true boxObject位置據以正規化的互動範圍盒 { center, size },單位是公釐。預設是 INTERACTION_BOX 。 可省略 預設 INTERACTION_BOX thresholdsObject手勢遲滯值的覆寫,例如 { pinch: { on: 0.9, off: 0.6 } }(見 THRESHOLDS )。沒寫的鍵維持預設。 可省略 預設 THRESHOLDS WebSocketFunction要用的 WebSocket 建構函式。傳你自己的,就能在沒有網路的情況下測試。 可省略 預設 globalThis.WebSocket
成員 名稱 說明 connect()method 開啟跟橋接程式的連線,會一直重試(間隔愈來愈長),直到 disconnect()。回傳這個輸入本身。 disconnect()method 關閉連線並停止重試。狀態變成 'closed',所有的手都放開。 connectedgetter 跟橋接程式的 socket 現在是不是開著。 hand(side)method hand('left') 或 hand('right'):該邊的手物件,不在畫面裡就是 null。normalize(p, clamp = true)method 把一個以公釐計的點換算成這個輸入的互動範圍盒裡的 0..1(除非 clamp 是 false,否則會被夾住)。 onFrame(cb)method 每收到一個有手資料的影格,就呼叫 cb(message, hands)。回傳一個取消訂閱的函式。 onStatus(cb)method 狀態一改變就呼叫 cb(status, leap)。回傳一個取消訂閱的函式。 ingest(msg)method 餵進一則解析好的訊息,就是 socket 會送來的東西:v6 的標頭、v6 的影格、裝置事件,或原始的狀態或影格行。它是公開的,讓測試、錄影和模擬器走的路徑跟真實影格一模一樣。 simulate({ target = typeof window !== 'undefined' ? window : null, hz = 60 } = {})method 沒有感測器?滑鼠和觸控會變成手:移動 = 右手掌心(x 橫向、y 往螢幕上方)、滾輪 = 深度、按下 = 捏合、右鍵或 Alt+按下 = 握拳、按住 Shift 控制左手、觸控螢幕上第二根手指 = 左手。{ target = window, hz = 60 }。回傳 stop()。沒有可以監聽的目標時會丟出錯誤。 simulatinggetter 模擬器是否正在執行。 stopSimulation()method 停止模擬器。如果橋接程式有連著,它的手就會回來。 project(p, w, h, { view = 'oblique', box = this.box } = {})method 把以公釐計的點換算成畫布像素:project(p, w, h, { view, box })。view 是 'oblique'(預設:表演者的視角,畫布往上 = 更高且更遠,所以平放的手看得到手指)、'front'(x 橫向、y 向上)或 'top'(z 往畫布下方)。回傳 [x, y]。 skeleton(ctx, w, h, { view = 'oblique', box = this.box, colorLeft = 'rgba(55,201,120,0.95)', colorRight = 'rgba(255,209,102,0.95)', lineWidth = 2, hands = this.hands } = {})method 把手畫到 2D 畫布的 context 上:skeleton(ctx, w, h, { view, box, colorLeft, colorRight, lineWidth, hands })。掌心和指尖是圓點,捏合時會多一個圈。在盒子外面的點照樣會畫。 mountPanel(el, opts)method 把狀態與骨架面板掛到一個元素裡,等同 mountLeapPanel(el, leap, opts) 。
注意 可以讀的欄位:status('idle'、'connecting'、'bridge'、'no-service'、'no-device'、'tracking'、'simulated'、'closed')、hands(陣列,每隻手有 id、side、palm、fingers(5 × 5 個關節)、pinch、grab、velocity、normal、direction ……,單位是公釐)、frame(最近收到的原始訊息)、frames、fps、device、serviceVersion、gestures 和 box。 模擬器在跑的時候,手由它掌管:橋接程式來的真實影格會被忽略,橋接程式的狀態會被記住,等模擬器停止再恢復。 訊號是正規化的(y = 1 代表向上)。leap.hands 和 LeapJS 的影格則保留感測器自己的公釐單位,+z 朝向玩家。 橋接程式怎麼溝通、各種格式和疑難排解表,都在套件的 README 。 範例
js 複製
import { Signals } from '@openav/core' ;
import { LeapInput, mockFrame } from '@openav/leap' ;
const signals = new Signals();
const leap = new LeapInput({ signals });
leap.ingest(mockFrame(2.0)); // a made-up frame: the same path real frames take
console.log(leap.status, leap.hands.length); // tracking 2
console.log(signals.get ('leap/hand/right/pinch' )); // 1
leap.skeleton(document.getElementById('c' ).getContext('2d' ), 400, 300);
module 主控台印出 tracking 2 和 1(假的右手在第 2 秒正在捏合),兩隻手也畫在畫布上。
js 複製
import { Signals } from '@openav/core' ;
import { LeapInput, mockFrame } from '@openav/leap' ;
const signals = new Signals();
const leap = new LeapInput({ signals });
signals.onAny((name, value, meta) => {
if (meta.kind === 'pulse' ) console.log(name, value.strength.toFixed(2));
});
for (let i = 0; i <= 360; i++) leap.ingest(mockFrame(i / 60)); // six seconds of mock hands at 60 frames a second
console.log(signals.get ('leap/hands' ), signals.get ('leap/status' ));
module 六秒鐘的假手,脈衝在觸發時一一列出:右手捏合兩次(leap/hand/right/pinch-start 0.86,然後 pinch-end 0.50,再來一次),左手握拳一次(leap/hand/left/grab-start 0.80)。最後印出 2 3(兩隻手,狀態 3 = 追蹤中)。
js 複製
import { Signals } from '@openav/core' ;
import { LeapInput } from '@openav/leap' ;
const signals = new Signals();
const leap = new LeapInput({ signals });
const ctx = document.getElementById('c' ).getContext('2d' );
leap.simulate({ target: window }); // mouse and touch become hands
leap.onFrame(() => {
ctx.clearRect(0, 0, 400, 300);
leap.skeleton(ctx, 400, 300);
document.getElementById('app' ).textContent = 'y ' + signals.get ('leap/hand/right/y' ).toFixed(2) + ' pinch ' + signals.get ('leap/hand/right/pinch' ).toFixed(2);
});
module 在頁面上移動滑鼠:右手會跟著動,文字顯示它的高度(y)。按下按鍵是捏合,按右鍵是握拳。
js 複製
import { Signals } from '@openav/core' ;
import { LeapInput } from '@openav/leap' ;
const signals = new Signals();
const leap = new LeapInput({ signals }).connect(); // ws://127.0.0.1:6437/v6.json
leap.onStatus((status) => console.log('leap' , status)); // connecting, bridge, tracking …
signals.on('leap/hand/right/pinch-start' , ({ x, y }) => console.log('pinch at' , x, y));
static 橋接程式開著、手放在感測器上方,主控台會跟著連線的進展(leap connecting、leap bridge、leap tracking),每次捏合印出一行。
待確認
這裡沒有用真正的 Leap Motion 感測器試過:範例是把假手(mockFrame)餵進同一條 ingest() 路徑。
在公開的 https 頁面上,Chrome 是否會在頁面連到 127.0.0.1 之前詢問權限(套件 README 說需要「loopback-network」權限),這裡沒有實測。
相關 leapSignals() LeapInput 發布的所有訊號清單,連同範圍,格式是 [[name, meta], …],可以直接交給 signals.define()。mockFrame() 時間 t 的一個影格的假手,會動:右手繞圈、每 3 秒捏合一次,左手升降,並且慢慢握拳。 HandGestures 把捏合和握拳的力度變成乾淨的開始與結束事件,每隻手各一組,另外還有雙手捏合,用遲滯處理,所以抖動的值只會觸發一次。 mountLeapPanel() 掛上一個小面板,顯示 Leap 有沒有活著(橋接、服務、裝置、手)並畫出它看到的東西,還有一個模擬手的按鈕。 normalizePoint() 把一個以公釐計的點,換算成互動範圍盒裡的 0..1,預設會夾住:leap/* 訊號用的就是這個正規化。 handsOf() 把兩種傳輸格式(原始行或 LeapJS v6 影格)都讀成套件其他部分共用的同一種手的形狀,其他任何東西都回傳 []。 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 import { LeapInput } from '@openav/leap'; packages/leap/index.js 第 64 行
leapSignals()#
函式 @openav/leap leapSignals({ fingers = true } = {})
LeapInput 發布的所有訊號清單,連同範圍,格式是 [[name, meta], …],可以直接交給 signals.define()。
new LeapInput({ signals }) 已經把這些全部宣告好了,所以很少需要自己呼叫。想要這份清單的時候它就很有用:做訊號選單、檢查某條路由的來源存不存在、或在感測器還沒接上之前先宣告訊號。它回傳的名稱就是訊號參考 裡 Leap 的那一批詞彙。
參數 名稱 型別 說明 fingersBoolean要不要包含指尖訊號。false 會少掉那 30 個。 可省略 預設 true
回傳 Array [name, meta] 的成對清單:有指尖是 63 個,沒有是 33 個。每個 meta 都有 source: 'leap',以及 min 和 max(roll、pitch、yaw 是弧度,−π..π),脈衝則是 kind: 'pulse'。
範例
js 複製
import { Signals } from '@openav/core' ;
import { leapSignals } from '@openav/leap' ;
console.log(leapSignals().length, leapSignals({ fingers: false }).length); // 63 33
const signals = new Signals();
for (const [name, meta] of leapSignals({ fingers: false })) signals.define(name, meta);
console.log(signals.meta.get ('leap/hand/right/roll' ).min); // -3.14159…
console.log(signals.meta.get ('leap/hand/left/pinch-start' ).kind); // pulse
module 主控台印出 63 33,然後是 roll 範圍的下限(−π)和 pulse。
相關 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 mockFrame() 時間 t 的一個影格的假手,會動:右手繞圈、每 3 秒捏合一次,左手升降,並且慢慢握拳。 import { leapSignals } from '@openav/leap'; packages/leap/index.js 第 43 行
mockFrame()#
函式 @openav/leap mockFrame(t, id = Math.round(t * 60), { hands = 2 } = {})
時間 t 的一個影格的假手,會動:右手繞圈、每 3 秒捏合一次,左手升降,並且慢慢握拳。
這就是橋接程式 --mock 模式送出的串流,也是沒有感測器時試 Leap 程式的辦法。右手每 3 秒有大約 1 秒在捏合,左手每 12 秒捏合一次,兩者重疊的時刻(例如第 8 秒),兩隻手同時捏合,所以雙手捏合也會觸發。左手的拳頭則慢慢地出現又消失。輸出走的轉換與發布路徑,跟真正的 Leap Motion Controller 完全一樣。
參數 名稱 型別 說明 tNumber時間,單位是秒。結果只跟 t 有關,所以每次都一樣。 idNumber寫進影格裡的影格編號。 可省略 預設 Math.round(t * 60) handsNumber手的數量:2(右手和左手)、1(只有右手)或 0。 可省略 預設 2
範例
js 複製
import { mockFrame } from '@openav/leap' ;
for (const t of [0.5, 2.0, 8.0]) {
const frame = mockFrame(t);
console.log(t, frame.hands.map((h) => h.side + ' pinch ' + h.pinch).join(' · ' ));
}
module 主控台印出 0.5 R pinch 0 · L pinch 0、2 R pinch 1 · L pinch 0 和 8 R pinch 1 · L pinch 1(雙手捏合)。
相關 mockHand() 用掌心位置加上捏合、握拳、roll、yaw、pitch,做出一隻假手,五根手指和手臂都有。 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 handsOf() 把兩種傳輸格式(原始行或 LeapJS v6 影格)都讀成套件其他部分共用的同一種手的形狀,其他任何東西都回傳 []。 import { mockFrame } from '@openav/leap'; packages/leap/mock.js 第 104 行
mockHand()#
函式 @openav/leap mockHand({ id = 1, side = 'R' , palm = [0, 200, 0], pinch = 0, grab = 0, roll = 0, yaw = 0, pitch = 0, vel = [0, 0, 0], conf = 1, width = 85 } = {})
用掌心位置加上捏合、握拳、roll、yaw、pitch,做出一隻假手,五根手指和手臂都有。
它在空間裡放一隻合理的手:掌心、指節、隨 grab 彎起來的手指、pinch 到 1 時碰在一起的拇指和食指、手腕和手肘。LeapInput.simulate() 用它把滑鼠變成手,mockFrame() 用它做出會動的手。需要把手剛好放在某個位置來測試時,就用它。
參數 名稱 型別 說明 idNumber這隻手的 id。 可省略 預設 1 side'L' | 'R'右手用 'R',左手用 'L'。 可省略 預設 'R' palmArray<Number>掌心位置 [x, y, z],單位是公釐(+x 向右、+y 向上、+z 朝向玩家)。 可省略 預設 [0, 200, 0] pinchNumber拇指到食指,0..1。到 1 的時候兩個指尖碰在一起。 可省略 預設 0 grabNumber拳頭握得多緊,0..1。 可省略 預設 0 rollNumber弧度,正值是往拇指那一側翻。 可省略 預設 0 yawNumber弧度,正值是讓手指轉向 +x。 可省略 預設 0 pitchNumber弧度,正值是讓手指往上翹。 可省略 預設 0 velArray<Number>掌心速度,單位是公釐每秒。 可省略 預設 [0, 0, 0] confNumber追蹤的信心程度,0..1。 可省略 預設 1 widthNumber手掌寬度,單位是公釐。 可省略 預設 85
回傳 Object 原始行格式的手:{ id, side, conf, pinch, grab, palm, vel, n, dir, w, f, ext, arm },其中 f 是 5 根手指 × 5 個關節(從拇指到小指,每根的最後一個關節是指尖)。把手包進 { t: 'f', id, fps, hands: [...] } 就成了一個影格。
範例
js 複製
import { mockHand } from '@openav/leap' ;
const open = mockHand({ palm: [0, 200, 0] });
const pinched = mockHand({ palm: [0, 200, 0], pinch: 1 });
const gap = (hand) => Math.hypot(...hand.f[0][4].map((v, i) => v - hand.f[1][4][i])).toFixed(0);
console.log('thumb to index tip, open:' , gap(open), 'mm pinched:' , gap(pinched), 'mm' );
module 主控台量出拇指指尖和食指指尖的距離:手張開時是 100 mm,捏合時是 0 mm。
相關 mockFrame() 時間 t 的一個影格的假手,會動:右手繞圈、每 3 秒捏合一次,左手升降,並且慢慢握拳。 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 handsOf() 把兩種傳輸格式(原始行或 LeapJS v6 影格)都讀成套件其他部分共用的同一種手的形狀,其他任何東西都回傳 []。 handBones() 一隻手要畫的骨頭,用成對的點表示:每根手指的關節依序相連、橫過指節的那條線,以及前臂。 import { mockHand } from '@openav/leap'; packages/leap/mock.js 第 42 行
mountLeapPanel()#
函式 @openav/leap mountLeapPanel(el, leap, { width = 300, simulate = true , simTarget } = {})
掛上一個小面板,顯示 Leap 有沒有活著(橋接、服務、裝置、手)並畫出它看到的東西,還有一個模擬手的按鈕。
一行狀態列會告訴你是哪一段連線壞了,依序是:沒有橋接、有橋接但追蹤服務沒開、有服務但沒有裝置、追蹤中(附上手的數量和影格率)。下面是個小小的正視圖,畫出互動範圍盒(手在盒子外面一眼就看得出來,它的訊號會被夾在邊緣)和手的骨架。它只在有影格或狀態改變時重畫,感測器閒置時什麼都不會跑。
在演出裡不用自己呼叫:宣告 modules: { leap: true } 之後,主控台的 L1 · Input 面板會掛上它。
參數 名稱 型別 說明 elHTMLElement面板要放在哪個元素裡。 leapLeapInput要顯示的 LeapInput 。 widthNumber繪圖區的寬度(CSS 像素),高度跟著決定(約為寬度的 0.62 倍)。 可省略 預設 300 simulateBoolean要不要顯示「simulate」按鈕(滑鼠和觸控變成手)。 可省略 預設 true simTargetHTMLElement | Window模擬器監聽哪個目標。預設是 leap.simTarget,再不然是 window。 可省略
回傳 Object { el, render(), destroy() }:面板的根元素、重畫它的函式,以及移除它並取消訂閱的函式。
範例
js 複製
import { LeapInput, mockFrame, mountLeapPanel } from '@openav/leap' ;
const leap = new LeapInput();
const panel = mountLeapPanel(document.getElementById('app' ), leap, { width: 360 });
leap.ingest(mockFrame(2.0)); // a made-up frame stands in for the sensor
panel.render();
module 頁面上出現一個面板:綠色圓點、狀態列 tracking · 2 hands · 60 fps · R:pinch(右手正在捏合)、一個「simulate」按鈕,以及畫在小方框裡的兩隻假手。
相關 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 mountConsole() 把導演控台掛進一個元素:播放控制與 scrubber、圖層總覽、輸入、映射、參數推桿、訊號音量表,以及全螢幕的演出模式。 import { mountLeapPanel } from '@openav/leap'; packages/leap/panel.js 第 28 行
normalizePoint()#
函式 @openav/leap normalizePoint(p, box = INTERACTION_BOX, clamp = true )
把一個以公釐計的點,換算成互動範圍盒裡的 0..1,預設會夾住:leap/* 訊號用的就是這個正規化。
經典的盒子中心在感測器上方 200 公釐,大小約 235 × 235 × 148 公釐(INTERACTION_BOX )。掌心在中心讀到 [0.5, 0.5, 0.5],掌心遠在盒子外面,那個軸就讀到 0 或 1,這就是手離開盒子時訊號會「夾在邊緣」的原因。想知道超出多遠,就傳 clamp = false。
參數 名稱 型別 說明 pArray<Number>點 [x, y, z],單位是公釐。 boxObject範圍盒 { center, size },單位是公釐。 可省略 預設 INTERACTION_BOX clampBoolean把每個值限制在 0..1。false 的話,盒子外面的點可以超出範圍。 可省略 預設 true
回傳 Array<Number> [x, y, z],每個都是 (p − center) / size + 0.5:0.5 是盒子的中心,x 往右增加、y 往上增加、z 朝向玩家。
範例
js 複製
import { normalizePoint } from '@openav/leap' ;
console.log(normalizePoint([0, 200, 0])); // [ 0.5, 0.5, 0.5 ]: the centre
console.log(normalizePoint([500, 0, -500])); // [ 1, 0, 0 ]: far outside, clamped
console.log(normalizePoint([100, 320, 0]).map((v) => +v.toFixed(2))); // [ 0.93, 1, 0.5 ]
module 主控台印出 [ 0.5, 0.5, 0.5 ]、[ 1, 0, 0 ] 和 [ 0.93, 1, 0.5 ]。
相關 INTERACTION_BOX 經典的 Leap 互動範圍盒,{ center: [0, 200, 0], size: [235.247, 235.247, 147.751] },單位是公釐,位置就是對它做正規化。 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 handAngles() 把手的方向和掌心法線轉成以弧度計的 { pitch, yaw, roll },沿用 LeapJS 的慣例。 import { normalizePoint } from '@openav/leap'; packages/leap/frame.js 第 78 行
palmBasis()#
函式 @openav/leap palmBasis(direction, normal, left)
進階用的輔助函式:由手的方向和法線算出掌心的正交基底 [x, y, z],列的排法就是 LeapJS 當作 hand.r 來讀的那種。
和 boneBasis() 一樣,它的存在是為了讓 V6Converter 寫出的 LeapJS 影格有 hand.rotationAngle() 能用的 r。要畫手的世界讀的是 leap.hands 和 handAngles() 。
參數 名稱 型別 說明 directionArray<Number>手指指的方向。 normalArray<Number>掌心法線。 leftBoolean左手傳 true:x 會反向。
回傳 Array 三列 [x, y, z](x 是橫向、y 是手背、z 朝向手腕),四捨五入到小數三位。
範例
js 複製
import { palmBasis } from '@openav/leap' ;
console.log(palmBasis([0, 0, -1], [0, -1, 0], false )); // flat hand, palm down, fingers forward: the identity
module 主控台印出單位矩陣 [ [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ]。
相關 boneBasis() 進階用的輔助函式:用兩個關節和掌心法線重建一根骨頭的正交基底 [x, y, z],做法符合 LeapJS 的期待。 handAngles() 把手的方向和掌心法線轉成以弧度計的 { pitch, yaw, roll },沿用 LeapJS 的慣例。 V6Converter 把原始的手資料行轉成 LeapJS v6 影格,並保存通訊協定需要的少量狀態:可見時間、平滑過的位置和指尖速度。 import { palmBasis } from '@openav/leap'; packages/leap/frame.js 第 60 行
PROTOCOL_VERSION#
常數 @openav/leap PROTOCOL_VERSION = 6
我們支援的 LeapJS 通訊協定最高版本:6。LeapJS 1.1.1 和更早的版本遇到更高的版本會丟出 "unrecognized version"。
v6Header() 不會回報比這更高的版本,橋接程式也把 /v7.json 當成版本 6 回應。它是那裡 version 參數的預設值。
相關 v6Header() LeapJS v6 連線的第一則訊息:{ serviceVersion, version },version 被限制在 1..6,因為 LeapJS 超過 6 會丟出錯誤。 protocolOfPath() /vN.json 這種路徑要求的是哪個通訊協定版本,回傳數字,路徑不是這種格式就回傳 null。DEFAULT_URL LeapInput 預設連的位址:ws://127.0.0.1:6437/v6.json,也就是這台機器上、講 LeapJS 通訊協定的橋接程式。import { PROTOCOL_VERSION } from '@openav/leap'; packages/leap/frame.js 第 27 行
protocolOfPath()#
函式 @openav/leap protocolOfPath(pathname)
/vN.json 這種路徑要求的是哪個通訊協定版本,回傳數字,路徑不是這種格式就回傳 null。
橋接程式用它判斷一個 WebSocket 升級請求是不是 LeapJS 的用戶端:/v6.json 是,/raw 另外處理,其他的一律回 404 拒絕。
參數 名稱 型別 說明 pathnameString網址的路徑,例如 '/v6.json'。
回傳 Number | null /v<數字>.json 裡的數字,或 null。它不會截到 6:/v7.json 得到 7(橋接程式再以版本 6 回應)。
範例
js 複製
import { protocolOfPath } from '@openav/leap' ;
console.log(protocolOfPath('/v6.json' ), protocolOfPath('/v3.json' ), protocolOfPath('/v7.json' )); // 6 3 7
console.log(protocolOfPath('/raw' ), protocolOfPath('/v6.jsonx' ), protocolOfPath(undefined )); // null null null
module 主控台印出 6 3 7,然後是 null null null。
相關 v6Header() LeapJS v6 連線的第一則訊息:{ serviceVersion, version },version 被限制在 1..6,因為 LeapJS 超過 6 會丟出錯誤。 PROTOCOL_VERSION 我們支援的 LeapJS 通訊協定最高版本:6。LeapJS 1.1.1 和更早的版本遇到更高的版本會丟出 "unrecognized version"。 import { protocolOfPath } from '@openav/leap'; packages/leap/frame.js 第 202 行
rawToV6()#
函式 @openav/leap rawToV6(raw, tUs, opts)
把一個原始影格一次轉成 LeapJS v6 影格,每次都用全新的轉換器(沒有歷史,所以沒有速度)。
用在測試,以及查看舊版草圖會收到什麼的時候很方便。處理一條串流的話,請改為保留一個 V6Converter ,timeVisible 和 tipVelocity 才能累積。
參數 名稱 型別 說明 rawObject原始影格 { t: 'f', id, fps, hands }。 tUsNumber時間,單位是微秒。預設是現在。 可省略 optsObject給 V6Converter 的選項:{ box, smoothing }。 可省略
回傳 Object v6 影格。LeapJS 的 Frame、Hand、Pointable、Finger 和 Bone 建構函式會讀的每個欄位都有。
範例
js 複製
import { mockFrame, rawToV6 } from '@openav/leap' ;
const frame = rawToV6(mockFrame(2.0, 120), 5_000_000);
console.log(Object.keys(frame).join(' ' ));
console.log(frame.hands.length, frame.pointables.length, frame.hands[0].type, frame.hands[0].pinchStrength);
module 主控台印出影格的欄位(id timestamp currentFrameRate hands pointables interactionBox gestures r s t),然後是 2 10 right 1。
相關 V6Converter 把原始的手資料行轉成 LeapJS v6 影格,並保存通訊協定需要的少量狀態:可見時間、平滑過的位置和指尖速度。 handsOf() 把兩種傳輸格式(原始行或 LeapJS v6 影格)都讀成套件其他部分共用的同一種手的形狀,其他任何東西都回傳 []。 v6Header() LeapJS v6 連線的第一則訊息:{ serviceVersion, version },version 被限制在 1..6,因為 LeapJS 超過 6 會丟出錯誤。 mockFrame() 時間 t 的一個影格的假手,會動:右手繞圈、每 3 秒捏合一次,左手升降,並且慢慢握拳。 import { rawToV6 } from '@openav/leap'; packages/leap/frame.js 第 185 行
RESERVED_PORTS#
常數 @openav/leap RESERVED_PORTS = array, 1 items
Leap 橋接程式拒絕使用的連接埠:[7457],那是 The Last Input 和後台監看用的。
startBridge({ port: 7457 }) 會立刻丟出 "port 7457 belongs to The Last Input",所以橋接程式絕不會占走後台監看監聽的連接埠(node packages/monitor/server.js)。輸出橋接也一樣拒絕這一個。
相關 MonitorFeed 把正在進行的演出的快照透過 WebSocket 串流到後台中繼站,每秒最多 hz 次,沒有中繼站的時候安靜地持續嘗試。 import { RESERVED_PORTS } from '@openav/leap/bridge'; packages/leap/bridge/leap-bridge.mjs 第 31 行
startBridge()#
函式 @openav/leap startBridge({ port = DEFAULT_PORT, host = '127.0.0.1' , mock = false , hz = 60, bin = null , log = () => {} } = {})
在這台機器上啟動 Leap 橋接程式:一個只綁 loopback 的 WebSocket 與 HTTP 伺服器,把感測器的手送給每一個頁面(只能在 Node 執行)。
node packages/leap/bridge/leap-bridge.mjs 執行的就是它(加 --mock 得到假手,另有 --port、--hz、--bin、--quiet)。它提供 ws://127.0.0.1:6437/v6.json(LeapJS 通訊協定,2014 到 2021 年所有的瀏覽器草圖講的都是它)、ws://…/raw(原始行)、GET /raw 和 /hands 上以 server-sent events 送的同樣內容,以及 GET /health(來源、服務、裝置、fps、手、用戶端)。任何頁面來源都可以讀取,跟原本的 Leap 服務一樣。
想把橋接程式放進你自己的 Node 程式裡(例如測試)時,就自己呼叫它。連接埠是 7457(那是 The Last Input 和後台監看用的)或 host 不是 loopback,startBridge() 會立刻丟出錯誤。連接埠已被占用,回傳的 promise 會被拒絕,訊息會建議你執行 curl http://127.0.0.1:6437/health。
呼叫之前
Node 22 以上,要接真的手,還需要 Ultraleap Hand Tracking 服務,以及第一次啟動時用來編譯讀取程式的 Xcode clang。mock: true 的話兩者都不需要。
參數 名稱 型別 說明 portNumber要監聽的連接埠,沒特別指定就是 DEFAULT_PORT 。0 表示隨便挑一個空的。RESERVED_PORTS 裡的連接埠(7457)會被拒絕。 可省略 預設 DEFAULT_PORT hostString要綁定的位址。只允許 loopback(127.0.0.1、::1、localhost):手的資料絕不會離開這台機器。 可省略 預設 '127.0.0.1' mockBooleantrue 的話,送出兩隻會動的假手(mockFrame() ),不讀感測器。 可省略 預設 false hzNumber每個用戶端每秒最多收到幾個影格(限制在 1..120)。慢的用戶端會跳過影格,不會排隊。 可省略 預設 60 binString | nullleap-stream 讀取程式的路徑。預設是橋接程式第一次啟動時編譯它的 C 讀取程式(需要 Xcode 命令列工具)並快取起來。 可省略 預設 null logFunction每一件值得知道的事(用戶端進出、狀態改變)都會以一行文字呼叫它。 可省略 預設 () => {}
回傳 Promise<Object> 開始監聽之後才完成,結果是 { port, state, health(), close() }:實際的連接埠(port: 0 時有用)、即時狀態、回傳 /health 物件的函式,以及停止一切並回傳 Promise 的函式。
範例
js 複製
import { startBridge } from '@openav/leap/bridge' ;
const bridge = await startBridge({ mock: true , port: 0 }); // made-up hands, any free port
const socket = new WebSocket('ws://127.0.0.1:' + bridge.port + '/v6.json' );
socket.onmessage = async ({ data }) => {
const message = JSON.parse(data);
if (message.serviceVersion) console.log('handshake:' , message); // { serviceVersion: 'mock', version: 6, bridge: 'open-audiovisual' }
if (message.hands) { // a frame
console.log('hands in the frame:' , message.hands.length); // 2
socket.close();
await bridge.close();
}
};
static 在 Node 裡:第一則訊息是交握 handshake: { serviceVersion: 'mock', version: 6, bridge: 'open-audiovisual' },接著收到一個影格,程式印出 hands in the frame: 2,並把橋接程式停掉。
相關 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 mockFrame() 時間 t 的一個影格的假手,會動:右手繞圈、每 3 秒捏合一次,左手升降,並且慢慢握拳。 V6Converter 把原始的手資料行轉成 LeapJS v6 影格,並保存通訊協定需要的少量狀態:可見時間、平滑過的位置和指尖速度。 DEFAULT_URL LeapInput 預設連的位址:ws://127.0.0.1:6437/v6.json,也就是這台機器上、講 LeapJS 通訊協定的橋接程式。import { startBridge } from '@openav/leap/bridge'; packages/leap/bridge/leap-bridge.mjs 第 45 行
STATUS = object, 4 keys
leap/status 訊號的四個等級,用名字表示:off 0、bridge 1、service 2、tracking 3。
一個凍結的物件 { off: 0, bridge: 1, service: 2, tracking: 3 }:0 沒有橋接、1 有橋接但追蹤服務沒開、2 有服務但沒有裝置、3 追蹤中。讀訊號時可以不用神祕數字:signals.get('leap/status') === STATUS.tracking。注意,發布成 leap/status 的是這個數字 。leap.status 是另一回事,它是字串('tracking'、'no-device' ……),而模擬器算 3。
注意 LeapInput 裡的程式是自己寫入數字,並不讀這個物件,所以 STATUS 只是方便你自己的程式用。字串的對應是:tracking 和 simulated 是 3,no-device 是 2,bridge 和 no-service 是 1,其餘是 0。相關 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 deviceEvent() 一則 LeapJS 裝置事件,告訴用戶端裝置有沒有接上、有沒有在串流,另外附上我們自己加的 service 旗標。 mountLeapPanel() 掛上一個小面板,顯示 Leap 有沒有活著(橋接、服務、裝置、手)並畫出它看到的東西,還有一個模擬手的按鈕。 import { STATUS } from '@openav/leap'; packages/leap/index.js 第 37 行
THRESHOLDS#
常數 @openav/leap THRESHOLDS = object, 3 keys
手勢預設的遲滯門檻:捏合 0.86 / 0.5、雙手捏合 0.8 / 0.5、握拳 0.8 / 0.5(高於 on 開始,低於 off 結束)。
一個凍結的物件 { pinch: { on, off }, bothPinch: { on, off }, grab: { on, off } }。數字來自 The Last Input,在 Leap Motion Controller 2 上調過,這樣一個橋接程式就是一種手感。要改的話,在每個輸入上設定:new LeapInput({ thresholds: { pinch: { on: 0.9, off: 0.6 } } }) 或 new HandGestures(...),這個物件本身改不了。
相關 HandGestures 把捏合和握拳的力度變成乾淨的開始與結束事件,每隻手各一組,另外還有雙手捏合,用遲滯處理,所以抖動的值只會觸發一次。 Hysteresis 一個施密特觸發器:數值升到 on 以上,update(v) 回報 'start',要降到 off 以下才會回報 'end'。 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 import { THRESHOLDS } from '@openav/leap'; packages/leap/gesture.js 第 13 行
V6Converter#
類別 @openav/leap new V6Converter({ box = INTERACTION_BOX, smoothing = 0.5 } = {})
把原始的手資料行轉成 LeapJS v6 影格,並保存通訊協定需要的少量狀態:可見時間、平滑過的位置和指尖速度。
所有為舊版 Leap 服務寫的瀏覽器草圖(LeapJS,2014 到 2021 年)講的都是 v6 通訊協定,所以橋接程式在 /v6.json 提供這個格式。這個類別負責轉換:一條串流用一個轉換器。它在通訊協定要求有狀態的地方才有狀態:手在畫面裡時 timeVisible 會增加,離開再回來就重新開始,stabilized… 位置是輕微的低通濾波,tipVelocity 是兩個影格之間的差分。
LeapC 給的是關節,不是骨頭的旋轉,所以骨頭的基底是從關節和掌心法線重建的(boneBasis() )。手勢不會被合成:gestures 永遠是 []。
參數 名稱 型別 說明 boxObject寫進每個影格 interactionBox 的互動範圍盒。 可省略 預設 INTERACTION_BOX smoothingNumber0..1:stabilizedPalmPosition 和 stabilizedTipPosition 落後的程度(0 完全跟隨,接近 1 幾乎不動)。 可省略 預設 0.5
成員 名稱 說明 convert(raw, tUs = nowUs())method convert(raw, tUs = 現在):把原始影格 { t: 'f', id, fps, hands } 和它的時間(微秒)轉成 v6 影格 { id, timestamp, currentFrameRate, hands, pointables, interactionBox, gestures: [], r, s, t }。pointable 的 id 是 handId × 10 + 手指編號。
範例
js 複製
import { V6Converter, mockFrame } from '@openav/leap' ;
const converter = new V6Converter();
converter.convert(mockFrame(1.0), 1_000_000); // microseconds
const frame = converter.convert(mockFrame(1.1), 1_100_000);
console.log(frame.hands[0].timeVisible); // 0.1: seen for a tenth of a second
console.log(frame.pointables.length, frame.pointables[1].tipVelocity); // 10 [ -21, 17, 28 ] mm per second
module 主控台印出 0.1,接著是 10 個 pointable,以及食指指尖的速度 [ -21, 17, 28 ](兩個影格之間,每秒幾公釐)。
相關 rawToV6() 把一個原始影格一次轉成 LeapJS v6 影格,每次都用全新的轉換器(沒有歷史,所以沒有速度)。 v6Header() LeapJS v6 連線的第一則訊息:{ serviceVersion, version },version 被限制在 1..6,因為 LeapJS 超過 6 會丟出錯誤。 deviceEvent() 一則 LeapJS 裝置事件,告訴用戶端裝置有沒有接上、有沒有在串流,另外附上我們自己加的 service 旗標。 handsOf() 把兩種傳輸格式(原始行或 LeapJS v6 影格)都讀成套件其他部分共用的同一種手的形狀,其他任何東西都回傳 []。 import { V6Converter } from '@openav/leap'; packages/leap/frame.js 第 91 行
v6Header(serviceVersion = 'unknown' , version = PROTOCOL_VERSION)
LeapJS v6 連線的第一則訊息:{ serviceVersion, version },version 被限制在 1..6,因為 LeapJS 超過 6 會丟出錯誤。
用戶端一連到 /v6.json,橋接程式就會送出這則訊息(外加 bridge: 'open-audiovisual'),LeapInput 收到後會把狀態變成 'bridge'。要求 7 會得到 6。
參數 名稱 型別 說明 serviceVersionString要回報的追蹤服務版本,例如 '5.20.0'。 可省略 預設 'unknown' versionNumber用戶端要求的通訊協定版本。限制在 1..6。 可省略 預設 PROTOCOL_VERSION
回傳 Object 物件 { serviceVersion: String, version: Number }。
範例
js 複製
import { v6Header } from '@openav/leap' ;
console.log(v6Header('5.20.0' )); // { serviceVersion: '5.20.0', version: 6 }
console.log(v6Header('x' , 3)); // { serviceVersion: 'x', version: 3 }
console.log(v6Header('x' , 9).version); // 6: never above what LeapJS accepts
module 主控台印出版本 6 的標頭、版本 3 的標頭,以及要求 9 時得到的 6。
相關 deviceEvent() 一則 LeapJS 裝置事件,告訴用戶端裝置有沒有接上、有沒有在串流,另外附上我們自己加的 service 旗標。 PROTOCOL_VERSION 我們支援的 LeapJS 通訊協定最高版本:6。LeapJS 1.1.1 和更早的版本遇到更高的版本會丟出 "unrecognized version"。 protocolOfPath() /vN.json 這種路徑要求的是哪個通訊協定版本,回傳數字,路徑不是這種格式就回傳 null。LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 import { v6Header } from '@openav/leap'; packages/leap/frame.js 第 188 行
核心:訊號、參數、舞台、演出#
所有套件共用的匯流排、世界、createShow()。 14
@openav/core
new Bus()
最陽春的事件發射器:on(event, cb)、emit(event, payload)、off(event, cb)。沒有萬用字元,也不是非同步。
new Bus() 是給你自己的程式用的小型發布與訂閱工具:用 on() 訂閱一個事件名稱,用 emit() 觸發,每個函式都會收到你傳的那一個內容。函式依訂閱順序同步執行,都在 emit() 裡面跑完。
演出裡各部分溝通靠的不是它。輸入和世界是透過 Signals (有範圍的具名值)和 Params 溝通,框架裡沒有別的東西用到 Bus,createShow() 也不會建立一個。
範例
js 複製
import { Bus } from '@openav/core' ;
const bus = new Bus();
const off = bus.on('hit' , (strength) => console.log('hit' , strength));
bus.emit('hit' , 0.8); // hit 0.8
off(); // unsubscribe
bus.emit('hit' , 0.9); // nothing: nobody is listening
module 主控台只印出一次 hit 0.8,取消訂閱之後,第二次 emit 沒有任何反應。
相關 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 Params 世界所聽的那些值:一份宣告好的參數清單,每個參數每個畫面都解析成一個數字,除非有手、旋鈕或路由把它蓋掉。 import { Bus } from '@openav/core'; packages/core/src/bus.js 第 2 行
new Loop(onFrame, { maxDt = 0.1 } = {})
畫面的時鐘:每個動畫畫面呼叫你的函式一次,並告訴你距離上一次過了幾秒,分頁被藏起來時它也繼續跳。
new Loop((dt) => { … }).start() 每個 requestAnimationFrame 呼叫你的函式一次,dt 的單位是秒。這是一場演出唯一的時鐘:時間軸往前走、映射的訊號安頓下來、世界繪圖、後台資料送出,都在它裡面做,順序見架構頁 。createShow() 會替你建好並啟動一個,用 show.loop 回傳。
瀏覽器會在分頁被藏起來的時候凍結 requestAnimationFrame,但演出不能因為有人切換視窗就停下來。所以迴圈在跑的時候,會有一個 Web Worker 每 33 毫秒跳一下。只要 50 毫秒內沒有收到動畫畫面,迴圈就自己往前走一步。被藏起來的分頁因此大約每秒 15 個畫面,dt 大小正常,演出時間仍是真實時間。如果頁面禁止 Worker(嚴格的內容安全政策),這個備援就不存在。
loop.fps 是四捨五入後的影格率,大約每半秒更新一次(第一個值只由第一個畫面算出來),在第一個畫面之前是 0。loop.running 告訴你它有沒有在跑。
呼叫之前
要呼叫 start(),在那之前什麼都不會跳。它需要瀏覽器(requestAnimationFrame、performance),在 Node 裡沒有時鐘可以讓它跑。
參數 名稱 型別 說明 onFrameFunction以 onFrame(dt, now) 呼叫:dt 是距離上一個畫面的秒數(不會超過 maxDt),now 是以毫秒計的時間。裡面丟出的錯誤會被接住、記成 [loop],時鐘繼續跑。 maxDtNumber你拿到的 dt 最大值,單位是秒。卡頓(長時間的記憶體回收、被拖動的視窗)會被截成這個值,世界不用處理一秒鐘的跳動。 可省略 預設 0.1
成員 名稱 說明 start()method 啟動時鐘。在它已經在跑的時候呼叫,什麼都不會發生。 stop()method 停掉時鐘,並關掉分頁被藏起來時用的 Worker。可以再 start() 一次。
注意 不要在世界裡讀真實時間,時間請從 dt(或時間軸)來,這樣 30 fps 和 144 fps 的演出才會做一樣的事。 範例
js 複製
import { Loop } from '@openav/core' ;
let elapsed = 0;
const loop = new Loop((dt) => {
elapsed += dt;
document.getElementById('app' ).textContent = elapsed.toFixed(1) + ' s · ' + loop.fps + ' fps' ;
if (elapsed > 3) loop.stop();
});
loop.start();
module 頁面上有一個時鐘一直往上數,三秒後自己停下來。
js 複製
import { Loop } from '@openav/core' ;
const ctx = document.getElementById('c' ).getContext('2d' );
let x = 0;
const loop = new Loop((dt) => {
x = (x + 120 * dt) % 400; // 120 pixels a second, whatever the frame rate
ctx.fillStyle = '#111' ; ctx.fillRect(0, 0, 400, 300);
ctx.fillStyle = '#7ea6ff' ; ctx.fillRect(x, 130, 40, 40);
}, { maxDt: 0.05 });
loop.start();
module 一個方塊以每秒 120 像素的速度橫過畫布,它是按 dt 移動的,所以不管畫面更新率多少,速度都一樣。
相關 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 Timeline 用關鍵影格回答「時間 t 的時候,每個參數是多少?」,並在外面包一層播放控制:播放、跳轉、場景、hold、循環、cue 和錄製。 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 Params 世界所聽的那些值:一份宣告好的參數清單,每個參數每個畫面都解析成一個數字,除非有手、旋鈕或路由把它蓋掉。 import { Loop } from '@openav/core'; packages/core/src/loop.js 第 7 行
new Params(schema = [])
世界所聽的那些值:一份宣告好的參數清單,每個參數每個畫面都解析成一個數字,除非有手、旋鈕或路由把它蓋掉。
參數是世界取得連續控制的方式:{ key: 'bloom', min: 0, max: 1, def: 0.5 }。世界從來不問是誰設定的。每個畫面,每個參數都會解析成一個數字:有覆寫值 (override)就用覆寫值,否則用基準值 (通常是 Timeline 在目前時間的自動化曲線),再不然就用 def。resolve() 做的就是這件事,把 { key: value } 物件交給世界。
覆寫是黏住的。params.override('bloom', 0.8) 之後(滑桿、MIDI 旋鈕或映射的路由做的就是這個),時間軸就不再動 bloom,直到你呼叫 clearOverride('bloom')。這跟舞台上的情形一樣:有人抓住控制的時候,樂譜讓路。覆寫值在寫入的時候就會被夾進 min..max,並對齊 step。
pulse: true 的參數是觸發器,不是水準值:沒有人從它讀值。用 firePulse(key) 觸發它(打擊墊、脈衝訊號的路由、主控台的按鈕),用 onPulse(key, cb) 聽它。
在演出裡,createShow() 會建好一個 Params,用 add() 把每個世界的 schema 登記進去,再用 show.params 回傳。
參數 名稱 型別 說明 schemaArray參數的宣告:{ key, label?, min, max, def, step?, pulse?, group? }。key 一定要有。min、max、def 給出範圍,以及沒人設定時的值。這個陣列是直接拿來用,不會複製。 可省略 預設 []
成員 名稱 說明 add(entries)method 再登記一些參數:add(entries)。已經存在的 key 會被略過,所以先宣告的算數。回傳 Params 本身。 get(key)method 一個參數的宣告({ key, min, max, def, … }),沒有這個 key 就是 undefined。它不是目前的值:目前的值要從 resolve() 讀。 clamp(key, v)method clamp(key, v) 把 v 夾進參數的 min..max,並四捨五入到 step 的倍數。不認識的 key 會原樣回傳 v。override(key, v)method override(key, v) 設定一個黏住的覆寫值,會像 clamp() 那樣夾住。沒宣告過的 key 會被默默忽略。clearOverride(key)method 把一個參數還給時間軸。 clearAllOverrides()method 把所有參數都還給時間軸。 isOverridden(key)method 這個參數目前有沒有被覆寫(主控台會把這些標籤顯示成黃色)。 firePulse(key)method 觸發一個脈衝參數:所有用 onPulse(key, …) 登記的函式都會以 key 為參數執行。 onPulse(key, cb)method onPulse(key, cb) 在該脈衝參數每次觸發時呼叫 cb(key)。回傳一個停止監聽的函式。resolve(base = {})method resolve(base = {}) 回傳每個已宣告參數的 { key: value }:有覆寫值用覆寫值,否則用 base[key](例如 timeline.state()),再不然用 def。脈衝參數沒有 def,所以它的值是 undefined。
注意 step 用一般的浮點數運算對齊:step: 0.1 時,覆寫值 0.3 讀回來是 0.30000000000000004。在時間軸上,有 step 的參數還會停在每個關鍵影格,而不是做內插。resolve() 不會夾基準值,只有覆寫值在寫入時會被夾住。自動化曲線請自己保持在 min..max 之內。世界可以宣告的欄位(label、options、surface、group ……)列在寫一個世界 。 範例
js 複製
import { Params } from '@openav/core' ;
const params = new Params([{ key: 'bloom' , min: 0, max: 1, def: 0.5 }]);
console.log(params.resolve().bloom); // 0.5 (no automation, no override: def)
params.override('bloom' , 0.8); // what a slider or a mapped knob does
console.log(params.resolve({ bloom: 0.1 }).bloom); // 0.8 (the override beats the base)
params.clearOverride('bloom' );
console.log(params.resolve({ bloom: 0.1 }).bloom); // 0.1 (the base is back)
module 主控台印出 0.5、0.8、0.1:先是 def,接著是蓋過時間軸的覆寫值,最後時間軸回來了。
js 複製
import { Params } from '@openav/core' ;
const params = new Params([
{ key: 'mode' , min: 0, max: 3, def: 0, step: 1 },
{ key: 'burst' , pulse: true },
]);
params.override('mode' , 1.6);
console.log(params.resolve().mode); // 2 (clamped and snapped to the step)
params.onPulse('burst' , (key) => console.log('fired' , key));
params.firePulse('burst' ); // fired burst
module 有 step 的參數會對齊(主控台顯示 2),脈衝參數會觸發它的監聽函式(fired burst)。
js 複製
import { Params } from '@openav/core' ;
const params = new Params([{ key: 'bloom' , min: 0, max: 1, def: 0.5 }]);
console.log(params.get ('bloom' )); // the declaration, not a value
params.override('bloom' , 5);
console.log(params.resolve().bloom); // 1 (clamped to max)
params.override('typo' , 0.3); // unknown key: ignored
console.log(params.isOverridden('typo' )); // false
module 幾個陷阱:get() 回傳的是宣告 { key, min, max, def },覆寫值會被夾進範圍(1),不認識的 key 會被默默忽略(false)。
相關 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 Timeline 用關鍵影格回答「時間 t 的時候,每個參數是多少?」,並在外面包一層播放控制:播放、跳轉、場景、hold、循環、cue 和錄製。 Mapper 把訊號接到參數:它手上有一份路由清單(輸入窗、曲線、平滑),每當訊號到來,就寫入對應的參數。 Stage 一次執行一個世界:登記世界和它們的參數、在世界之間切換,並在每個畫面解析參數、呼叫世界的 update 和 render。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 import { Params } from '@openav/core'; packages/core/src/params.js 第 12 行
new Signals()
所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。
new Signals() 會做出一個空的登錄表。做演出的時候通常不用自己建:createShow() 會建好一個,用 show.signals 回傳給你。一個訊號就是一個名字加一個值。輸入套件用 set() 和 pulse() 發布。其他東西用 get() 和 norm() 讀,或用 on() 和 onAny() 訂閱。
訊號分兩種。連續 (continuous,預設)的訊號隨時都有一個目前的值,通常在 0 到 1 之間:旋鈕的位置、房間的音量、手腕的高度。脈衝 (pulse,寫成 kind: 'pulse')是事件:它的值是最近一次觸發帶的內容,在第一次觸發之前是 null:按下一個鍵、聽到一下大鼓、開始一次捏合。水準值想讀就讀。事件用 on() 訂閱。
名字是「來源在前」的路徑:midi/cc/74、audio/rms、pose/hand/right/y、breath/pressure。發布之前先用範圍宣告它:signals.define('breath/pressure', { min: 0, max: 1 })。對一個沒人宣告過的名字呼叫 set(),會當場把它宣告成 0 到 1 的連續訊號,所以你的數字如果是角度、MIDI 音符號碼或別的範圍,請先宣告。
旋鈕、舉起的手、一個和弦,在這裡地位相同。讀訊號的一方(Mapper 、聽事件的世界、後台監看)不知道也不在乎是什麼產生它。
成員 名稱 說明 define(name, meta = {})method 宣告一個訊號和它的範圍:define(name, { kind, min, max, unit, description, source })。可以重複呼叫:後面的宣告會合併進前面的。連續訊號一開始是 min,脈衝一開始是 null。回傳這個登錄表本身。 set(name, value)method 發布一個值:set(name, value)。每次呼叫訂閱者都會執行,就算值沒變。沒宣告過的名字會當場宣告成 0..1 的連續訊號。 pulse(name, payload = 1)method 觸發一個事件:pulse(name, payload = 1)。它就是換個名字的 set()。請先用 kind: 'pulse' 宣告,否則它會被登記成連續訊號。 get(name)method 訊號最近的值,沒宣告也沒設定過的名字是 undefined。還沒觸發過的脈衝是 null。 norm(name)method 把值換算成宣告的 min 到 max 之間的 0..1,超出範圍會被夾住。不認識的名字,或 min 等於 max 的訊號,回傳 0。 on(name, cb)method 訂閱單一訊號:on(name, cb),每次 set() 或 pulse() 都會呼叫 cb(value, name)。回傳一個取消訂閱的函式。 onAny(cb)method 訂閱所有訊號:每一個訊號都會呼叫 cb(name, value, meta)。映射的 learn 模式和後台監看都靠它。回傳一個取消訂閱的函式。 list()method 所有訊號組成的陣列 [{ name, value, at, kind, min, max, unit, description, source }]。at 是最近一次 set() 的時間(performance.now(),從沒設定過就是 0)。監看和音量表讀的就是這個。
注意 signals.meta 是「名字 → 宣告」的 Map,signals.values 是「名字 → 最近的值」的 Map。需要的話可以讀,要寫請透過 define() 和 set(),訂閱者才聽得到。訂閱者丟出錯誤會被接住、記成 [signals] <名字>,其他訂閱者照常執行,set() 也不會丟錯。 每個內建套件發布的訊號名稱,都列在訊號參考 。 範例
js 複製
import { Signals } from '@openav/core' ;
const signals = new Signals();
signals.define('breath/pressure' , { min: 0, max: 1 });
signals.on('breath/pressure' , (v) => console.log('pressure' , v));
signals.set ('breath/pressure' , 0.4); // logs: pressure 0.4
module 打開主控台:會印出 pressure 0.4,因為值一設定,訂閱者就跟著執行。
js 複製
import { Signals } from '@openav/core' ;
const signals = new Signals();
signals.define('temp/room' , { min: 10, max: 30, unit: '°C' });
signals.set ('temp/room' , 25);
console.log(signals.get ('temp/room' ), signals.norm('temp/room' )); // 25 0.75
signals.define('breath/sigh' , { kind: 'pulse' });
signals.on('breath/sigh' , (payload) => console.log('sigh' , payload));
signals.pulse('breath/sigh' , { depth: 0.8 }); // sigh { depth: 0.8 }
module 一個水準值和一個事件並排:主控台先印出 25 0.75,再印出 sigh { depth: 0.8 }。
js 複製
import { Signals } from '@openav/core' ;
const signals = new Signals();
signals.set ('wind/speed' , 12); // never declared, so it is 0..1
console.log(signals.norm('wind/speed' )); // 1 (12 is far past the range)
signals.define('wind/speed' , { min: 0, max: 20 });
console.log(signals.norm('wind/speed' )); // 0.6
module 陷阱:沒宣告的訊號範圍是 0..1,所以 12 會讀成 1。把範圍宣告好,同一個值就讀成 0.6(主控台先印 1,再印 0.6)。
相關 Params 世界所聽的那些值:一份宣告好的參數清單,每個參數每個畫面都解析成一個數字,除非有手、旋鈕或路由把它蓋掉。 Loop 畫面的時鐘:每個動畫畫面呼叫你的函式一次,並告訴你距離上一次過了幾秒,分頁被藏起來時它也繼續跳。 Mapper 把訊號接到參數:它手上有一份路由清單(輸入窗、曲線、平滑),每當訊號到來,就寫入對應的參數。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 Bus 最陽春的事件發射器:on(event, cb)、emit(event, payload)、off(event, cb)。沒有萬用字元,也不是非同步。 ChordDetector 把幾乎同時按下的音歸成一個手勢並分析它:和弦類型、協和度、三和弦與音叢的旗標,發布成 chord/* 訊號。 import { Signals } from '@openav/core'; packages/core/src/signals.js 第 19 行
@openav/show
createShow()#
函式 @openav/show async createShow({ world = null , worlds = [], timeline: timelineCfg = { total: 120 }, routes = [], modules = {}, artwork = null , hint = '' , profile = null , map = null , onFrame = null , mount = null , telemetry = true , score: scoreCfg = null , } = {})
一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。
每個範例都是一個世界加上一次 createShow() 呼叫。它建好 Signals 登錄表、Params 、放著你的世界的 Stage 、Timeline 、Mapper (帶著你的 routes)、你在 modules 宣告的輸入與輸出、導演控台(mountConsole() )、後台資料(MonitorFeed )和影格 Loop ,啟動迴圈,再把每個部分交給你。什麼都沒有藏起來:show.signals、show.params、show.timeline、show.mapper、show.stage 這些,就是影格迴圈實際在跑的那些物件。框架多了一個模組的時候,每場演出只要宣告就能用上。
modules 的鍵:midi(預設開啟,false 關掉,{ controllers: true } 或 { controllers: { profile, profiles, open } } 會在舞台上停靠一個螢幕上的 MIDI 控制器)、keys(螢幕鋼琴、電腦鍵盤和模擬演奏者:預設開啟,false 關掉,{ base, octaves, sim, capture } 可調整)、drums(true 或 { engine, autoEnableEngine })、audio(任何真值:麥克風分析器,從 🎤 按鈕啟動)、chord(true 或 { window }:接上每個被彈奏的音的 ChordDetector )、hands(true:HandTracker )、pose(true:PoseTracker )、leap(true 或 { url, simulate, fingers, box }:LeapInput ,網址上的 ?leap=sim 或 ?leap=ws://… 可以覆寫)、remote(true 或 { room, … }:把手機當控制器)和 sound(true、像 'piano' 這樣的樂器 id,或 { instrument, remember, picker, engine }:頁面內的樂器,宣告了才會載入)。需要下載東西的部分都是延遲載入,所以沒宣告的演出不會付出任何代價。
頁面外殼只固定一次,所以每場演出都一樣。沒有 mount 的話,頁面的 body 會變成兩欄的格線:舞台,以及放著鋼琴和控台的 340 像素側邊面板(直立的手機則是舞台在上、面板在下:見 isStacked() )。每個畫面依這個順序做事:
text
keys, drums, audio update on-screen performers and the microphone
onFrame(dt, show) your hook
timeline.advance(dt) time moves
director.update(...) with a score: segment modules enter, update, exit
mapper.update(dt) smoothed routes settle
stage.frame(dt, state) params resolve, world updates and renders
sound, remote sound params reach the synth, values echo to phones
console.render(state) the desk reflects reality
monitor.frame(snapshot) the backstage hears about it
呼叫之前
要 await 它:它是非同步的,因為要等世界的 init()(和延遲載入的模組)完成。它需要一個頁面(document、location、localStorage),還要有能解析它所匯入的每個 @openav/* 套件的 import map 或打包工具(可以複製 examples/01-hello-particles/index.html 裡的那份)。瀏覽器要等點擊之後才會啟動聲音,所以聲音和麥克風要從控台的按鈕啟動。
參數 名稱 型別 說明 worldObject | null要表演的世界:{ name, params, init, update, render, dispose }(見 Stage )。它會最先被啟用,createShow 會等它的 init() 完成。 可省略 預設 null worldsArrayworld 之外要登記的更多世界。它們的參數會全部先登記好。用 show.stage.activate(name) 切換。 可省略 預設 [] timelineObjectTimeline 的選項:{ total, automation, scenes, start, loop, onCue }。有 score 的時候,以它的場景和總長為準。 可省略 預設 { total: 120 } routesArray給 Mapper 的路由。如果有存起來的 profile 就會載入,其中沒有的 source → target 組合,仍然會把你宣告的路由補進去。 可省略 預設 [] modulesObject要開啟的輸入與輸出,用鍵名指定(見說明):midi、keys、drums、audio、chord、hands、pose、leap、remote、sound。 可省略 預設 {} artworkObject | null{ title, artist, year, note }:舞台右下角的一行作品署名,以及 <head> 裡的 <meta name="artwork"> 標籤。 可省略 預設 null hintString舞台左下角的一行 HTML ,例如 'play the piano · Space plays the timeline'。它是當成 HTML 插入的,所以千萬別放來路不明的文字。 可省略 預設 '' profileString | false | null映射在 localStorage 儲存路由時用的名字(預設是第一個世界的名字,再不然是 'show')。false 會在演出開始時忽略存起來的路由,所以每次載入路由都來自你的程式。 可省略 預設 null mapString | false | null開始時要載入的映射檔:同源或 https 的網址(見 loadMapUrl() )。沒寫的話,使用頁面網址上的 ?map=<url>。false 會忽略 ?map=。 可省略 預設 null onFrameFunction | null每個畫面以 onFrame(dt, show) 呼叫,在螢幕上的演奏者和麥克風更新之後、時間軸前進之前 。適合放組裝層級的邏輯,例如包絡,或 osc.flush()。 可省略 預設 null mountObject | null{ stage, side }:要渲染進去的元素或選擇器,取代自動產生的全頁面版面。 可省略 預設 null telemetryBoolean是否允許每次載入頁面送出一次匿名的 oav_show_start。false 什麼都不送。 可省略 預設 true scoreObject | null{ cuts, cut, modules, api, onStatus, onCue, maxStep, midi }:以段落長度寫成的演出結構,由段落模組表演(見 Score 和 Director )。頁面網址上的 ?cut=<id> 可以選版本。 可省略 預設 null
回傳 Promise<Object> 演出物件:{ signals, params, stage, timeline, mapper, midi, controllers, midiPanel, keys, drums, sound, audio, hands, pose, leap, chord, remote, score, director, scoreMidi, console, app, loop }。沒有要求的部分是 null。有 score 的時候還有 show.show,也就是排練用的控制。同一個物件也是 window.openav。
注意 映射每 3 秒把路由存在 profile 名稱底下,所以學來的或改過的路由,重新載入之後還在,而且會贏過你在程式裡改過的宣告 (同一個 source → target 組合會保留存起來的曲線和平滑)。profile: false 讓路由每次載入都來自程式,但映射之後仍然會用世界的名字存檔。要從頭開始,執行 localStorage.removeItem('openav.map.<世界名稱>')。 路由加進去的時間比聲音引擎登記 sound/* 參數還早,所以宣告接到 sound/cutoff 的路由要明確寫上 outMin: 100, outMax: 8000。 show.params.resolve() 不給參數的話,回傳的是每個參數的 def。想讀世界這個畫面看到的值,要傳入時間軸的基準狀態:show.params.resolve(show.timeline.state())。除非 telemetry: false,每次載入頁面會有一次匿名的 oav_show_start 送到 Google Analytics。依程式碼,在 localhost、區域網路位址、file:// 和 openaudiovisual.com 本身都不會送,也尊重 Do Not Track 和 Global Privacy Control。彈了什麼、頁面路徑、cookie 和固定的識別碼都不會送。 有 score 的時候,window.openav.show 有 table()、goto('dusk')、goto('dusk.glow')、goto(2)、seek(T)、cue(name)、status()、next()、prev() 和 cuts,可以在開發者工具的主控台排練。 範例
js 複製
import { createShow } from '@openav/show' ;
import { createCanvas } from '@openav/stage' ;
const world = {
name: 'dot' ,
params: [{ key: 'size' , label: 'Size' , min: 0, max: 1, def: 0.3 }],
init({ container }) { this .view = createCanvas(container); },
update(dt, state) { this .size = state.size; },
render() {
const { ctx } = this .view, { w, h } = this .view.fit();
ctx.fillStyle = '#000' ; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#7ea6ff' ; ctx.beginPath(); ctx.arc(w / 2, h / 2, this .size * h / 2, 0, Math.PI * 2); ctx.fill();
},
dispose() { this .view.dispose(); },
};
const show = await createShow({
world,
timeline: { total: 60, automation: { size: [[0, 0.1], [60, 0.9]] } },
modules: { midi: false , keys: false }, // nothing to plug in
telemetry: false ,
});
show.timeline.play(); // the dot grows over a minute
console.log(Object.keys(show).length, show.timeline.playing);
module 舞台上的藍色圓點隨時間軸播放慢慢變大,右邊是控台(播放控制、圖層、size 推桿)。主控台印出 23 true:二十三個部分,時間軸正在播放。
js 複製
import { createShow } from '@openav/show' ;
const world = {
name: 'glow' ,
params: [{ key: 'size' , min: 0, max: 1, def: 0.3 }, { key: 'burst' , pulse: true }],
init() {},
update(dt, state) { this .last = state; },
};
const show = await createShow({
world,
routes: [{ source: 'midi/cc/1' , target: 'size' }, { source: 'pad/1' , target: 'burst' }],
modules: { midi: false , keys: false },
profile: false , // routes come from this code on every load
telemetry: false ,
});
show.params.onPulse('burst' , () => console.log('burst!' ));
show.signals.set ('midi/cc/1' , 0.9); // what a knob would do
console.log(show.params.isOverridden('size' ), show.params.resolve(show.timeline.state()).size); // true 0.9
show.signals.set ('pad/1' , 0);
show.signals.set ('pad/1' , 1); // a continuous source rising past 0.5 fires a pulse param
setTimeout(() => console.log('the world heard' , world.last.size), 200);
module 一個訊號透過路由驅動一個參數:主控台印出 true 0.9,接著是墊子越過 0.5 時的 burst!,最後是 the world heard 0.9。
js 複製
import { createShow } from '@openav/show' ;
const world = { name: 'sky' , params: [{ key: 'sun' , min: 0, max: 1, def: 0 }], init() {}, update() {} };
const dusk = {
id: 'dusk' ,
enter() { console.log('dusk enters' ); },
param: (key, value, ctx) => (key === 'sun' ? 1 - ctx.p : value), // the segment bends the sun param
};
const show = await createShow({
world,
modules: { midi: false , keys: false },
telemetry: false ,
profile: false ,
score: { cuts: [{ id: 'full' , segments: [{ id: 'dawn' , dur: 10 }, { id: 'dusk' , dur: 10 }] }], modules: [dusk] },
});
console.log(show.score.total, show.timeline.total); // 20 20: the timeline takes its length from the score
show.show.goto('dusk' ); // rehearsal control: jump to a segment
setTimeout(() => console.log(show.show.status().segment.id, show.timeline.t), 300);
module 有總譜的時候,時間軸的長度由它決定:主控台印出 20 20,跳轉之後印出 dusk enters,最後是 dusk 10。
js 複製
import { createShow } from '@openav/show' ;
const show = await createShow({
world, // your world
timeline: { total: 120, automation, scenes },
routes: [{ source: 'audio/rms' , target: 'energy' , inMax: 0.3, smooth: 0.1 }],
modules: { keys: { base: 48 }, chord: true , audio: true , sound: 'piano' },
artwork: { title: 'Untitled' , artist: 'You' , year: 2026 },
hint: 'play the piano · Space plays the timeline' ,
});
show.signals.on('chord/event' , (a) => console.log(a.chordType));
static 完整的一套:螢幕鋼琴、和弦分析、麥克風,以及在控台上按一下聲音按鈕之後就會發聲的平台鋼琴。彈一個和弦,主控台就印出它的類型(major)。
待確認
需要硬體、權限或下載的模組(麥克風、鏡頭、Leap、手機、Tone.js 聲音引擎、MIDI 裝置)沒有在這裡跑過,說明是根據程式碼和套件文件寫的。範例把 midi 和 keys 關掉,這樣在哪裡都能跑。
相關 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 Params 世界所聽的那些值:一份宣告好的參數清單,每個參數每個畫面都解析成一個數字,除非有手、旋鈕或路由把它蓋掉。 Loop 畫面的時鐘:每個動畫畫面呼叫你的函式一次,並告訴你距離上一次過了幾秒,分頁被藏起來時它也繼續跳。 Stage 一次執行一個世界:登記世界和它們的參數、在世界之間切換,並在每個畫面解析參數、呼叫世界的 update 和 render。 Timeline 用關鍵影格回答「時間 t 的時候,每個參數是多少?」,並在外面包一層播放控制:播放、跳轉、場景、hold、循環、cue 和錄製。 Mapper 把訊號接到參數:它手上有一份路由清單(輸入窗、曲線、平滑),每當訊號到來,就寫入對應的參數。 mountConsole() 把導演控台掛進一個元素:播放控制與 scrubber、圖層總覽、輸入、映射、參數推桿、訊號音量表,以及全螢幕的演出模式。 MonitorFeed 把正在進行的演出的快照透過 WebSocket 串流到後台中繼站,每秒最多 hz 次,沒有中繼站的時候安靜地持續嘗試。 Score 把一場演出寫成「每一段有多長」,而不是絕對秒數:每個起點、終點、cue 和倒數,以及每一個版本(cut)的時間,都由它算出來。 Director 執行總譜的段落模組:每個模組進場一次、每個畫面更新、退場一次,觸發 cue,而且某個模組當掉時只關掉它,不會讓演出停下來。 ChordDetector 把幾乎同時按下的音歸成一個手勢並分析它:和弦類型、協和度、三和弦與音叢的旗標,發布成 chord/* 訊號。 LeapInput 從本機的橋接程式讀取 Leap Motion 的手,發布成 leap/* 訊號,並在 leap.hands 提供關節資料,讓你把手本身畫出來。 isStacked() 這個大小的視窗會不會用堆疊版面(舞台在上、面板在下):直立的、而且寬度不超過 820 像素。 import { createShow } from '@openav/show'; packages/show/index.js 第 44 行
isStacked()#
函式 @openav/show isStacked(width, height)
這個大小的視窗會不會用堆疊版面(舞台在上、面板在下):直立的、而且寬度不超過 820 像素。
createShow() 的外殼只有一條版面規則:寬的視窗是舞台和 340 像素的側邊面板並排,直立的手機或小平板則是舞台在上、面板在下並可以捲動。規則就是媒體查詢 STACK_QUERY ,而 isStacked() 是同一個判斷的純函式版本,所以測試(或你自己的版面程式)不用瀏覽器也能用。橫放的手機高度不會比寬度大,所以仍然是寬版面。
參數 名稱 型別 說明 widthNumber視窗寬度,單位是像素。 heightNumber視窗高度,單位是像素。
回傳 Boolean width <= 820 而且 height > width 的時候是 true。
範例
js 複製
import { isStacked, stageHeight } from '@openav/show/layout' ;
console.log(isStacked(390, 844)); // true: a phone held upright
console.log(isStacked(844, 390)); // false: the same phone on its side
console.log(isStacked(820, 1180)); // true: a small tablet held upright
console.log(isStacked(1024, 1366)); // false: wider than 820, even though it is upright
console.log(stageHeight(844)); // 490: the stage keeps 58% of the window height
module 主控台印出 true、false、true、false,然後是 490。
import { isStacked } from '@openav/show/layout'; packages/show/layout.js 第 16 行
SHELL_CSS#
常數 @openav/show SHELL_CSS = "\n body { margin: 0; background: #000; height: 100vh; display: grid;\n grid…"
createShow() 放進頁面的 CSS 文字:兩欄格線、340 像素的側邊面板、提示與署名的樣式,以及堆疊版面。
一個字串。createShow() 會把它用 <style id="openav-shell-css"> 加進頁面一次(除非已經有同 id 的)。它把頁面 body 設成舞台加上 340 像素側邊面板的格線,用到 .oav-stage、.oav-side、.oav-keys-slot、.oav-desk、.oav-hint 和 .oav-credit 這些 class,以及來自 STACK_QUERY 的堆疊版面。想知道外殼對你的頁面做了什麼就讀它。或在呼叫 createShow() 之前,自己先放一個 <style id="openav-shell-css">,就能整個取代它。
範例
js 複製
import { SHELL_CSS } from '@openav/show/layout' ;
console.log(SHELL_CSS.includes('.oav-stage' ), SHELL_CSS.includes('340px' ), SHELL_CSS.length > 500); // true true true
console.log(SHELL_CSS.split('\n' ).slice(1, 4).join('\n' )); // the first rules: body is a grid
module 主控台印出 true true true,接著是樣式表最前面的幾行(body 的格線)。
相關 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 STACK_QUERY 堆疊版面背後的媒體查詢:(max-width: 820px) and (orientation: portrait)。 isStacked() 這個大小的視窗會不會用堆疊版面(舞台在上、面板在下):直立的、而且寬度不超過 820 像素。 import { SHELL_CSS } from '@openav/show/layout'; packages/show/layout.js 第 25 行
STACK_MAX_WIDTH#
常數 @openav/show STACK_MAX_WIDTH = 820
能用堆疊版面的最寬視窗:820 像素(直立的小平板)。
用在 STACK_QUERY 和 isStacked() 裡面,所以 CSS 規則和函式不會彼此走樣。超過 820 像素,舞台和 340 像素的面板一律是並排的。
相關 STACK_QUERY 堆疊版面背後的媒體查詢:(max-width: 820px) and (orientation: portrait)。 isStacked() 這個大小的視窗會不會用堆疊版面(舞台在上、面板在下):直立的、而且寬度不超過 820 像素。 STAGE_SHARE 版面堆疊的時候,舞台保有視窗高度的多少:58(百分比)。 import { STACK_MAX_WIDTH } from '@openav/show/layout'; packages/show/layout.js 第 10 行
STACK_QUERY#
常數 @openav/show STACK_QUERY = "(max-width: 820px) and (orientation: portrait)"
堆疊版面背後的媒體查詢:(max-width: 820px) and (orientation: portrait)。
這是一個字串,可以交給 matchMedia(),問瀏覽器跟外殼的 CSS 同一個問題:matchMedia(STACK_QUERY).matches。isStacked(width, height) 是用數字做的同一個判斷。
範例
js 複製
import { STACK_QUERY } from '@openav/show/layout' ;
const query = matchMedia(STACK_QUERY);
console.log(STACK_QUERY, query.matches); // the window this runs in is not an upright phone: false
query.addEventListener('change' , (e) => console.log('stacked now?' , e.matches));
module 主控台印出這個查詢,以及目前的視窗符不符合(在桌機上通常是 false)。最後一行在視窗大小跨過這條規則的時候會再印一次。
import { STACK_QUERY } from '@openav/show/layout'; packages/show/layout.js 第 11 行
STAGE_SHARE#
常數 @openav/show STAGE_SHARE = 58
版面堆疊的時候,舞台保有視窗高度的多少:58(百分比)。
在手機上,舞台必須仍然是主角,所以它保有 58% 的高度,下方的控台可以捲動。stageHeight() 把這個數字換算成像素。
相關 stageHeight() 版面堆疊的時候,這個高度的視窗裡舞台的高度(像素):它的 58%,四捨五入。 isStacked() 這個大小的視窗會不會用堆疊版面(舞台在上、面板在下):直立的、而且寬度不超過 820 像素。 STACK_QUERY 堆疊版面背後的媒體查詢:(max-width: 820px) and (orientation: portrait)。 import { STAGE_SHARE } from '@openav/show/layout'; packages/show/layout.js 第 13 行
stageHeight()#
函式 @openav/show stageHeight(height)
版面堆疊的時候,這個高度的視窗裡舞台的高度(像素):它的 58%,四捨五入。
外殼堆疊的時候,舞台必須仍然是主角,所以它佔視窗高度的 STAGE_SHARE 百分比(CSS 用 58vh,瀏覽器支援的話用 58dvh),面板拿剩下的。這是同一個數字的函式版本,給測試和你自己的版面程式用。
參數 名稱 型別 說明 heightNumber視窗高度,單位是像素。
回傳 Number 一個數字:Math.round(height * 58 / 100)。
範例
js 複製
import { stageHeight } from '@openav/show/layout' ;
for (const height of [667, 844, 1180]) console.log(height, stageHeight(height)); // 667 387, 844 490, 1180 684
module 主控台印出 667 387、844 490 和 1180 684。
相關 isStacked() 這個大小的視窗會不會用堆疊版面(舞台在上、面板在下):直立的、而且寬度不超過 820 像素。 STAGE_SHARE 版面堆疊的時候,舞台保有視窗高度的多少:58(百分比)。 STACK_QUERY 堆疊版面背後的媒體查詢:(max-width: 820px) and (orientation: portrait)。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 import { stageHeight } from '@openav/show/layout'; packages/show/layout.js 第 21 行
@openav/stage
createCanvas()#
函式 @openav/stage createCanvas(container, { alpha = false } = {})
做出一個填滿容器、並隨容器大小調整的 2D 畫布,解析度最高到裝置像素比的 2 倍。
給用 2D 畫布 API 繪圖的世界的小幫手。世界可以用,也可以不用。畫布是 display: block,跟容器一樣大。fit() 把它的像素大小設成容器大小乘以裝置像素比(最多 2),並縮放 context,讓你繼續用 CSS 像素繪圖。在 render() 開頭呼叫 fit():只有大小改變時它才會做事,而且會回傳畫圖要用的 { w, h }。容器改變大小時,ResizeObserver 也會呼叫它。
在世界的 dispose() 裡呼叫 dispose():它會停掉觀察器並移除畫布。
參數 名稱 型別 說明 containerHTMLElement要放畫布的元素(通常是世界的 container)。 alphaBoolean畫布可不可以是透明的。預設 false 比較快,畫布是不透明的。 可省略 預設 false
回傳 Object { canvas, ctx, fit, dispose }:<canvas>、它的 2D context、fit() 和 dispose()。fit() 回傳 { w, h },也就是容器以 CSS 像素計的大小。
注意 如果容器沒有高度(一般排版流程裡的空 <div> 高度是 0 像素),畫布就是 0 × 0,什麼都看不到。請給容器一個大小,或是用在 Stage 裡,它的圖層會填滿舞台。 範例
js 複製
import { createCanvas } from '@openav/stage' ;
const container = document.getElementById('app' );
container.style.cssText = 'width: 100%; height: 200px; background: #111;' ;
const view = createCanvas(container);
function render(time) {
const { w, h } = view.fit(); // the size in CSS pixels; cheap when nothing changed
view.ctx.clearRect(0, 0, w, h);
view.ctx.fillStyle = '#7ea6ff' ;
view.ctx.fillRect(w / 2 - 30 + Math.sin(time / 500) * (w / 3), h / 2 - 30, 60, 60);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
module 一個藍色方塊在填滿頁面 200 像素高橫條的畫布上左右滑動,視窗大小改變時依然清晰。
相關 Stage 一次執行一個世界:登記世界和它們的參數、在世界之間切換,並在每個畫面解析參數、呼叫世界的 update 和 render。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 import { createCanvas } from '@openav/stage'; packages/stage/index.js 第 75 行
new Stage({ container, params, signals, io = {} })
一次執行一個世界:登記世界和它們的參數、在世界之間切換,並在每個畫面解析參數、呼叫世界的 update 和 render。
世界 就是任何符合這個形狀的系統,每個方法都是選填的:
js
{
name: 'garden' , // unique: the stage registers worlds by name
params: [{ key: 'growth' , min: 0, max: 1, def: 0.5 }], // what it can be performed with
init({ container, signals, params }) {}, // when it becomes active (may be async)
update(dt, state, io) {}, // every frame: state = the resolved params
render() {}, // every frame, right after update
dispose() {}, // when another world is activated
}舞台負責生命週期和參數的彙整,而刻意不 負責繪圖:用 2D 畫布、p5、three.js、SVG、DOM,隨你高興。外殼永遠不知道作品是什麼:換作品就是換掉世界和它的資料,外殼不動。
世界從 state(參數)取得連續控制,可以訂閱 signals 來接離散事件(按下一個音、一個和弦解決)。如果它在 update() 裡讀 signals.get('midi/cc/74'),今天用你的控制器是行的,但時間軸、手或別的控制器就再也驅動不了它。在演出裡,createShow() 依 world 和 worlds 選項建好舞台,用 show.stage 回傳。
呼叫之前
先登記你的世界,再 await stage.activate(name) 一次。在有世界啟用之前,frame() 會回傳 null,什麼都不做。之後每個畫面呼叫 stage.frame(dt, baseState)(演出的迴圈會做)。
參數 名稱 型別 說明 containerHTMLElement世界要畫進去的元素。如果它原本是 static,會被改成 position: relative。舞台會在裡面加一個自己的圖層,所以你放在容器裡的覆蓋物(提示、開始按鈕)在切換世界的時候不會被清掉。 可省略 paramsParams世界的 schema 要併進去、也是 frame() 要解析的 Params 。 可省略 signalsSignals交給每個世界的 Signals 登錄表,讓世界可以訂閱事件。 可省略 ioObject給世界的 update(dt, state, io) 用的額外輸出把手,例如 { midi, osc }。signals 一定在裡面。 可省略 預設 {}
成員 名稱 說明 register(world)method 依 name 登記一個世界,並把它的 params 加進共用的 Params (已經存在的 key 保留最先的宣告)。回傳舞台本身。 activate(name)method 切換到這個名字的世界。它是非同步的:先讓前一個世界 dispose(它的 dispose() 出錯只會記錄,不會丟出),清空舞台圖層,再等新世界的 init({ container, signals, params }) 完成。那個世界已經啟用的話就什麼都不做。名字從沒登記過,則以 "unknown world" 被拒絕(reject)。 frame(dt, baseState)method 每個畫面:frame(dt, baseState) 先做 params.resolve(baseState)(有覆寫用覆寫,否則用基準值,再不然用 def),呼叫目前世界的 update(dt, state, io),接著呼叫 render(),並回傳解析後的狀態。沒有世界啟用就回傳 null。兩個呼叫裡任何一個丟出例外,都會被接住、記成 [stage] update: 或 [stage] render:,演出繼續進行。
注意 啟用之前請先確認名字:activate('nope') 會先 讓目前的世界 dispose,之後才被拒絕,而舞台仍然指著那個已經 dispose 的世界(active 和 activeName 沒有改變),所以下一次 frame() 還是會呼叫它的 update() 和 render()。 可以讀的欄位:active(目前啟用的世界物件)、activeName、worlds(名字對應世界的 Map)、container、layer(世界被放進去的元素),以及 io({ signals, ...io },也就是 update 收到的東西,之後可以用 stage.io.osc = osc 補上把手)。 init() 裡的例外不會被接住:activate() 會被拒絕。update() 和 render() 裡的例外會被接住。世界必須在 dispose() 裡釋放它拿走的東西(GPU 物件、DOM、訂閱),因為世界是可以現場切換的。範例
js 複製
import { Signals, Params } from '@openav/core' ;
import { Stage } from '@openav/stage' ;
const container = document.getElementById('app' );
container.style.cssText = 'width: 400px; height: 120px;' ; // give the stage some room
const stage = new Stage({ container, params: new Params(), signals: new Signals() });
stage.register({
name: 'label' ,
params: [{ key: 'size' , min: 10, max: 60, def: 20 }],
init({ container }) { this .el = document.createElement('div' ); container.appendChild(this .el); },
update(dt, state) { this .size = state.size; },
render() { this .el.style.cssText = 'color: #7ea6ff; font: ' + this .size + 'px monospace' ; this .el.textContent = 'size ' + this .size; },
dispose() { this .el.remove(); },
});
await stage.activate('label' ); // init runs now
console.log(stage.frame(0.016, { size: 40 })); // { size: 40 }: the base state, since nothing overrides it
console.log(stage.frame(0.016, {})); // { size: 20 }: no base either, so def
module 主控台印出 { size: 40 },然後是 { size: 20 },頁面上用藍色顯示文字 size 20:世界依解析後的參數畫出了自己。
js 複製
import { Signals, Params } from '@openav/core' ;
import { Stage } from '@openav/stage' ;
const signals = new Signals();
const stage = new Stage({ container: document.getElementById('app' ), params: new Params(), signals });
const world = (name) => ({
name, params: [{ key: 'calm' , min: 0, max: 1, def: 0.5 }],
init() { console.log(name, 'init' ); },
dispose() { console.log(name, 'dispose' ); },
});
stage.register(world('calm' )).register(world('storm' ));
await stage.activate('calm' ); // calm init
await stage.activate('storm' ); // calm dispose, then storm init
await stage.activate('storm' ); // already active: nothing happens
console.log(stage.activeName, stage.params.schema.length); // storm 1: both worlds share the one 'calm' param
await stage.activate('nope' ).catch ((error) => console.log(error.message)); // unknown world: storm is disposed first, then this rejects
module 主控台印出 calm init、calm dispose、storm init,然後是 storm 1(共用的參數只登記一次),接著是 storm dispose 和未知世界的錯誤訊息:失敗的切換已經先讓正在跑的世界 dispose 了。
相關 createCanvas() 做出一個填滿容器、並隨容器大小調整的 2D 畫布,解析度最高到裝置像素比的 2 倍。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 Params 世界所聽的那些值:一份宣告好的參數清單,每個參數每個畫面都解析成一個數字,除非有手、旋鈕或路由把它蓋掉。 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 webtoeWorld() 把一份 WebToe 節點圖變成世界:節點圖在 iframe 裡執行,每個改變的參數都會被送進去。 Timeline 用關鍵影格回答「時間 t 的時候,每個參數是多少?」,並在外面包一層播放控制:播放、跳轉、場景、hold、循環、cue 和錄製。 import { Stage } from '@openav/stage'; packages/stage/index.js 第 21 行
@openav/world-webtoe
webtoeWorld()#
函式 @openav/world-webtoe webtoeWorld({ name = 'webtoe' , app = 'https://webtoe.openaudiovisual.com/' , project = null , params = [], extra = null , } = {})
把一份 WebToe 節點圖變成世界:節點圖在 iframe 裡執行,每個改變的參數都會被送進去。
WebToe 是姊妹專案:一個網頁原生、以節點為基礎的資料流引擎,講的是 TouchDesigner 的語言。webtoeWorld() 把它的應用程式嵌進一個填滿舞台的 iframe(帶著 allow="camera; microphone"),每個畫面把解析後的參數用 { type: 'webtoe:ext', values } 送進 iframe。節點圖裡,任何寫成 ext('name', fallback) 的參數,都會跟著名為 name 的值走,在值抵達之前用 fallback。
約定維持純粹:世界讀的是參數,從不讀輸入。是哪個旋鈕、手或時間軸在驅動 speed,是映射的事,跟任何世界一樣。為了少送一點,世界只在至少有一個值跟上一次送出的不同時才會送。
呼叫之前
頁面必須能把 WebToe 應用程式載進 iframe(要有網路,預設是 https://webtoe.openaudiovisual.com/),節點圖檔案也要讓應用程式抓得到。世界在被啟用的時候才開始做事,所以請搭配 createShow({ world }),或 stage.register() 加 stage.activate() 使用。
參數 名稱 型別 說明 nameString世界的名字:Stage 用它來登記,stage.activate() 也是傳它。每份節點圖請取自己的名字。 可省略 預設 'webtoe' appString要嵌進 iframe 的 WebToe 應用程式。改它就能跑你自己那一份。 可省略 預設 'https://webtoe.openaudiovisual.com/' projectString | null.webtoe.json 節點圖檔案的網址。它會用 ?project=<網址> 交給應用程式,由應用程式去抓,所以檔案所在的伺服器必須允許應用程式的來源(CORS)。請寫成絕對網址,例如 new URL('./garden.webtoe.json', location.href).href。 可省略 預設 null paramsArray世界的參數 schema:[{ key, min, max, def }, …]。這些 key 就是節點圖用 ext('name', fallback) 讀取的名稱。 可省略 預設 [] extraFunction | nullextra(state, out),每個畫面呼叫:把衍生的值加進 out(狀態的複本),節點圖也讀得到,例如 out.hueRad = state.hue * Math.PI / 180。 可省略 預設 null
回傳 Object 一個世界 { name, params, init, update, render, dispose },可以交給 createShow({ world }) 或 stage.register()。
注意 訊息是用目標來源 * 送給 iframe,所以 iframe 當時顯示的任何頁面都收得到這些值。如果你把 app 指向你不信任的網站,它就能讀到你的參數。 範例
js 複製
import { webtoeWorld } from '@openav/world-webtoe' ;
// a stand-in for the WebToe app, so this needs no network: it just shows what it receives
const patch = '<body style="margin:0;background:#111;color:#7ea6ff;font:14px monospace"></body>'
+ '<script>onmessage = (e) => { document.body.textContent = JSON.stringify(e.data); };<\/script>' ;
const world = webtoeWorld({
app: 'data:text/html,' + encodeURIComponent(patch),
params: [{ key: 'speed' , min: 0, max: 1, def: 0.5 }, { key: 'hue' , min: 0, max: 360, def: 205 }],
extra: (state, out) => { out.hueRad = Math.round(state.hue * Math.PI / 180 * 100) / 100; },
});
const container = document.getElementById('app' );
container.style.cssText = 'position: relative; width: 400px; height: 120px;' ;
world.init({ container });
container.querySelector('iframe' ).onload = () => world.update(0.016, { speed: 0.8, hue: 180 });
module iframe 填滿容器,載入完成之後顯示世界送出的訊息:{"type":"webtoe:ext","values":{"speed":0.8,"hue":180,"hueRad":3.14}}。
js 複製
import { createShow } from '@openav/show' ;
import { webtoeWorld } from '@openav/world-webtoe' ;
const world = webtoeWorld({
name: 'garden-patch' ,
project: new URL('./garden.webtoe.json' , location.href).href, // the app fetches it: CORS must allow it
params: [
{ key: 'speed' , min: 0, max: 1, def: 0.5 },
{ key: 'hue' , min: 0, max: 360, def: 205 },
],
});
const show = await createShow({ world, routes: [{ source: 'midi/cc/1' , target: 'speed' }] });
static garden.webtoe.json 的節點圖填滿舞台,轉動 MIDI 旋鈕 1 會透過映射改變它的 speed。節點圖裡每個 ext('speed', …) 都會跟著動。
待確認
這裡沒有對真正的 WebToe 應用程式跑過(範例用的是一個代替的頁面,不需要網路)。因為值只有在改變時才會送出,在第一個畫面之後才載入完成的節點圖,對於之後再也不動的值,可能要等到其中一個值改變才會收到。真正的應用程式會不會發生這種情況,沒有查證。
相關 Stage 一次執行一個世界:登記世界和它們的參數、在世界之間切換,並在每個畫面解析參數、呼叫世界的 update 和 render。 createShow() 一個呼叫就替世界組好整場演出:訊號、參數、舞台、時間軸、映射、你開啟的模組、控台、後台資料和影格迴圈。 Params 世界所聽的那些值:一份宣告好的參數清單,每個參數每個畫面都解析成一個數字,除非有手、旋鈕或路由把它蓋掉。 Mapper 把訊號接到參數:它手上有一份路由清單(輸入窗、曲線、平滑),每當訊號到來,就寫入對應的參數。 import { webtoeWorld } from '@openav/world-webtoe'; packages/world-webtoe/index.js 第 20 行
手機、中繼與控制面板#
@openav/remote、@openav/relay、@openav/surface。 40
@openav/relay
aliasOf()#
函式 @openav/relay aliasOf(name, alias = 'any' )
手機訊號的「最新那支手機」名稱:aliasOf('phone/ab12/tilt/x') 是 'phone/any/tilt/x'。其他的名稱回傳 null。
寫在世界檔案裡的路由,不可能知道陌生人手機的 id。別名就是用來解決這件事:每個手機訊號也會歸一份到 phone/any/…,它永遠是最後送出數值的那支手機的值,所以 { source: 'phone/any/tilt/x', target: 'hue' } 在還沒有人加入之前就能寫好。bindSignals() 和 localSensors() 都會替你歸這份別名。
參數 名稱 型別 說明 nameString訊號名稱。只有 phone/<id>/… 有別名。 aliasString用來取代 id 的字。不給就是 any。 可省略 預設 'any'
回傳 String | null 別名。name 不是手機訊號、或本來就用別名的時候是 null。
範例
js 複製
import { aliasOf } from '@openav/relay' ;
document.getElementById('app' ).innerHTML = [
aliasOf('phone/ab12/tilt/x' ),
aliasOf('phone/any/tilt/x' ),
aliasOf('midi/note/on' ),
aliasOf('phone/ab12/knock' , 'latest' ),
].map(String).join('<br>' );
module 印出 phone/any/tilt/x,然後兩個 null(已經是別名、不是手機訊號),最後是 phone/latest/knock。
相關 bindSignals() 把演出端的 RelayClient 接到 Signals:手機送來的每個訊號都歸進去,並多一份 phone/any/… 的複本。回傳一個復原函式。 localSensors() 給跑在手機本身上的演出頁面用:手機自己的感測器直接進到 Signals,名稱是 phone/local/…,另外多一份 phone/any/… 的複本。回傳 PhoneSensors。 fileSignal() 把一個收到的訊號歸進 Signals:第一次聽到名稱就先宣告,補上力度,再設值或觸發脈衝。 import { aliasOf } from '@openav/relay'; packages/relay/signals.js 第 32 行
attachRelay()#
函式 @openav/relay attachRelay(server, { path = '/relay' , log = () => {}, heartbeatMs = 20000 } = {})
把房間中繼掛到 Node 的 http.Server 上:連到 path 的 WebSocket 用戶端會被放進房間,並依身分轉送訊息。回傳 { hub, close() }。
這是伺服器那一半,跑在 Node 裡,沒有任何相依套件(WebSocket 編解碼器就在套件裡)。node serve.js 已經在 /relay 替你做好了。要用在你自己的伺服器上:在 server.listen() 之前或之後呼叫 attachRelay(server) 都行。用戶端連到 ws://host/relay?role=controller|runner|monitor&room=<name>&id=<device>:身分不合格會變成 controller,房間名不合格會變成 default,id 會被截成 64 個字元。
誰能對誰說話:控制端的 signal 和 batch 送給演出端和監看端。演出端的只送給監看端。演出端的 feedback 和 config 送給控制端和監看端。監看端什麼都不能說。config 依 key 留著,重播給之後才加入的控制端,演出端離開時房間就忘掉它。房間之間互相聽不到。超過 256 KB 的訊息會被忽略,超過 1 MB 的 frame 會讓連線關閉。
沒有任何身分驗證:任何連得到伺服器、又知道房間名稱的人,都能以控制端身分加入。請用封閉的網路,或一個沒人猜得到的房間名稱。
呼叫之前
一支 Node 程式,從 @openav/relay/server 匯入(它跟 @openav/relay 是不同的進入點,後者是瀏覽器端)。單獨執行:node packages/relay/server.js [port](預設 7458,/health 會回傳房間名稱,用 RELAY_HOST=127.0.0.1 可以綁在 localhost,後面接反向代理)。
參數 名稱 型別 說明 serverhttp.ServerNode 的 http.Server(或任何有同樣 upgrade 事件的東西,例如 https 伺服器)。中繼會聽它的 upgrade 事件。 pathString用戶端要連的網址路徑。其他路徑一律拒絕。 可省略 預設 '/relay' logFunction(line) => {},用戶端加入時以 + controller ab12 @main 這樣的一行呼叫,離開時是 - controller ab12 @main。預設什麼都不說。 可省略 預設 () => {} heartbeatMsNumber多久對每個用戶端 ping 一次,單位毫秒。到下一次 ping 還沒回應的用戶端會被移除,所以斷線的手機會在一到兩個週期內消失。 可省略 預設 20000
回傳 { hub, close } hub 是 RoomHub(hub.rooms 是房間的 Map,hub.counts(room) 回傳 { controllers, runners, monitors })。close() 把所有連線中的用戶端斷開,不會關掉你的伺服器。
範例
js 複製
import http from 'node:http' ;
import { attachRelay } from '@openav/relay/server' ;
const server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
const relay = attachRelay(server, { log: (line) => console.log('[relay]' , line) });
server.listen(7458, () => console.log('relay at ws://localhost:7458/relay' ));
// later: relay.hub.counts('main') → { controllers: 1, runners: 1, monitors: 0 }
static 五行左右就有一個跑在 7458 埠的中繼。打開演出頁面時加上 ?relay=ws://localhost:7458/relay,頁面連上去的時候,終端機會印出像 [relay] + runner show-k3x9 @default 這樣的一行。
相關 RelayClient 房間中繼的瀏覽器端:把訊號送給同一個房間裡的其他頁面,也接收它們的訊號,身分可以是手機(controller)、演出本身(runner)或後台監看(monitor)。 bindSignals() 把演出端的 RelayClient 接到 Signals:手機送來的每個訊號都歸進去,並多一份 phone/any/… 的複本。回傳一個復原函式。 mountRemoteHost() 手機遙控在演出這一邊:以演出端(runner)身分加入中繼,歸檔手機訊號,依世界的參數建立控制面板,並把數值回送。回傳 host。 import { attachRelay } from '@openav/relay/server'; packages/relay/server.js 第 20 行
bindSignals()#
函式 @openav/relay bindSignals(client, signals, { alias = 'any' , meta = signalMeta } = {})
把演出端的 RelayClient 接到 Signals:手機送來的每個訊號都歸進去,並多一份 phone/any/… 的複本。回傳一個復原函式。
在演出頁面上,做好演出端的用戶端之後呼叫一次:bindSignals(show, signals)。它會設定 client.onSignal,讓每個收到的訊號都經過 fileSignal() ,手機訊號會歸兩份:自己的名稱(phone/ab12/tilt/x)和別名(phone/any/tilt/x)。寫作品時寫的路由用的是別名。
client.onSignal 裡原本就有的回呼,會在歸檔之後繼續執行,所以你還是看得到原始訊息。
參數 名稱 型別 說明 clientRelayClient演出端的用戶端(任何有 onSignal 欄位的物件都行)。 signalsSignals要填的註冊表(來自 @openav/core)。 aliasString | null複本用的字,phone/<alias>/…。null 或空字串表示不做複本。 可省略 預設 'any' metaFunction像 fileSignal() 一樣,用你自己的宣告取代 signalMeta 。 可省略 預設 signalMeta
回傳 Function 呼叫它就解除綁定:client.onSignal 回到綁定之前的樣子。
範例
js 複製
import { bindSignals } from '@openav/relay' ;
import { Signals } from '@openav/core' ;
const signals = new Signals();
const client = {}; // anything with an onSignal field; a RelayClient in real use
const unbind = bindSignals(client, signals);
// what the relay calls when a phone's message arrives
client.onSignal('phone/ab12/tilt/x' , 0.4, {});
client.onSignal('phone/ab12/knock' , { strength: 1 }, { pulse: true });
unbind(); // client.onSignal is back to what it was
document.getElementById('app' ).innerHTML = [
'phone/any/tilt/x = ' + signals.get ('phone/any/tilt/x' ),
'knock kind: ' + signals.meta.get ('phone/any/knock' ).kind,
'unbound: ' + (client.onSignal === undefined ),
].join('<br>' );
module 印出別名底下的傾斜值(0.4)、被宣告成脈衝的 knock,以及 unbind() 把 onSignal 放回去之後的「true」。
js 複製
import { RelayClient, bindSignals } from '@openav/relay' ;
import { Signals } from '@openav/core' ;
const signals = new Signals();
const show = new RelayClient({ role: 'runner' , room: 'main' });
const seen = [];
show.onSignal = (name) => seen.push(name); // our own listener first
bindSignals(show, signals); // it still runs after the filing
show.onSignal('phone/ab12/tilt/y' , 0.2, {});
document.getElementById('app' ).textContent = seen.join(', ' ) + ' | ' + signals.get ('phone/any/tilt/y' );
module bindSignals() 之前就有的監聽函式,照樣聽得到每一則訊息:印出「phone/ab12/tilt/y | 0.2」。
相關 RelayClient 房間中繼的瀏覽器端:把訊號送給同一個房間裡的其他頁面,也接收它們的訊號,身分可以是手機(controller)、演出本身(runner)或後台監看(monitor)。 fileSignal() 把一個收到的訊號歸進 Signals:第一次聽到名稱就先宣告,補上力度,再設值或觸發脈衝。 aliasOf() 手機訊號的「最新那支手機」名稱:aliasOf('phone/ab12/tilt/x') 是 'phone/any/tilt/x'。其他的名稱回傳 null。 signalMeta() 中繼對一個訊號名稱知道什麼:它的範圍和單位,或者它是脈衝。不認得的名稱回傳 null。 mountRemoteHost() 手機遙控在演出這一邊:以演出端(runner)身分加入中繼,歸檔手機訊號,依世界的參數建立控制面板,並把數值回送。回傳 host。 import { bindSignals } from '@openav/relay'; packages/relay/signals.js 第 65 行
deviceId()#
函式 @openav/relay deviceId(key = 'lab.deviceId' )
這個瀏覽器的隨機短 id,存在 localStorage 裡,用來在訊號名稱裡稱呼這支手機,例如 phone/ab12/tilt/x。
第一次呼叫會做一個 id 並存在 key 底下,之後的呼叫就把它讀回來。RelayClient 把它當作預設的 id,所以手機重新載入之後名字不變,訊號也一直是 phone/<同一個 id>/…。
如果 localStorage 被擋掉(有些無痕視窗),就存不下來,每次呼叫都會做一個新的 id。
參數 名稱 型別 說明 keyStringlocalStorage 的 key。預設是實驗室用的那一個,所以同一支手機上的 /control 和 /remote 算是同一個裝置。 可省略 預設 'lab.deviceId'
回傳 String 大約五個字母和數字的短 id,例如 k3x9a。同一個瀏覽器裡,每次呼叫都一樣。
範例
js 複製
import { deviceId } from '@openav/relay' ;
const first = deviceId();
const again = deviceId();
document.getElementById('app' ).textContent =
`id ${first}, the same on the next call: ${first === again}` ;
module 印出一個短 id 和「true」:第二次呼叫讀回第一次存下的值。
相關 RelayClient 房間中繼的瀏覽器端:把訊號送給同一個房間裡的其他頁面,也接收它們的訊號,身分可以是手機(controller)、演出本身(runner)或後台監看(monitor)。 PhoneSensors 把手機的傾斜、加速度、旋轉、敲擊和前鏡頭的光線,換成有名字的訊號(用螢幕座標軸),交給你給它的 io。 import { deviceId } from '@openav/relay'; packages/relay/index.js 第 27 行
fileSignal()#
函式 @openav/relay fileSignal(signals, name, value, { pulse = false , meta = signalMeta, source = '' } = {})
把一個收到的訊號歸進 Signals:第一次聽到名稱就先宣告,補上力度,再設值或觸發脈衝。
這是傳輸格式的接收那一半,每個接收端都靠它用同樣的方式歸檔訊息:有中繼的演出用 bindSignals() ,跑在手機上的演出用 localSensors() ,自己開 socket 的主機也一樣。順序是:註冊表沒聽過這個名稱的話,就照 signalMeta() 的說法宣告(脈衝不會被當成旋鈕,音量表也拿得到範圍),用 normalizeValue() 補上缺的力度寫法,然後脈衝就 signals.pulse(name, value),其他就 signals.set(name, value)。
參數 名稱 型別 說明 signalsSignals要歸進去的註冊表(來自 @openav/core)。 nameString訊號名稱。必須是非空字串,最長 128 個字元,否則什麼都不歸。 valueany訊號的內容。 pulseBoolean訊息是當作事件送來的話為 true。名稱若是 signalMeta() 認得的脈衝(或之前就宣告成脈衝),不管這一項是什麼,都當作脈衝。 可省略 預設 false metaFunction用你自己的 (name, { pulse }) => 資料 | null 取代 signalMeta ,用來宣告中繼不認得的名稱。 可省略 預設 signalMeta sourceString存進宣告資料裡的標籤(signals.meta.get(name).source),例如 'relay' 或 'local'。 可省略 預設 ''
回傳 Boolean 歸好了是 true,名稱不合格是 false。
範例
js 複製
import { fileSignal } from '@openav/relay' ;
import { Signals } from '@openav/core' ;
const signals = new Signals();
fileSignal(signals, 'midi/note/on' , { note: 64, velocity: 100 }); // a known pulse name
fileSignal(signals, 'phone/ab12/tilt/x' , 0.3, { source: 'relay' });
const bad = fileSignal(signals, '' , 1);
document.getElementById('app' ).innerHTML = [
'note vel: ' + signals.get ('midi/note/on' ).vel.toFixed(2),
'note kind: ' + signals.meta.get ('midi/note/on' ).kind,
'tilt range: ' + signals.meta.get ('phone/ab12/tilt/x' ).min + '..' + signals.meta.get ('phone/ab12/tilt/x' ).max,
'empty name filed: ' + bad,
].join('<br>' );
module 印出送訊號的人沒寫過的力度 0.79(100 / 127)、種類「pulse」、傾斜的範圍 -1..1,以及空名稱的「false」。
相關 bindSignals() 把演出端的 RelayClient 接到 Signals:手機送來的每個訊號都歸進去,並多一份 phone/any/… 的複本。回傳一個復原函式。 signalMeta() 中繼對一個訊號名稱知道什麼:它的範圍和單位,或者它是脈衝。不認得的名稱回傳 null。 normalizeValue() 補上音符訊息缺的那一種力度寫法:vel(0..1)或 velocity(1..127)。其他訊息原樣通過。 localSensors() 給跑在手機本身上的演出頁面用:手機自己的感測器直接進到 Signals,名稱是 phone/local/…,另外多一份 phone/any/… 的複本。回傳 PhoneSensors。 aliasOf() 手機訊號的「最新那支手機」名稱:aliasOf('phone/ab12/tilt/x') 是 'phone/any/tilt/x'。其他的名稱回傳 null。 import { fileSignal } from '@openav/relay'; packages/relay/signals.js 第 55 行
normalizeValue()#
函式 @openav/relay normalizeValue(name, value)
補上音符訊息缺的那一種力度寫法:vel(0..1)或 velocity(1..127)。其他訊息原樣通過。
@openav/midi 發布的 vel 是 0..1,硬體鍵盤說的是 1..127 的 velocity,而送訊號的人(舊的手機頁面、TouchOSC 橋接、編曲軟體)只會給其中一種。草圖通常只照一種寫法寫,所以接收端把另一種補上:velocity 會變成 vel = velocity / 127,vel 會變成 velocity = Math.round(vel * 127)。
通常不用自己呼叫:fileSignal() 歸檔每一則訊息時都會做這件事。
參數 名稱 型別 說明 nameString訊號名稱。只有 midi/note/on、midi/note/off,以及中間多一個裝置名的寫法(midi/<device>/note/on)會被處理。 valueany訊號的內容。只有帶 vel 或 velocity 的物件會被改(改的是複本,不動原件)。
回傳 any 同時帶 vel 和 velocity 的內容。兩個本來就都有的話,原物件原樣傳回。
範例
js 複製
import { normalizeValue } from '@openav/relay' ;
const rows = [
normalizeValue('midi/note/on' , { note: 60, velocity: 127 }),
normalizeValue('midi/note/on' , { note: 60, vel: 0.5 }),
normalizeValue('midi/minilab3/note/off' , { note: 61, vel: 1 }),
normalizeValue('surface/main/pad/hit' , { velocity: 3 }),
];
document.getElementById('app' ).innerHTML = rows.map((r) => JSON.stringify(r)).join('<br>' );
module 前兩個補上缺的寫法(vel 是 1、velocity 是 64)。第三個名稱中間有裝置名也照樣處理。第四個不是音符,保持原樣。
相關 fileSignal() 把一個收到的訊號歸進 Signals:第一次聽到名稱就先宣告,補上力度,再設值或觸發脈衝。 bindSignals() 把演出端的 RelayClient 接到 Signals:手機送來的每個訊號都歸進去,並多一份 phone/any/… 的複本。回傳一個復原函式。 import { normalizeValue } from '@openav/relay'; packages/relay/signals.js 第 43 行
RelayClient#
類別 @openav/relay new RelayClient({ role = 'controller' , room = 'default' , id = deviceId(), url = null , batchHz = 30, resendMs = 1000, onStatus, onSignal, onFeedback, onConfig } = {})
房間中繼的瀏覽器端:把訊號送給同一個房間裡的其他頁面,也接收它們的訊號,身分可以是手機(controller)、演出本身(runner)或後台監看(monitor)。
每個頁面做一個用戶端,給它身分和房間,然後呼叫 connect()。手機用 phone.set('phone/me/tilt/x', 0.3) 送連續變化的數值,用 phone.send('midi/note/on', { note: 60 }, true) 送事件。演出端把自己的用戶端交給 bindSignals() ,每個手機訊號就會進到它的 Signals。中繼本身是 Node 伺服器上的 attachRelay() 。
連續的數值刻意做得很省:set() 只是把值存起來(跟上一次一樣就什麼都不做),之後每 1 / batchHz 秒,有變動的值打包成一則 batch 送出,每 resendMs 毫秒,全部的值再送一遍。事件則相反:send() 馬上送出,不打包也不重送,因為一個 note-on 收到兩次就是錯。
連線它自己照顧。socket 關掉之後,它會在 0.5 秒、1 秒、2 秒、4 秒,之後每 5 秒重連一次,直到你呼叫 close()。每 2 秒送一個 ping,回應的時間差就是 latency(毫秒)。status 是 'connecting'、'open' 或 'closed',peers 是房間裡有幾個 controllers、runners、monitors。
呼叫之前
要有一台中繼伺服器可以連,例如 node serve.js(它在 /relay 架了一個)或 node packages/relay/server.js。socket 還沒打開之前,set() 會記住最新的值,打開時送出,但 send()、feedback()、config() 的訊息會直接丟掉,不會排隊。
參數 名稱 型別 說明 role'controller' | 'runner' | 'monitor'這個頁面的身分。控制端(controller,也就是手機)把訊號送給演出端和監看端。演出端(runner,也就是演出本身)聽得到控制端,也能把 feedback 和 config 送回去。監看端(monitor)只聽不說。伺服器遇到其他字一律當成 controller。 可省略 預設 'controller' roomString房間名稱。不同房間的頁面彼此聽不到。1 到 32 個字元,只能用 A–Z a–z 0–9 _ -,其他的伺服器會換成 default。 可省略 預設 'default' idString這個裝置的 id,也就是 phone/<id>/… 裡的 <id>。預設是 deviceId() ,存在 localStorage 裡。伺服器只留前 64 個字元。 可省略 預設 deviceId() urlString完整的 WebSocket 位址,查詢字串要自己帶好:wss://host/relay?role=controller&room=main&id=ab12。它不會再補任何東西。不給的話,用頁面網址裡的 ?relay=ws://…,再沒有就用頁面自己的 /relay(https 頁面用 wss:),並自動補上 role、room、id。 可省略 預設 null batchHzNumber每秒把你 set() 的數值送出幾次,打包成一則 batch 訊息。 可省略 預設 30 resendMsNumber每隔這麼多毫秒,把存起來的每一個值再送一次,不只是有變動的。晚進來的手機、重新載入的演出頁面,就靠這個趕上現況。 可省略 預設 1000 onStatusFunction(client) => {},連線打開或關閉、pong 帶來新的 latency、連線數改變的時候呼叫。在裡面讀 client.status、client.latency、client.peers。 可省略 onSignalFunction(name, value, msg) => {},每收到一個訊號就呼叫(演出端和監看端聽得到控制端,監看端也聽得到演出端)。一則 batch 會每個項目各呼叫一次。脈衝訊號的 msg.pulse 是 true。bindSignals() 會替你填這一項。 可省略 onFeedbackFunction(name, value) => {},在控制端:演出端用 feedback() 送來的數值,拿去顯示在音量表或移動推桿。 可省略 onConfigFunction(key, data) => {},在控制端:演出端用 config() 送來的常駐設定,例如控制面板的版面。晚進來的手機會立刻收到已儲存的那些。 可省略
成員 名稱 說明 opengetter socket 開著的時候是 true(getter)。它是 false 的時候,send() 和 config() 什麼都不做。 connect()method connect():打開 socket,啟動計時器(送出、重送、ping)。回傳用戶端本身,所以 new RelayClient({…}).connect() 可以直接寫。close() 之後再呼叫一次就會重新開始。close()method close():停掉計時器並把 socket 關掉。在你再次呼叫 connect() 之前不會重連。send(name, value, pulse = false, extra)method send(name, value, pulse?, extra?):現在就送出一個訊號。pulse 為 true 表示它是事件。extra 是物件,裡面的鍵會加進訊息裡。socket 沒開的話就丟掉。set(name, value)method set(name, value):連續變化的數值。每個名字只留最新的值,隨下一次送出一起出去,之後每 resendMs 再送一次。feedback(name, value)method feedback(name, value):只有演出端有用。給控制端顯示或回應的數值,送出的方式跟 set() 一樣,每 resendMs 再送一次。在控制端或監看端上什麼都不會發生。config(key, data)method config(key, data):只有演出端有用。馬上送出一份常駐設定,例如 config('surface', { layout })。中繼會依 key 留著,給之後才加入的控制端,演出端離開時就忘掉。
注意 公開的欄位有 role、room、id、url、batchHz、resendMs、各個回呼、status、latency(毫秒,第一個 pong 回來之前是 null)、peers 和 sent(已經送出幾則訊息)。 重連之後,演出端要再送一次 config():用戶端不會記得它。mountRemoteHost() 會替你做這件事。 範例
js 複製
import { RelayClient, bindSignals } from '@openav/relay' ;
import { Signals } from '@openav/core' ;
// the show page: every phone signal lands in a Signals registry
const signals = new Signals();
const show = new RelayClient({ role: 'runner' , room: 'main' }).connect();
bindSignals(show, signals);
// the phone page: a continuous value and an event
const phone = new RelayClient({ role: 'controller' , room: 'main' }).connect();
phone.set ('phone/me/tilt/x' , 0.3);
phone.send('midi/note/on' , { note: 60 }, true );
static 一個房間的兩端,實際上會放在兩個頁面(演出和手機):socket 打開之後,演出端的 signals 裡就有 phone/me/tilt/x,以及別名 phone/any/tilt/x。
js 複製
import { RelayClient, bindSignals } from '@openav/relay' ;
import { Signals } from '@openav/core' ;
// A stand-in for the relay so this runs without a server: a controller's messages reach the runner,
// and the runner's feedback and config reach the controller (the same routing the real relay does).
const sockets = [];
globalThis.WebSocket = class {
constructor(url) {
this .role = new URL(url).searchParams.get ('role' );
this .readyState = 1;
sockets.push(this );
setTimeout(() => this .onopen?.());
}
send(text) {
const type = JSON.parse(text).type;
const to = this .role === 'controller' ? 'runner' : (type === 'feedback' || type === 'config' ) ? 'controller' : null ;
for (const s of sockets) if (s.role === to) s.onmessage?.({ data: text });
}
close() {}
};
const signals = new Signals();
const show = new RelayClient({ role: 'runner' , url: 'ws://relay/x?role=runner&room=demo&id=show' }).connect();
bindSignals(show, signals);
const got = [];
const phone = new RelayClient({
role: 'controller' , url: 'ws://relay/x?role=controller&room=demo&id=ab12' ,
onFeedback: (name, value) => got.push('feedback ' + name + ' = ' + value),
onConfig: (key, data) => got.push('config ' + key + ' = ' + JSON.stringify(data)),
}).connect();
phone.set ('phone/ab12/tilt/x' , 0.3);
show.feedback('surface/main/level' , 0.4);
show.config('surface' , { title: 'demo' });
setTimeout(() => {
got.push('tilt in the show: ' + signals.get ('phone/any/tilt/x' ));
document.getElementById('app' ).innerHTML = got.join('<br>' );
phone.close(); show.close();
}, 200);
module 印出手機從演出端收到的 config 和 feedback,再印出演出端聽到的手機傾斜(在別名 phone/any/tilt/x 底下)。呼叫的方式跟連真正的中繼完全一樣。
待確認
重連的間隔(0.5 秒、1 秒、2 秒、4 秒,之後 5 秒)是從程式碼讀來的,沒有拿一個斷線又恢復的中繼去實測。第二個範例是拿替身 socket 跑用戶端,不是真正的 WebSocket 伺服器。
相關 bindSignals() 把演出端的 RelayClient 接到 Signals:手機送來的每個訊號都歸進去,並多一份 phone/any/… 的複本。回傳一個復原函式。 attachRelay() 把房間中繼掛到 Node 的 http.Server 上:連到 path 的 WebSocket 用戶端會被放進房間,並依身分轉送訊息。回傳 { hub, close() }。 deviceId() 這個瀏覽器的隨機短 id,存在 localStorage 裡,用來在訊號名稱裡稱呼這支手機,例如 phone/ab12/tilt/x。 mountRemoteHost() 手機遙控在演出這一邊:以演出端(runner)身分加入中繼,歸檔手機訊號,依世界的參數建立控制面板,並把數值回送。回傳 host。 mountRemote() 一次呼叫就是整個手機 app:狀態列加上 感測、琴鍵、控制台、MIDI 這幾個分頁,以控制端身分跟中繼溝通。/packages/remote/ 做的就是這件事。 Signals 所有輸入都寫進去、其他東西都從這裡讀的登錄表:一個個有名字的值,不是持續的數值(continuous),就是一次性的事件(pulse)。 import { RelayClient } from '@openav/relay'; packages/relay/index.js 第 34 行
signalMeta(name, { pulse = false } = {})
中繼對一個訊號名稱知道什麼:它的範圍和單位,或者它是脈衝。不認得的名稱回傳 null。
Signals 要先知道一個名稱的範圍,音量表才能縮放、映射才分得出旋鈕和事件。fileSignal() 第一次聽到一個名稱就會呼叫它。它認得的有:phone/<id>/tilt/* 是 −1..1,accel/* 是 −20..20 m/s²,rot/* 是 −360..360 °/s,orient/* 是 0..360 °,touch/count 是 0..10,knock 是脈衝,其他的 phone/… 名稱(觸控、光線)是 0..1。surface/… 的名稱是 0..1(…/raw 和 …/delta 沒有縮放,回傳 {})。midi/note/on、midi/note/off、midi/virtual,以及控制器的 hit、on、off、last 名稱是脈衝,midi/cc/* 是 0..1。
參數 名稱 型別 說明 nameString訊號名稱,例如 phone/ab12/tilt/x、surface/main/hue 或 midi/note/on。 pulseBoolean訊息是當作事件送來的話,傳 { pulse: true }:答案一律是 { kind: 'pulse' }。 可省略 預設 false
回傳 Object | null 給 signals.define() 用的資料:{ min, max, unit? }、{ kind: 'pulse' },原始值則是 {}。名稱不在它認得的範圍內時是 null。
範例
js 複製
import { signalMeta } from '@openav/relay' ;
const names = ['phone/ab12/tilt/x' , 'phone/ab12/accel/z' , 'phone/ab12/knock' , 'surface/main/hue' , 'surface/main/hue/raw' , 'midi/note/on' , 'my/own/thing' ];
document.getElementById('app' ).innerHTML = names
.map((n) => `${n}: ${JSON.stringify(signalMeta(n))}` )
.join('<br>' );
module 印出中繼對七個名稱的認識,從傾斜的 {"min":-1,"max":1},到完全不認得的名稱的 null。
相關 fileSignal() 把一個收到的訊號歸進 Signals:第一次聽到名稱就先宣告,補上力度,再設值或觸發脈衝。 bindSignals() 把演出端的 RelayClient 接到 Signals:手機送來的每個訊號都歸進去,並多一份 phone/any/… 的複本。回傳一個復原函式。 aliasOf() 手機訊號的「最新那支手機」名稱:aliasOf('phone/ab12/tilt/x') 是 'phone/any/tilt/x'。其他的名稱回傳 null。 import { signalMeta } from '@openav/relay'; packages/relay/signals.js 第 8 行
@openav/remote
attachTouchPad()#
函式 @openav/remote attachTouchPad(pad, io, { onMove, onEnd } = {})
把一個元素變成多點觸控板:每根手指發布 touch/x、touch/y 和 touch/down(在元素裡的 0..1),touch/count 是現在按著幾根。
第一根按下去的手指是 slot 0,發布 touch/x、touch/y、touch/down。第二根是 slot 1,發布 touch/1/x、touch/1/y、touch/1/down,以此類推。手指離開就把 slot 還回去,下一根會用最小的空號。x 從左邊緣的 0 到右邊緣的 1,y 從上緣的 0 到下緣的 1,都限制在元素裡面,並四捨五入到小數點後 4 位。touch/down 是 1 或 0,touch/count 是按著的手指數。
它一接上去就先發布 touch/down 0 和 touch/count 0,所以接收端在還沒有人碰之前就知道觸控板存在。它用的是 pointer 事件,所以滑鼠也能用(算一根手指)。
參數 名稱 型別 說明 padHTMLElement要監聽的元素。在 CSS 裡給它 touch-action: none,不然瀏覽器可能把拖曳當成捲動,把觸碰取消掉。 io{ set(name, value) }數值要送去哪裡,跟 PhoneSensors 一樣:只用到 set,名稱前面沒有 phone/<id>/。 onMoveFunction(slot, x, y, down) => {},手指按下去和每次移動的時候呼叫。x、y 是 0..1,down 是 true。拿來畫手指的位置。 可省略 onEndFunction(slot) => {},手指離開或觸碰被取消的時候呼叫。 可省略
範例
js 複製
import { attachTouchPad } from '@openav/remote' ;
const pad = document.createElement('div' );
pad.style.cssText = 'width:200px;height:200px;background:#222;touch-action:none' ;
document.getElementById('app' ).append(pad);
const seen = [];
attachTouchPad(pad, { set : (name, value) => seen.push(name + '=' + value) });
// a finger lands a quarter of the way across, halfway down, then moves right
const r = pad.getBoundingClientRect();
const touch = (type, id, fx, fy) => pad.dispatchEvent(new PointerEvent(type, {
pointerId: id, clientX: r.left + fx * r.width, clientY: r.top + fy * r.height, bubbles: true ,
}));
touch('pointerdown' , 1, 0.25, 0.5);
touch('pointermove' , 1, 0.5, 0.5);
touch('pointerup' , 1, 0.5, 0.5);
pad.after(Object.assign(document.createElement('p' ), { innerHTML: seen.join('<br>' ) }));
module 畫面上出現一個 200 像素的方塊,下面列出一根手指會發布的數值:一開始的 touch/down=0、touch/count=0,然後是它落下、移動、離開時的 x、y、down 和 count。
js 複製
import { attachTouchPad } from '@openav/remote' ;
const pad = document.createElement('div' );
pad.style.cssText = 'width:200px;height:100px;background:#222;touch-action:none' ;
document.getElementById('app' ).append(pad);
const seen = [];
attachTouchPad(pad, { set : (name, value) => { if (name.endsWith('/count' ) || name.endsWith('/down' )) seen.push(name + '=' + value); } }, {
onMove: (slot, x, y) => seen.push('finger ' + slot + ' at ' + x.toFixed(2) + ', ' + y.toFixed(2)),
onEnd: (slot) => seen.push('finger ' + slot + ' lifted' ),
});
const r = pad.getBoundingClientRect();
const touch = (type, id, fx, fy) => pad.dispatchEvent(new PointerEvent(type, {
pointerId: id, clientX: r.left + fx * r.width, clientY: r.top + fy * r.height, bubbles: true ,
}));
touch('pointerdown' , 1, 0.1, 0.5); // slot 0
touch('pointerdown' , 2, 0.9, 0.5); // slot 1: touch/1/…
touch('pointerup' , 1, 0.1, 0.5); // slot 0 is free again
touch('pointerdown' , 3, 0.5, 0.5); // takes slot 0
pad.after(Object.assign(document.createElement('p' ), { innerHTML: seen.join('<br>' ) }));
module 兩根手指拿到 slot 0 和 1,第一根離開之後,下一根又拿回 slot 0。清單裡是各個回呼和手指數。
相關 PhoneSensors 把手機的傾斜、加速度、旋轉、敲擊和前鏡頭的光線,換成有名字的訊號(用螢幕座標軸),交給你給它的 io。 mountRemote() 一次呼叫就是整個手機 app:狀態列加上 感測、琴鍵、控制台、MIDI 這幾個分頁,以控制端身分跟中繼溝通。/packages/remote/ 做的就是這件事。 RelayClient 房間中繼的瀏覽器端:把訊號送給同一個房間裡的其他頁面,也接收它們的訊號,身分可以是手機(controller)、演出本身(runner)或後台監看(monitor)。 import { attachTouchPad } from '@openav/remote'; packages/remote/sensors.js 第 138 行
localSensors()#
函式 @openav/remote localSensors(signals, { prefix = 'phone/local/' , alias = 'any' , source = 'local' , hooks = {} } = {})
給跑在手機本身上的演出頁面用:手機自己的感測器直接進到 Signals,名稱是 phone/local/…,另外多一份 phone/any/… 的複本。回傳 PhoneSensors。
遠端的手機要透過中繼才找得到演出。演出頁面就開在手機上的時候,沒有人需要轉送,所以直接在原地讀感測器。名稱和門檻跟遠端手機一樣(phone/local/tilt/x 是 −1..1,phone/local/knock 是脈衝),用 fileSignal() 一樣的方式宣告,並複製一份到 phone/any/…,所以替「最新的那支手機」寫的路由 phone/any/tilt/x,不論手機是拿在手上當控制端、還是它本身就是演出,都能用。
呼叫之前
拿到結果之後,要在點擊時呼叫 .start()(iOS 只在手勢裡才會問權限,而且只限 https 頁面)。在那之前什麼都不會發布。見 PhoneSensors 。
參數 名稱 型別 說明 signalsSignals要填的註冊表(來自 @openav/core)。 prefixString放在每個感測器名稱前面的字。 可省略 預設 'phone/local/' aliasString | null複本用的字,phone/<alias>/…。null 表示不做複本。 可省略 預設 'any' sourceString存成 signals.meta 裡這些訊號的 source。 可省略 預設 'local' hooksObjectPhoneSensors 的 hooks:onKnock、onSample、notify。 可省略 預設 {}
回傳 PhoneSensors 還沒有開始聽:要在點擊時呼叫 .start()。
範例
js 複製
import { localSensors } from '@openav/remote' ;
import { Signals } from '@openav/core' ;
const signals = new Signals();
const own = localSensors(signals);
await own.start(); // on a phone: from a tap
window.dispatchEvent(new DeviceOrientationEvent('deviceorientation' , { alpha: 0, beta: 22.5, gamma: -45 }));
own.stop();
document.getElementById('app' ).innerHTML = [
'phone/local/tilt/x = ' + signals.get ('phone/local/tilt/x' ),
'phone/any/tilt/y = ' + signals.get ('phone/any/tilt/y' ),
'source: ' + signals.meta.get ('phone/local/tilt/x' ).source,
].join('<br>' );
module 印出兩個名稱底下的傾斜值(-1 和 0.5)和來源「local」。在手機上,事件是從感測器來的,而不是這個編出來的。
js 複製
import { localSensors } from '@openav/remote' ;
import { Signals } from '@openav/core' ;
const signals = new Signals();
const own = localSensors(signals, { prefix: 'phone/me/' , alias: null }); // no phone/any copy
await own.start();
window.dispatchEvent(new DeviceOrientationEvent('deviceorientation' , { alpha: 0, beta: 0, gamma: 9 }));
own.stop();
document.getElementById('app' ).textContent =
'phone/me/tilt/x = ' + signals.get ('phone/me/tilt/x' ) + ', copy exists: ' + signals.meta.has('phone/any/tilt/x' );
module 換一個前綴、不做別名:印出「phone/me/tilt/x = 0.2, copy exists: false」。
相關 PhoneSensors 把手機的傾斜、加速度、旋轉、敲擊和前鏡頭的光線,換成有名字的訊號(用螢幕座標軸),交給你給它的 io。 fileSignal() 把一個收到的訊號歸進 Signals:第一次聽到名稱就先宣告,補上力度,再設值或觸發脈衝。 aliasOf() 手機訊號的「最新那支手機」名稱:aliasOf('phone/ab12/tilt/x') 是 'phone/any/tilt/x'。其他的名稱回傳 null。 bindSignals() 把演出端的 RelayClient 接到 Signals:手機送來的每個訊號都歸進去,並多一份 phone/any/… 的複本。回傳一個復原函式。 import { localSensors } from '@openav/remote'; packages/remote/sensors.js 第 163 行
mountJoinCard()#
函式 @openav/remote mountJoinCard(parent, host)
在演出頁面上放一張小卡片,有 QR code 和手機該打開的網址,還有一行字說明中繼連不連得上、有幾支手機進來。
網址是手機頁面(這個檔案旁邊的 packages/remote/),後面接 ?room=<room>,演出用公開中繼或你自己的中繼的話還會接上 ?relay=…,這樣手機會進到同一個中繼的同一個房間。在 localhost 的時候,它會向 node serve.js 的 /__info 詢問這台電腦的區域網路位址並填進網址,因為手機打不開 localhost。QR code 是依網址畫出來的,網址太長、編碼器放不下時,就只留網址、不畫 QR code。
連線打開的時候,狀態那行寫 relay ok · 2 phones · room demo(用公開中繼的話後面加 · public relay),本機的中繼連不上時寫 relay offline — is it node serve.js?,共用的中繼連不上則寫 can't reach the relay (…) — retrying。直拿的手機上,卡片一開始是收起來的,因為舞台很小,點一下可以展開或收起。
參數 名稱 型別 說明 parentHTMLElement要把卡片加進去的元素。卡片是 position: absolute,貼在左上角,所以父元素最好有定位(position: relative),而且夠大,放得下它。 hostObjectmountRemoteHost() 回傳的 host。卡片會讀 host.room、host.relayMode、host.relayUrl,並接管 host.onStatus 來寫狀態那行字。
回傳 HTMLElement 卡片的元素,已經放進 parent 裡了。要移除就呼叫 .remove()。
範例
js 複製
import { mountRemoteHost, mountJoinCard } from '@openav/remote/host' ;
import { Signals, Params } from '@openav/core' ;
import { Mapper } from '@openav/mapping' ;
const params = new Params([{ key: 'hue' , min: 0, max: 360, def: 200 }]);
const signals = new Signals();
const mapper = new Mapper({ signals, params });
const host = mountRemoteHost({ signals, params, mapper, room: 'demo' }); // not connected
const card = mountJoinCard(document.getElementById('app' ), host);
host.onStatus({ status: 'open' , peers: { controllers: 2 } }); // pretend the relay answered
module 左上角出現一張卡片,有 QR code、結尾是 ?room=demo 的手機網址,以及「relay ok · 2 phones · room demo」這行字。
相關 mountRemoteHost() 手機遙控在演出這一邊:以演出端(runner)身分加入中繼,歸檔手機訊號,依世界的參數建立控制面板,並把數值回送。回傳 host。 mountRemote() 一次呼叫就是整個手機 app:狀態列加上 感測、琴鍵、控制台、MIDI 這幾個分頁,以控制端身分跟中繼溝通。/packages/remote/ 做的就是這件事。 RelayClient 房間中繼的瀏覽器端:把訊號送給同一個房間裡的其他頁面,也接收它們的訊號,身分可以是手機(controller)、演出本身(runner)或後台監看(monitor)。 import { mountJoinCard } from '@openav/remote/host'; packages/remote/host.js 第 90 行
mountRemote()#
函式 @openav/remote mountRemote(root, opts = {})
一次呼叫就是整個手機 app:狀態列加上 感測、琴鍵、控制台、MIDI 這幾個分頁,以控制端身分跟中繼溝通。/packages/remote/ 做的就是這件事。
它就是手機打開的那個東西:lockViewport() 讓畫面不能捲動也不能縮放,keepAwake() 讓螢幕不暗掉,一個所有分頁共用的、身分是控制端的 RelayClient ,再加上幾個分頁。感測(sense)有一顆傾斜球、讀數、一塊多點觸控板(attachTouchPad() ),以及藏在開始按鈕後面的感測器(PhoneSensors )。琴鍵(keys)是一台鋼琴,橫拿時一排,直拿時上下兩排。控制台(control)是一個 Surface 。MIDI 是一台虛擬控制器。
控制台分頁的版面依序從這些地方找:surfaceUrl 或 ?surface=、metaUrl 或 ?meta=、演出透過中繼發布的版面(設定 surface),最後是一個小小的通用面板,頁面永遠不會空白。訊號是 phone/<id>/…(感測)、midi/note/on|off 和 midi/cc/64(琴鍵)、surface/<page>/<id>(控制台)。app 一開始打開 ?tab= 指定的分頁,沒有就是這個瀏覽器上次用的分頁,再沒有就是感測。
呼叫之前
一個專屬的頁面(它會佔滿整個螢幕),以及一個連得到的中繼:沒有 relayUrl 或 ?relay= 的話就是同源的 /relay。房間裡沒有演出的時候,狀態列會寫在等作品開啟。
參數 名稱 型別 說明 rootHTMLElement要把 app 放在哪裡。它會填滿整個頁面(position: fixed),所以傳 document.body。 optsObject{ room, relayUrl, surfaceUrl, metaUrl },每一項都可省略。room 是中繼房間(沒給就用網址的 ?room=,再沒有就用 window.__REMOTE__.room,再沒有就是 default)。relayUrl 是 ws:// 或 wss:// 的中繼(沒給就用 ?relay=。只收這兩種,所以一個連結不能把手機指到別的地方)。surfaceUrl 是控制台分頁用的版面 JSON,metaUrl 是帶 params 陣列的 JSON,控制台分頁會把它變成 autoSurface() 。網址上也可以寫 ?tab=sense|keys|control|midi。 可省略 預設 {}
回傳 Object { relay, show(tab), surface, dispose() }:RelayClient 、切換分頁的函式、控制台分頁的 Surface (getter,那個分頁還沒打開過就是 null),以及 dispose():關掉中繼、解除視窗鎖定、放掉螢幕喚醒鎖、移除整個 app。
範例
js 複製
import { mountRemote } from '@openav/remote' ;
// the whole phone app: this is what /packages/remote/ does
const app = mountRemote(document.body, { room: 'main' });
// later: app.show('keys') switches tab, app.dispose() takes everything down
static 在手機上:有連線和延遲的狀態列,以及下方的 感測、琴鍵、控制台、MIDI 分頁。
待確認
這一條沒有在真正的手機上打開過:分頁怎麼運作,是從 packages/remote/app.js 和 docs/remote.md 讀來的。
相關 mountRemoteHost() 手機遙控在演出這一邊:以演出端(runner)身分加入中繼,歸檔手機訊號,依世界的參數建立控制面板,並把數值回送。回傳 host。 PhoneSensors 把手機的傾斜、加速度、旋轉、敲擊和前鏡頭的光線,換成有名字的訊號(用螢幕座標軸),交給你給它的 io。 attachTouchPad() 把一個元素變成多點觸控板:每根手指發布 touch/x、touch/y 和 touch/down(在元素裡的 0..1),touch/count 是現在按著幾根。 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 RelayClient 房間中繼的瀏覽器端:把訊號送給同一個房間裡的其他頁面,也接收它們的訊號,身分可以是手機(controller)、演出本身(runner)或後台監看(monitor)。 import { mountRemote } from '@openav/remote'; packages/remote/app.js 第 105 行
mountRemoteHost()#
函式 @openav/remote mountRemoteHost({ signals, params, mapper, world = null , room = 'default' , auto = {}, surface = null , feedbackHz = 10, url = null , mode = null , controllers = null } = {})
手機遙控在演出這一邊:以演出端(runner)身分加入中繼,歸檔手機訊號,依世界的參數建立控制面板,並把數值回送。回傳 host。
createShow({ modules: { remote: true } }) 會替你呼叫它,所以世界完全不必知道手機的存在。自己呼叫的話,它做五件事:先做一個 RelayClient 當房間的演出端,再用 bindSignals() 把每個手機訊號歸進 signals(所以 phone/any/… 永遠是最新那支手機的值),然後用 surface、world.surface 或參數的 autoSurface() 建立版面,接著把 surface/… → param 路由加進映射器,最後,你每個畫面呼叫 host.frame(dt, state) 的話,它大約每秒 feedbackHz 次把算好的參數值送回去,讓手機上的推桿跟著時間軸動。
做出 host 並不會連線:要連的時候呼叫 host.connect()。每次(重新)連上時,它會把版面當成中繼設定 surface 發布出去,所以之後才加入的手機也拿得到正確的面板。host.layout、host.routes、host.bindings 一做出來就能看,host.dispose() 會把 socket 關掉。
呼叫之前
要有中繼讓 connect() 連:node serve.js 在 /relay 架了一個,靜態網站用公開中繼(見 pickRelay()),或傳 url。單單做出 host 不需要這些。
參數 名稱 型別 說明 signalsSignals手機訊號要歸進去的註冊表。 可省略 paramsParams世界的參數。控制面板就是依它的 schema 建立的。 可省略 mapperMapper會收到 surface/… → param 路由的映射器(它已經有的路由不會重複加)。 可省略 worldObject世界物件:params.schema 是空的時候用它的 params,它的 name 會變成版面的標題,它的 surface(手寫的版面)會取代自動產生的。 可省略 預設 null roomString要加入的中繼房間。 可省略 預設 'default' autoObject傳給 autoSurface() 的選項:pairs、meters、style、perPage、smooth、title。 可省略 預設 {} surfaceObject指定的版面。它比 world.surface 優先。 可省略 預設 null feedbackHzNumberhost.frame() 每秒把參數值送回手機幾次。 可省略 預設 10 urlString不用這個頁面自己的 /relay,改用的中繼,例如 wss://host/relay。role、room、id 會替你補上去。這時演出端的 id 是 show- 加四個隨機字元,所以同一個演出開兩個分頁也不會打架。沒給 url 的話就是 show。 可省略 預設 null mode'local' | 'public' | 'custom'pickRelay() 的決定。只有加入卡片上的字會依它而變,host 會把它報成 host.relayMode。 可省略 預設 null controllersMidiControllers@openav/midi 的螢幕 MIDI 控制器:這樣手機的 MIDI 分頁會顯示同一台裝置,兩邊畫面上彈的音也會互相顯示。 可省略 預設 null
回傳 Object { relay, layout, routes, bindings, publish, room, relayUrl, relayMode, onStatus, frame(dt, state), connect(), dispose() }。connect() 會再回傳 host 本身。
範例
js 複製
import { mountRemoteHost } from '@openav/remote/host' ;
import { Signals, Params } from '@openav/core' ;
import { Mapper } from '@openav/mapping' ;
const world = { name: 'Bloom' , params: [
{ key: 'hue' , min: 0, max: 360, def: 200 },
{ key: 'burst' , pulse: true },
] };
const signals = new Signals();
const params = new Params(world.params);
const mapper = new Mapper({ signals, params });
const host = mountRemoteHost({ signals, params, mapper, world, room: 'demo' }); // not connected yet
document.getElementById('app' ).innerHTML = [
'title: ' + host.layout.title,
'widgets: ' + host.layout.pages[0].widgets.map((w) => w.id + ' (' + w.type + ')' ).join(', ' ),
'routes: ' + host.routes.map((r) => r.source + ' > ' + r.target).join(', ' ),
'in the mapper: ' + mapper.routes.length,
].join('<br>' );
host.dispose();
module 印出 host 用兩個參數建出來的東西:標題是 Bloom、有一支推桿和一顆按鈕的版面、兩條路由,以及映射器現在已經有這兩條。
js 複製
import { mountRemoteHost, mountJoinCard } from '@openav/remote/host' ;
// signals, params, mapper and world as above
const host = mountRemoteHost({ signals, params, mapper, world, room: 'main' }).connect();
mountJoinCard(document.body, host); // the QR code and URL for phones
function frame(dt, state) {
host.frame(dt, state); // echo the params back to the phones
}
static 演出頁面上的一般接法:連線、顯示加入卡片、在畫面迴圈裡呼叫 host.frame()。手機掃了卡片上的 QR code,就能推動推桿。
相關 mountJoinCard() 在演出頁面上放一張小卡片,有 QR code 和手機該打開的網址,還有一行字說明中繼連不連得上、有幾支手機進來。 RelayClient 房間中繼的瀏覽器端:把訊號送給同一個房間裡的其他頁面,也接收它們的訊號,身分可以是手機(controller)、演出本身(runner)或後台監看(monitor)。 bindSignals() 把演出端的 RelayClient 接到 Signals:手機送來的每個訊號都歸進去,並多一份 phone/any/… 的複本。回傳一個復原函式。 autoSurface() 把世界的參數變成控制面板:一份版面、驅動參數的路由,以及把數值回送用的綁定。 routesFromLayout() 替手寫的版面產生映射器路由:每個用 target 指定參數的元件,都會得到一條從它的訊號到那個參數的路由。 feedbackFor() 把算好的參數狀態,換成手機上的元件現在該顯示的正規化數值:[{ name, value }]。 attachRelay() 把房間中繼掛到 Node 的 http.Server 上:連到 path 的 WebSocket 用戶端會被放進房間,並依身分轉送訊息。回傳 { hub, close() }。 mountRemote() 一次呼叫就是整個手機 app:狀態列加上 感測、琴鍵、控制台、MIDI 這幾個分頁,以控制端身分跟中繼溝通。/packages/remote/ 做的就是這件事。 import { mountRemoteHost } from '@openav/remote/host'; packages/remote/host.js 第 41 行
PhoneSensors#
類別 @openav/remote new PhoneSensors(io, hooks = {})
把手機的傾斜、加速度、旋轉、敲擊和前鏡頭的光線,換成有名字的訊號(用螢幕座標軸),交給你給它的 io。
這是 感測 分頁在手機這一半的邏輯,裡面不含頁面:給它一個 io,在使用者點一下時呼叫 start(),訊號就開始流動。它用手機自己的事件產生 tilt/x 和 tilt/y(−1..1,也就是離靜止姿勢 ±45°,x 向右、y 朝向你,用的是螢幕座標軸,所以手機橫著拿也還是「往右傾是 +x」)、orient/alpha|beta|gamma(度)、accel/x|y|z(m/s²,含重力)、rot/alpha|beta|gamma(每秒幾度)、knock(脈衝 { strength, delta, t }),打開鏡頭的話還有 light(0..1)。
敲擊就是猛地一震:兩筆動作樣本之間,加速度向量的變化超過 threshold(預設 10 m/s²),而且距離上次敲擊至少 130 毫秒,就會觸發 io.send('knock', { strength, delta, t }, true)。delta 是變化的大小,strength 從剛過門檻的 0.15 到 1。手機能震動的話也會震 10 毫秒。把手機拿成你想演奏的姿勢,呼叫 calibrate(),就把那個姿勢當成傾斜的零點。
演出頁面本身就跑在手機上的話,用 localSensors() ,它包住這個類別,直接把訊號歸進 Signals。
呼叫之前
要在點擊或觸碰時才呼叫 start(),不是載入頁面時。iOS 只在使用者手勢裡才會問動作權限,而且只把動作感測器開放給 https:// 的頁面,觸控和琴鍵在一般的 http:// 就能用。start() 一律回傳 true,被拒絕要看 denied。2.5 秒內沒有任何感測資料進來的話,hooks.notify 會說明(桌機,或 http 的 iPhone 頁面)。setCamera(true) 在 iOS 上同樣要手勢和 HTTPS。
參數 名稱 型別 說明 io{ set(name, value), send(name, value, pulse?) }訊號要送去哪裡。set 收連續的訊號,send 收敲擊(pulse 為 true)。名稱前面沒有 phone/<id>/,是 tilt/x、accel/z 這樣。後面可以接加了前綴的 RelayClient ,或是 Signals。 hooks{ onKnock?, onSample?, notify? }選用的回呼。onKnock(strength) 在每次敲擊之後呼叫,onSample(name, value) 每個連續樣本都呼叫(拿來畫讀數),notify(message) 收一行問題說明,例如權限被拒絕。訊息是中文寫的。 可省略 預設 {}
成員 名稱 說明 start()method start():非同步。在瀏覽器要求的地方先問動作權限,然後開始聽 deviceorientation 和 devicemotion。就算答案是不行也回傳 true(這時 denied 是 true,聽的事件照樣掛上)。再呼叫一次不會有任何作用。stop()method stop():不再聽事件,也把鏡頭關掉。calibrate()method calibrate():把目前的傾斜當成靜止姿勢。這個姿勢下傾斜讀到 0,直到手機移動。cycleThreshold()method cycleThreshold():把敲擊門檻調高 2 m/s² 並回傳,超過 24 就繞回 4。給「敲擊靈敏度」按鈕用。setCamera(on)method setCamera(on):非同步。true 打開前鏡頭,每秒 10 次發布 light(32 × 24 畫面的平均亮度,0..1)。false 就停掉。完成回傳 true,鏡頭不能用就回傳 false(這時 hooks.notify 會說明原因)。screenTilt(beta, gamma, angle = 0)static method PhoneSensors.screenTilt(beta, gamma, angle?):靜態方法。把兩個原始傾斜角,依螢幕角度 0、90、180 或 270 度換成螢幕座標軸。回傳 { x, y },單位是度。
注意 公開的欄位有 io、hooks、started、gotSensor(收過任何感測資料就是 true)、denied(iOS 說不行,要到「設定」裡改才會變)、rest 和 deg(傾斜,單位度)、threshold(敲擊門檻,10)和 COOLDOWN(130 毫秒)。 範例
js 複製
import { PhoneSensors } from '@openav/remote' ;
const seen = [];
const sensors = new PhoneSensors({
set : (name, value) => seen.push(name + ' = ' + value),
send: (name, value) => seen.push(name + ' ' + JSON.stringify(value)),
});
await sensors.start(); // on a phone: call this from a tap
// the event a phone fires; here a made-up one, tipped 22.5° toward you and 45° to the left
window.dispatchEvent(new DeviceOrientationEvent('deviceorientation' , { alpha: 5, beta: 22.5, gamma: -45 }));
sensors.stop();
document.getElementById('app' ).innerHTML = seen.join('<br>' );
module 印出 tilt/x = -1(向左 45° 就是範圍的盡頭)、tilt/y = 0.5,然後是三個原始的 orient 角度。
js 複製
import { PhoneSensors } from '@openav/remote' ;
const seen = [];
const sensors = new PhoneSensors(
{ set () {}, send: (name, v) => seen.push(name + ': strength ' + v.strength + ', delta ' + v.delta) },
{ onKnock: (s) => seen.push('onKnock(' + s + ')' ) },
);
await sensors.start();
await new Promise((r) => setTimeout(r, 200)); // the 130 ms knock cooldown counts from page load
navigator.vibrate = () => true ; // a knock also buzzes the phone; a stand-in for a desktop page
const motion = (x) => window.dispatchEvent(new DeviceMotionEvent('devicemotion' , {
accelerationIncludingGravity: { x, y: 0, z: 9.8 },
}));
motion(0); // at rest, gravity only
motion(15); // a jolt: the acceleration changed by 15 m/s²
motion(15.5); // too small to count
sensors.stop();
seen.push('threshold now ' + sensors.cycleThreshold());
document.getElementById('app' ).innerHTML = seen.join('<br>' );
module 印出一次敲擊,strength 是 0.35、delta 是 15(還有 onKnock 的呼叫),然後是 cycleThreshold() 之後的新門檻 12。
js 複製
import { PhoneSensors } from '@openav/remote' ;
// the same tilt (beta 10, gamma 20) as the screen turns: x is always "to the right of the screen"
const rows = [0, 90, 180, 270].map((angle) => {
const { x, y } = PhoneSensors.screenTilt(10, 20, angle);
return 'screen angle ' + angle + ': x ' + x + ', y ' + y;
});
document.getElementById('app' ).innerHTML = rows.join('<br>' );
module 印出四個螢幕角度的 x 和 y:(20, 10)、(10, -20)、(-20, -10)、(-10, 20)。
待確認
這一條沒有用真正的 iPhone 或 Android 手機試過:iOS 的權限流程、HTTPS 的要求、2.5 秒的「沒有資料」訊息,都是從程式碼和 docs/remote.md 讀來的。範例是在桌面瀏覽器裡,把真正的 DeviceOrientationEvent 和 DeviceMotionEvent 餵給這個類別。
相關 localSensors() 給跑在手機本身上的演出頁面用:手機自己的感測器直接進到 Signals,名稱是 phone/local/…,另外多一份 phone/any/… 的複本。回傳 PhoneSensors。 attachTouchPad() 把一個元素變成多點觸控板:每根手指發布 touch/x、touch/y 和 touch/down(在元素裡的 0..1),touch/count 是現在按著幾根。 RelayClient 房間中繼的瀏覽器端:把訊號送給同一個房間裡的其他頁面,也接收它們的訊號,身分可以是手機(controller)、演出本身(runner)或後台監看(monitor)。 mountRemote() 一次呼叫就是整個手機 app:狀態列加上 感測、琴鍵、控制台、MIDI 這幾個分頁,以控制端身分跟中繼溝通。/packages/remote/ 做的就是這件事。 signalMeta() 中繼對一個訊號名稱知道什麼:它的範圍和單位,或者它是脈衝。不認得的名稱回傳 null。 import { PhoneSensors } from '@openav/remote'; packages/remote/sensors.js 第 24 行
@openav/surface
autoArrange()#
函式 @openav/surface autoArrange(items, cols, fixed = [])
把元件依序排進格線,一列一列找第一個放得下的位置,絕不重疊,fixed 的位置會保留,並擋住它們的格子。回傳各元件的位置和用掉的列數。
每個項目都從左到右、再從上到下找第一個空位放進去:就是人在排控制器時的做法。格線需要多長就往下長多長,所以 rows 是結果,不是上限。resolvePage() 用它來排沒有位置的元件,也用在自動重排。
參數 名稱 型別 說明 items{ id, w, h }[]要擺的東西,依序排。每一項都要有數字的 w 和 h(單位是格,至少 1)。w 比格線寬的話會被截成 cols。 colsNumber格線有幾欄。 fixed{ id, x, y, w, h }[]已經決定的位置。它們會排在結果的最前面,其他項目繞著它們擺。 可省略 預設 []
回傳 { placements, rows } placements 是 { id, x, y, w, h } 的清單(固定的排前面),rows 是它們佔了幾列。
注意 沒有 w(或 w 不是數字)的項目永遠找不到位置,函式也就永遠不會結束。每個項目都要給 w 和 h。DEFAULT_SIZE 有每種元件類型的預設大小。 範例
js 複製
import { autoArrange } from '@openav/surface/layout' ;
const { placements, rows } = autoArrange([
{ id: 'a' , w: 4, h: 2 }, { id: 'b' , w: 4, h: 2 }, { id: 'c' , w: 8, h: 1 }, { id: 'd' , w: 2, h: 2 },
], 8);
document.getElementById('app' ).innerHTML = placements
.map((p) => p.id + ': x ' + p.x + ', y ' + p.y + ', ' + p.w + '×' + p.h).join('<br>' ) + '<br>rows: ' + rows;
module a 和 b 共用第一列,c 佔滿一整列,d 排在下面:rows 是 5。
js 複製
import { autoArrange } from '@openav/surface/layout' ;
// a button that must stay at the top left; the new knob goes around it
const { placements } = autoArrange(
[{ id: 'knob' , w: 2, h: 1 }],
4,
[{ id: 'go' , x: 0, y: 0, w: 2, h: 1 }],
);
document.getElementById('app' ).textContent = placements.map((p) => p.id + ' at ' + p.x + ',' + p.y).join(' ' );
module 印出「go at 0,0 knob at 2,0」:固定的位置排在最前面,旋鈕拿第一組空的格子。
相關 resolvePage() 算出某一頁在某個方向下每個元件要放哪裡:格線,以及每個元件的格子,還有是哪一條規則排出來的。 stretchRows() 把停在半途的最後一列拉到格線邊緣:四欄裡放五支推桿會剩下一支孤零零的,這個函式把它拉成四欄寬。 DEFAULT_SIZE 每種元件類型在格線上的預設大小,格式是 { w, h }(以格為單位):版面只有在在意的時候才需要寫 x、y、w、h。 import { autoArrange } from '@openav/surface'; 也能從 @openav/surface/layout packages/surface/layout.js 第 95 行
autoSurface()#
函式 @openav/surface autoSurface(params = [], { perPage = 8, style = 'auto' , pairs = [], meters = [], smooth = 0.04, title = '' } = {})
把世界的參數變成控制面板:一份版面、驅動參數的路由,以及把數值回送用的綁定。
一個宣告了「可以拿什麼來演奏」的世界,就這樣免費得到手機面板:const { layout, routes, bindings } = autoSurface(world.params),再 routes.forEach((r) => mapper.addRoute(r)) 和 new Surface(el, { layout, sink })。每個參數變成 widgetTypeFor() 挑的元件。每個 group 各有一頁(沒有 group 的參數放在 main),太長的群組接著放到 main2、main3 …,pairs 裡的一對會變成一塊有兩條路由(…/x 和 …/y)的 XY 板。
元件的 id 就是參數鍵(A–Z a–z 0–9 _ - 以外的字元會變成 _),顏色依序輪流用 cyan、amber、magenta、lime、violet、coral,radio 的 options 來自參數,沒有就用數字。脈衝參數沒有 binding,因為沒有值可以顯示。路由已經把正規化的訊號換算成參數的 min 和 max。自己帶版面的世界,改用 routesFromLayout() 。
參數 名稱 型別 說明 paramsObject[]參數宣告:{ key, label?, min, max, def, step?, pulse?, group?, options?, surface? }。參數上的 surface: { type, color, label } 可以蓋過自動的選擇。 可省略 預設 [] perPageNumber一頁放得下多少,以權重計算:推桿或音量表算 1,XY 板算 4,其他都算 2。預設的 8 就是八支推桿、四個旋鈕或兩塊 XY 板。要捲動的頁面就是失敗的頁面,所以滿了就開下一頁。 可省略 預設 8 style'auto' | 'fader' | 'knob'連續參數用推桿還是旋鈕。'auto' 六個以內用推桿,超過就用旋鈕。 可省略 預設 'auto' pairsstring[][]要用同一塊 XY 板驅動的參數鍵配對:[['cx', 'cy']]。第一個鍵是 x,第二個是 y。 可省略 預設 [] metersstring[]另外要顯示成唯讀音量表的參數鍵,放在一頁叫 Meters 的頁面。 可省略 預設 [] smoothNumber連續路由的平滑秒數,用來遮住 30 Hz 網路造成的階梯感(拉鍊雜訊)。脈衝、開關和單選不會被平滑。 可省略 預設 0.04 titleString版面的標題。 可省略 預設 ''
回傳 { layout, routes, bindings } layout 是給 Surface 用的版面 JSON。routes 是映射器路由的清單 { source, target, inMin, inMax, curve, smooth, outMin, outMax },從 surface/<page>/<id> 到各個參數。bindings 是給 feedbackFor() 用的 { param, name, page, widget, min, max, readonly? }。
範例
js 複製
import { autoSurface } from '@openav/surface/auto' ;
const params = [
{ key: 'hue' , min: 0, max: 360, def: 200 },
{ key: 'size' , min: 1, max: 10, def: 4 },
{ key: 'mode' , min: 0, max: 3, step: 1, def: 1 },
{ key: 'burst' , pulse: true },
];
const { layout, routes } = autoSurface(params, { title: 'Bloom' });
document.getElementById('app' ).innerHTML = [
layout.pages.map((p) => p.id + ': ' + p.widgets.map((w) => w.id + ' ' + w.type).join(', ' )).join('<br>' ),
routes.map((r) => r.source + ' → ' + r.target + (r.smooth ? ' (smooth ' + r.smooth + ')' : '' )).join('<br>' ),
].join('<br><br>' );
module 印出一頁 main,有 hue 和 size 的推桿、mode 的單選、burst 的按鈕,然後是四條路由,除了單選和按鈕之外都平滑 0.04 秒。
js 複製
import { autoSurface } from '@openav/surface/auto' ;
const params = [
{ key: 'cx' , min: 0, max: 1, def: 0.5 }, { key: 'cy' , min: 0, max: 1, def: 0.5 },
{ key: 'density' , min: 0, max: 100, def: 20 },
{ key: 'glow' , min: 0, max: 1, def: 0.3, group: 'look' },
];
const { layout, bindings } = autoSurface(params, { pairs: [['cx' , 'cy' ]], meters: ['density' ] });
document.getElementById('app' ).innerHTML = [
layout.pages.map((p) => p.id + ' (' + p.title + '): ' + p.widgets.map((w) => w.id + ' ' + w.type).join(', ' )).join('<br>' ),
'bindings: ' + bindings.map((b) => b.param + (b.readonly ? ' (read-only)' : '' )).join(', ' ),
].join('<br><br>' );
module cx 和 cy 共用一塊 XY 板,density 是推桿、同時也是自己那一頁上的唯讀音量表,group 是 look 的 glow 另外有一頁。
js 複製
import { autoSurface } from '@openav/surface/auto' ;
// twenty params do not fit on one page: more than six continuous ones become knobs, four to a page
const params = Array.from ({ length: 20 }, (_, i) => ({ key: 'p' + i, min: 0, max: 1, def: 0 }));
const { layout } = autoSurface(params);
document.getElementById('app' ).textContent = layout.pages
.map((p) => p.id + ': ' + p.widgets.length + ' ' + p.widgets[0].type + 's' ).join(' ' );
module 印出「main1: 4 knobs main2: 4 knobs …」一直到 main5:頁面上限把旋鈕算成 2,所以預設的 8 放得下四個。
import { autoSurface } from '@openav/surface'; 也能從 @openav/surface/auto packages/surface/auto.js 第 54 行
canFullscreen()#
函式 @openav/surface canFullscreen()
頁面可以要求全螢幕的時候是 true:瀏覽器有 requestFullscreen(或 WebKit 版的)。程式碼把 iPhone 的 Safari 當成沒有。
用來決定要不要顯示全螢幕按鈕:if (canFullscreen()) button.hidden = false。它只檢查那個函式存不存在。要求會不會成功由瀏覽器決定,瀏覽器要求它必須來自一次點擊(toggleFullscreen() )。
回傳 Boolean true 或 false。沒有 document 的話是 false。
範例
js 複製
import { canFullscreen } from '@openav/surface' ;
document.getElementById('app' ).textContent = 'this browser can ask for fullscreen: ' + canFullscreen();
module 在桌面的 Chrome、Firefox、Safari 和 Android 的 Chrome 上印出「true」。
待確認
沒有在 iPhone 或 iPad 上檢查過:iPhone 的 Safari 沒有全螢幕函式,這是程式碼自己的註解說的,不是在這裡量到的。
import { canFullscreen } from '@openav/surface'; packages/surface/platform.js 第 35 行
COLOR_NAMES#
常數 @openav/surface COLOR_NAMES = array, 7 items
元件的 color 可以用的七個顏色名稱:cyan、amber、magenta、lime、violet、coral、white。
{ id: 'hue', type: 'knob', color: 'amber' } 會讓元件的填色、數值讀數和按下時的描邊都變成琥珀色。沒有顏色的元件用 THEME_VARS 的 --oav-accent。autoSurface() 會輪流發前六個(white 留給你用)。不在清單裡的名稱沒有自己的顏色,會退回強調色。validateLayout() 不檢查它。
型別 string[]七個字串:['cyan', 'amber', 'magenta', 'lime', 'violet', 'coral', 'white']。
範例
js 複製
import { COLOR_NAMES } from '@openav/surface' ;
document.getElementById('app' ).textContent = COLOR_NAMES.join(', ' );
module 印出 cyan、amber、magenta、lime、violet、coral、white。
相關 THEME_VARS 每個控制元件的外觀,收成 :root 上的 15 個 CSS 自訂屬性(--oav-bg、--oav-accent …):覆寫它們就能替整個面板換皮膚。 autoSurface() 把世界的參數變成控制面板:一份版面、驅動參數的路由,以及把數值回送用的綁定。 WIDGET_TYPES 版面可以用的 14 種元件類型名稱:fader、knob、encoder、button、toggle、xy、bank、radio、pads、keyboard、number、text、label、meter。 import { COLOR_NAMES } from '@openav/surface'; packages/surface/theme.js 第 30 行
DEFAULT_SIZE#
常數 @openav/surface DEFAULT_SIZE = object, 14 keys
每種元件類型在格線上的預設大小,格式是 { w, h }(以格為單位):版面只有在在意的時候才需要寫 x、y、w、h。
沒寫 w 或 h 的元件,會用它的類型的大小,並限制在格線的欄數以內。autoArrange() 和 resolvePage() 會用到它,autoSurface() 計算一頁放得下幾個元件的時候也是。不認得的類型當作 2×2。
型別 Object十四個鍵,每種類型一個:fader 1×4、knob 2×2、encoder 2×2、button 2×1、toggle 2×1、xy 4×4、bank 4×4、radio 4×1、pads 4×4、keyboard 8×4、number 2×1、text 4×1、label 2×1、meter 1×4(寬 × 高)。
範例
js 複製
import { DEFAULT_SIZE } from '@openav/surface' ;
const { w, h } = DEFAULT_SIZE.knob;
document.getElementById('app' ).textContent = 'a knob is ' + w + ' cells wide and ' + h + ' tall; a fader is ' +
DEFAULT_SIZE.fader.w + ' by ' + DEFAULT_SIZE.fader.h;
module 印出「a knob is 2 cells wide and 2 tall; a fader is 1 by 4」。
相關 WIDGET_TYPES 版面可以用的 14 種元件類型名稱:fader、knob、encoder、button、toggle、xy、bank、radio、pads、keyboard、number、text、label、meter。 autoArrange() 把元件依序排進格線,一列一列找第一個放得下的位置,絕不重疊,fixed 的位置會保留,並擋住它們的格子。回傳各元件的位置和用掉的列數。 resolvePage() 算出某一頁在某個方向下每個元件要放哪裡:格線,以及每個元件的格子,還有是哪一條規則排出來的。 import { DEFAULT_SIZE } from '@openav/surface'; 也能從 @openav/surface/layout packages/surface/layout.js 第 31 行
feedbackFor()#
函式 @openav/surface feedbackFor(state, bindings)
把算好的參數狀態,換成手機上的元件現在該顯示的正規化數值:[{ name, value }]。
它是純函式、成本很低,所以演出端大約每秒呼叫十次,並用 relay.feedback(name, value) 把每一項送給手機,mountRemoteHost() 的 host.frame() 做的就是這件事。在手機上,Surface 的 feedback() 會移動元件,所以推桿會跟著時間軸和其他控制器動。
參數 名稱 型別 說明 stateObject算好的參數值,例如 params.resolve(…) 的結果:{ hue: 90, size: 5.5 }。 bindingsObject[]autoSurface() 回傳的 bindings。
回傳 { name, value }[] state 裡有數字的每個 binding 各一項:name 是元件的訊號,value 是縮放到 0..1 並限制在範圍內的參數值。缺少、不是數字、或 min 等於 max 的參數會跳過。
範例
js 複製
import { autoSurface, feedbackFor } from '@openav/surface/auto' ;
const params = [{ key: 'hue' , min: 0, max: 360, def: 200 }, { key: 'size' , min: 1, max: 10, def: 4 }];
const { bindings } = autoSurface(params);
const echo = feedbackFor({ hue: 90, size: 5.5 }, bindings);
document.getElementById('app' ).innerHTML = echo.map((f) => f.name + ' = ' + f.value).join('<br>' );
module 印出 surface/main/hue = 0.25(0 到 360 裡的 90)和 surface/main/size = 0.5(1 到 10 裡的 5.5)。
相關 autoSurface() 把世界的參數變成控制面板:一份版面、驅動參數的路由,以及把數值回送用的綁定。 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 mountRemoteHost() 手機遙控在演出這一邊:以演出端(runner)身分加入中繼,歸檔手機訊號,依世界的參數建立控制面板,並把數值回送。回傳 host。 RelayClient 房間中繼的瀏覽器端:把訊號送給同一個房間裡的其他頁面,也接收它們的訊號,身分可以是手機(controller)、演出本身(runner)或後台監看(monitor)。 import { feedbackFor } from '@openav/surface'; 也能從 @openav/surface/auto packages/surface/auto.js 第 141 行
gridOrientation()#
函式 @openav/surface gridOrientation(g)
判斷格線 { cols, rows } 是 'landscape'(欄數不少於列數)還是 'portrait'。
resolvePage() 會拿它跟螢幕的方向比:8 乘 4 的基本格線在橫向螢幕上直接用,在直向螢幕上就重排。
回傳 'landscape' | 'portrait' g.cols >= g.rows 是 'landscape',否則是 'portrait'。
範例
js 複製
import { gridOrientation } from '@openav/surface/layout' ;
document.getElementById('app' ).innerHTML = [{ cols: 8, rows: 4 }, { cols: 4, rows: 8 }, { cols: 4, rows: 4 }]
.map((g) => g.cols + ' x ' + g.rows + ': ' + gridOrientation(g)).join('<br>' );
module 印出 8 x 4 是 landscape、4 x 8 是 portrait、4 x 4 是 landscape。
import { gridOrientation } from '@openav/surface/layout'; packages/surface/layout.js 第 41 行
haptic()#
函式 @openav/surface haptic(ms, enabled = true )
瀏覽器做得到的地方(Android 的 Chrome)讓手機震動 ms 毫秒,其他地方什麼都不會發生,enabled 為 false 就關掉。
它在 navigator.vibrate(ms) 存在的時候呼叫它,並吞掉任何錯誤。沒有它的瀏覽器,例如 iPhone 上的 Safari,就單純什麼都不做,而 Chrome 要等使用者碰過頁面之後才會震動。Surface 會在按鈕、開關和單選時呼叫它,它的 haptics: false 選項可以把這個關掉。
參數 名稱 型別 說明 msNumber震動多久,單位毫秒。面板裡按一下按鈕用的是 10 到 14。 enabledBoolean傳 false 就什麼都不做:頁面可以提供一個「觸覺回饋」開關,把它的值交給這裡。 可省略 預設 true
範例
js 複製
import { haptic } from '@openav/surface' ;
document.querySelector('button' ).addEventListener('pointerdown' , () => haptic(20)); // a short buzz on every press
static 在 Android 手機上,按一下按鈕就震 20 毫秒,其他地方什麼都不會發生。
js 複製
import { haptic } from '@openav/surface' ;
const log = [];
navigator.vibrate = (ms) => { log.push('vibrate ' + ms + ' ms' ); return true ; }; // a stand-in, so there is something to see
haptic(20);
haptic(12, false ); // switched off: nothing is called
haptic(30, true );
document.getElementById('app' ).innerHTML = log.join('<br>' );
module 印出「vibrate 20 ms」和「vibrate 30 ms」:enabled 為 false 的那一次沒有震動。
待確認
這一條沒有在真正的 Android 手機上感受過。範例用了一個代替 navigator.vibrate 的替身,來示範什麼時候會被呼叫。
相關 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 keepAwake() 請瀏覽器讓螢幕保持亮著(螢幕喚醒鎖),分頁回來時再要求一次。回傳 { release() }。 lockViewport() 讓頁面像儀器面板,而不是網頁:不能捲動、不能雙指縮放、不會有橡皮筋回彈。回傳一個函式,可以復原其中大部分。 import { haptic } from '@openav/surface'; packages/surface/platform.js 第 43 行
injectTheme()#
函式 @openav/surface injectTheme()
把面板的樣式表寫進頁面,只寫一次::root 上的 15 個主題變數,以及每個元件的樣式規則。重複呼叫也沒問題。
Surface 和 mountRemote() 會替你呼叫它,所以只有自己建立 元件 的時候,或想在自己的元素上用這些變數(var(--oav-accent))的時候才需要它。它會在 head 裡加一個 <style id="openav-surface-css">,這個已經存在、或根本沒有 document 的話,就什麼都不做。要改外觀,在那之後設變數:document.documentElement.style.setProperty('--oav-accent', '#ff5fb3')。
範例
js 複製
import { injectTheme } from '@openav/surface' ;
const read = () => getComputedStyle(document.documentElement).getPropertyValue('--oav-accent' ).trim() || '(not set)' ;
const before = read();
injectTheme();
injectTheme(); // the second call does nothing
document.getElementById('app' ).innerHTML = [
'accent before: ' + before,
'accent after: ' + read(),
'style tags: ' + document.querySelectorAll('#openav-surface-css' ).length,
].join('<br>' );
module 印出之前是「(not set)」、之後是 #7ea6ff,而且就算呼叫了兩次,style 標籤也只有一個。
js 複製
import { Surface, injectTheme } from '@openav/surface' ;
injectTheme();
document.documentElement.style.setProperty('--oav-accent' , '#ff5fb3' ); // re-skin: pink instead of blue
const box = document.getElementById('app' );
box.style.cssText = 'position:relative;width:260px;height:160px' ;
new Surface(box, {
layout: { pages: [{ id: 'main' , grid: { cols: 3, rows: 2 }, widgets: [
{ id: 'a' , type: 'fader' , x: 0, y: 0, w: 1, h: 2, def: 0.7 }, { id: 'b' , type: 'knob' , x: 1, y: 0, w: 2, h: 2, def: 0.4 },
] }] },
sink() {},
});
module 一支推桿和一個旋鈕畫成粉紅色:它們沒有自己的顏色,所以用了被覆寫過的強調色。
相關 THEME_VARS 每個控制元件的外觀,收成 :root 上的 15 個 CSS 自訂屬性(--oav-bg、--oav-accent …):覆寫它們就能替整個面板換皮膚。 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 WIDGET_CLASSES Surface 依版面建立的元件類別,每種類型一個:WIDGET_CLASSES.fader、.knob、.xy 等等(進階)。mountRemote() 一次呼叫就是整個手機 app:狀態列加上 感測、琴鍵、控制台、MIDI 這幾個分頁,以控制端身分跟中繼溝通。/packages/remote/ 做的就是這件事。 import { injectTheme } from '@openav/surface'; packages/surface/theme.js 第 164 行
keepAwake()#
函式 @openav/surface keepAwake()
請瀏覽器讓螢幕保持亮著(螢幕喚醒鎖),分頁回來時再要求一次。回傳 { release() }。
當控制器用的手機,一分鐘內就會暗掉、鎖定,除非有東西讓螢幕保持亮著。如果頁面看得到,keepAwake() 馬上要求 navigator.wakeLock.request('screen')。頁面一被藏起來,作業系統就會放掉這把鎖,所以每次頁面再變成可見,它都會再要求一次。它是盡力而為、而且不出聲:瀏覽器沒有喚醒鎖、或回答不行的話,就只是螢幕可能還是會暗掉,它絕不會丟出錯誤。
呼叫之前
頁面要看得到,而且在 https 或 localhost 底下(喚醒鎖只存在於安全的頁面)。mountRemote() 會替你呼叫它。
回傳 { release } release() 停止再要求,並放掉喚醒鎖。
範例
js 複製
import { keepAwake } from '@openav/surface' ;
const awake = keepAwake(); // the screen stays on while this page is visible
// when the show is over:
// awake.release();
static 在手機或筆電上,頁面開著而且看得到的時候,螢幕不會再暗掉。
js 複製
import { keepAwake } from '@openav/surface' ;
const log = [];
// a stand-in for the browser's wake lock, so there is something to see on any machine
Object.defineProperty(navigator, 'wakeLock' , { configurable: true , value: {
request: async (type) => { log.push('request ' + type); return { release() { log.push('lock released' ); } }; },
} });
const awake = keepAwake();
await new Promise((r) => setTimeout(r, 50));
awake.release();
log.push('release() called' );
document.getElementById('app' ).innerHTML = log.join('<br>' );
module 印出「request screen」,然後是「lock released」和「release() called」:頁面看得到時要求一次,release() 把鎖放掉。
待確認
沒有在真正的 iPhone 或 iPad 上試過:Safari 可能會拒絕不在點擊裡發出的喚醒鎖要求,這時螢幕還是會暗掉。第二個範例只示範呼叫的順序,用了一個代替 navigator.wakeLock 的替身。
相關 lockViewport() 讓頁面像儀器面板,而不是網頁:不能捲動、不能雙指縮放、不會有橡皮筋回彈。回傳一個函式,可以復原其中大部分。 mountRemote() 一次呼叫就是整個手機 app:狀態列加上 感測、琴鍵、控制台、MIDI 這幾個分頁,以控制端身分跟中繼溝通。/packages/remote/ 做的就是這件事。 import { keepAwake } from '@openav/surface'; packages/surface/platform.js 第 25 行
lockViewport()#
函式 @openav/surface lockViewport()
讓頁面像儀器面板,而不是網頁:不能捲動、不能雙指縮放、不會有橡皮筋回彈。回傳一個函式,可以復原其中大部分。
它把 <meta name="viewport"> 設成 width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover(沒有這個標籤就先做一個),並加一段樣式,讓 html 和 body 沒有邊界、高度填滿、overflow: hidden、overscroll-behavior: none、touch-action: none。iOS 會忽略 user-scalable=no,所以它也取消 gesturestart 和 gesturechange(雙指縮放),以及每個 touchmove 的預設行為,只有從 input 或 textarea 開始的例外,所以文字框還是能用。
這是給整個頁面就是控制面板的情況用的,例如 mountRemote() 就會呼叫它。要捲動的頁面不要呼叫它。沒有 document(在 Node 裡)的話,它回傳一個什麼都不做的函式。
回傳 Function 呼叫它會移除樣式表和監聽。viewport 的 <meta> 維持設好的樣子,不會還原。
範例
js 複製
import { lockViewport } from '@openav/surface' ;
const unlock = lockViewport();
const meta = document.querySelector('meta[name=viewport]' );
const locked = getComputedStyle(document.body).overflow;
unlock();
document.getElementById('app' ).innerHTML = [
'viewport: ' + meta.content,
'body overflow while locked: ' + locked,
'body overflow after unlock: ' + getComputedStyle(document.body).overflow,
].join('<br>' );
module 印出 viewport 字串、鎖定時的「hidden」、unlock() 之後的「visible」:樣式表消失了,meta 標籤還在。
相關 keepAwake() 請瀏覽器讓螢幕保持亮著(螢幕喚醒鎖),分頁回來時再要求一次。回傳 { release() }。 mountRemote() 一次呼叫就是整個手機 app:狀態列加上 感測、琴鍵、控制台、MIDI 這幾個分頁,以控制端身分跟中繼溝通。/packages/remote/ 做的就是這件事。 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 import { lockViewport } from '@openav/surface'; packages/surface/platform.js 第 7 行
normalizeLayout()#
函式 @openav/surface normalizeLayout(raw)
回傳一份補上預設值的版面複本:id、標題、格線和版本。原本的輸入不會被改動。
沒有 id 的頁面會變成 page1、page2 …,沒有 title 就用它的 id(或 Page 1),沒有 grid 就是 { cols: 8, rows: 6 }。沒有 id 的元件會變成「類型加在清單中的位置」,像 fader1。在同一頁已經用過的 id 會一直加上 _ 直到不重複。其他欄位原樣保留。Surface 會把拿到的版面正規化,signalNames() 和遙控的演出端也一樣。
參數 名稱 型別 說明 rawObject版面 JSON。undefined 或沒有 pages 的物件,會得到沒有任何頁面的版面。
回傳 Object { version: 1, title, pages },每個頁面和元件的欄位都補齊了。
範例
js 複製
import { normalizeLayout } from '@openav/surface' ;
const layout = normalizeLayout({ pages: [{ widgets: [{ type: 'fader' }, { type: 'fader' , id: 'fader1' }, { type: 'knob' }] }] });
const page = layout.pages[0];
document.getElementById('app' ).innerHTML = [
'page: ' + page.id + ' "' + page.title + '", grid ' + page.grid.cols + 'x' + page.grid.rows,
'widgets: ' + page.widgets.map((w) => w.id).join(', ' ),
].join('<br>' );
module 印出「page: page1 "Page 1", grid 8x6」和元件 id:fader1、fader1_(第二個 fader1 已經被用了)、knob3。
import { normalizeLayout } from '@openav/surface'; 也能從 @openav/surface/layout packages/surface/layout.js 第 70 行
orientationOf()#
函式 @openav/surface orientationOf(w, h)
從寬和高判斷一個框是 'landscape'(橫向)還是 'portrait'(直向),正方形算橫向。
Surface 傳給它的是容器的大小,不是螢幕的大小,所以放在半寬窗格裡的面板可以是直向,而手機本身是橫向。要判斷格線的形狀,用 gridOrientation() 。
參數 名稱 型別 說明 wNumber寬,什麼單位都可以。 hNumber高,單位跟寬一樣。
回傳 'landscape' | 'portrait' w >= h 是 'landscape',否則是 'portrait'。
範例
js 複製
import { orientationOf } from '@openav/surface/layout' ;
document.getElementById('app' ).innerHTML = [[844, 390], [390, 844], [500, 500]]
.map(([w, h]) => w + ' x ' + h + ': ' + orientationOf(w, h)).join('<br>' );
module 印出 844 x 390 是 landscape、390 x 844 是 portrait,正方形是 landscape。
相關 gridOrientation() 判斷格線 { cols, rows } 是 'landscape'(欄數不少於列數)還是 'portrait'。 resolvePage() 算出某一頁在某個方向下每個元件要放哪裡:格線,以及每個元件的格子,還有是哪一條規則排出來的。 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 import { orientationOf } from '@openav/surface/layout'; packages/surface/layout.js 第 40 行
planKeyboard()#
函式 @openav/surface planKeyboard({ width, height, base = 48, minKeyPx = 38, maxWhites = 22, stackBelow = 2.2, forceRows = 0 })
決定一個框裡放得下多少琴鍵、排成幾列:框很寬就一排,框很高就上下兩排,讓琴鍵維持好彈的大小。
一台鋼琴有兩公尺半寬,直拿的手機只有六公分,把琴鍵縮小只會變成玩具。所以改成讓鍵盤隨著框的形狀變形。很寬的框得到一排,放得下幾個八度就放幾個(白鍵不窄於 minKeyPx)。很高的框得到兩排,上排接著下排停下來的地方往高音延伸,拇指搆得到。每一排都結束在 E 或 B,下一排從下一個白鍵開始,所以接縫處不會留下孤單的黑鍵。音符最高到 127。Surface 的 keyboard 元件在框的大小改變時,每次都會呼叫它。
參數 名稱 型別 說明 widthNumber框的寬,單位像素。 可省略 heightNumber框的高,單位像素。 可省略 baseNumber最低音那個鍵的 MIDI 音符號碼。48 是 C3(C4 是 60)。 可省略 預設 48 minKeyPxNumber白鍵最窄可以多寬,單位像素。至少會保留 3 個白鍵。 可省略 預設 38 maxWhitesNumber一排最多幾個白鍵。上下兩排的話,每排最多 14 個。 可省略 預設 22 stackBelowNumber寬 ÷ 高低於這個值就改成上下兩排。 可省略 預設 2.2 forceRows1 | 21 或 2 自己決定,0 由框的形狀決定。 可省略 預設 0
回傳 { stacked, whites, keyPx, rows } stacked 為 true 就是兩排,whites 是每排的白鍵數,keyPx 是白鍵的寬度。rows 是 { base, semitones, whites } 的清單,上面那一排在最前面 (音比較高),就像兩層鍵盤的讀法:base 是第一個鍵的 MIDI 音符號碼,semitones 是那一排涵蓋幾個半音。
範例
js 複製
import { planKeyboard } from '@openav/surface' ;
const show = (label, plan) => label + ': ' + (plan.stacked ? 'two rows' : 'one row' ) + ', ' + plan.whites + ' white keys of ' +
Math.round(plan.keyPx) + ' px, ' + plan.rows.map((r) => 'notes ' + r.base + '–' + (r.base + r.semitones - 1)).join(' / ' );
document.getElementById('app' ).innerHTML = [
show('phone on its side (844 x 330)' , planKeyboard({ width: 844, height: 330 })),
show('phone upright (390 x 640)' , planKeyboard({ width: 390, height: 640 })),
].join('<br>' );
module 寬的框印出一排 21 個白鍵(音符 48 到 83),高的框印出兩排各 10 個(上排 65 到 81、下排 48 到 64)。
相關 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 WIDGET_TYPES 版面可以用的 14 種元件類型名稱:fader、knob、encoder、button、toggle、xy、bank、radio、pads、keyboard、number、text、label、meter。 import { planKeyboard } from '@openav/surface'; packages/surface/kbplan.js 第 41 行
resolvePage()#
函式 @openav/surface resolvePage(page, orientation)
算出某一頁在某個方向下每個元件要放哪裡:格線,以及每個元件的格子,還有是哪一條規則排出來的。
手機會被轉來轉去,所以版面一定要撐得住。規則依序是:1. 頁面針對這個方向有 portrait 或 landscape 的項目,就用它的 grid 和 place({ 元件 id: { x, y, w, h } })。它沒提到的元件保留自己的位置,或被打包進空的格子(source 是 'explicit')。2. 否則,基本格線的形狀對(欄數多於列數是橫向)、而且每個元件都有 x 和 y,就用基本格線('base')。3. 否則重排:同樣的元件依閱讀順序,重新打包進一半的欄數(直向,至少 2 欄)或兩倍的欄數(橫向,最多 16 欄),最後孤零零的一列由 stretchRows() 拉到邊緣('reflow')。
列數是個數,不是像素,所以結果永遠填滿螢幕,不會捲動。沒有 w 和 h 的元件,會用 DEFAULT_SIZE 裡它的類型的大小。每次方向改變,Surface 都會對每一頁呼叫它。
參數 名稱 型別 說明 pageObject版面中的一頁:{ grid, widgets, portrait?, landscape? }。 orientation'portrait' | 'landscape'螢幕怎麼拿。
回傳 { grid, placements, source } grid 是 { cols, rows },placements 是 { id, x, y, w, h } 的清單,source 是 'explicit'、'base' 或 'reflow'。
範例
js 複製
import { resolvePage } from '@openav/surface/layout' ;
// eight faders in a row: right for a landscape screen, too wide for a portrait one
const page = {
grid: { cols: 8, rows: 4 },
widgets: Array.from ({ length: 8 }, (_, i) => ({ id: 'f' + i, type: 'fader' , x: i, y: 0, w: 1, h: 4 })),
};
const out = ['landscape' , 'portrait' ].map((o) => {
const r = resolvePage(page, o);
return o + ': ' + r.source + ', ' + r.grid.cols + ' columns x ' + r.grid.rows + ' rows' ;
});
document.getElementById('app' ).innerHTML = out.join('<br>' );
module 印出「landscape: base, 8 columns x 4 rows」和「portrait: reflow, 4 columns x 8 rows」:轉成直的之後,八支推桿變成兩列、每列四支。
js 複製
import { resolvePage } from '@openav/surface/layout' ;
// a page that says where things go when the phone is upright
const page = {
grid: { cols: 8, rows: 4 },
widgets: [
{ id: 'a' , type: 'fader' , x: 0, y: 0, w: 1, h: 4 },
{ id: 'b' , type: 'knob' , x: 1, y: 0 },
{ id: 'c' , type: 'button' },
],
portrait: { grid: { cols: 4, rows: 6 }, place: { a: { x: 3, y: 0, w: 1, h: 6 } } },
};
const r = resolvePage(page, 'portrait' );
document.getElementById('app' ).innerHTML = [r.source, ...r.placements.map((p) => p.id + ': ' + p.x + ',' + p.y + ' ' + p.w + '×' + p.h)].join('<br>' );
module 印出「explicit」:a 照 portrait 項目寫的位置放,旋鈕和按鈕(沒有位置)填進空的格子。
import { resolvePage } from '@openav/surface'; 也能從 @openav/surface/layout packages/surface/layout.js 第 116 行
routesFromLayout()#
函式 @openav/surface routesFromLayout(layout, params = [], { smooth = 0.04 } = {})
替手寫的版面產生映射器路由:每個用 target 指定參數的元件,都會得到一條從它的訊號到那個參數的路由。
給元件一個 target,光靠版面檔案就能演奏一個世界。target 是參數鍵,訊號不只一個的元件可以給陣列:xy 把 x 送給第一個、y 送給第二個,bank 把第 i 個頻道送給第 i 個。其他元件都用第一個(或唯一的)target。路由已經把 0..1 的訊號換算成參數的 min 和 max。mountRemoteHost() 遇到來自 world.surface 或 surface 選項的版面,就是用它。
參數 名稱 型別 說明 layoutObject版面 JSON,裡面的元件可以有 target。 paramsObject[]參數宣告。target 不在裡面的話,會不吭一聲地跳過。 可省略 預設 [] smoothNumber連續路由的平滑秒數。元件自己的 smooth 優先。脈衝參數,以及 button、toggle、radio 元件不會被平滑。 可省略 預設 0.04
回傳 Object[] 格式跟 autoSurface() 做出來的一樣的路由:{ source, target, inMin, inMax, curve, smooth, outMin, outMax },curve 取自元件(沒有就是 linear)。
範例
js 複製
import { routesFromLayout } from '@openav/surface/auto' ;
const params = [
{ key: 'size' , min: 1, max: 10 }, { key: 'cx' , min: 0, max: 1 }, { key: 'cy' , min: 0, max: 1 }, { key: 'burst' , pulse: true },
];
const layout = { pages: [{ id: 'p' , widgets: [
{ id: 'size' , type: 'fader' , target: 'size' },
{ id: 'pos' , type: 'xy' , target: ['cx' , 'cy' ] },
{ id: 'go' , type: 'button' , target: 'burst' },
{ id: 'note' , type: 'label' },
] }] };
document.getElementById('app' ).innerHTML = routesFromLayout(layout, params)
.map((r) => r.source + ' → ' + r.target).join('<br>' );
module 印出 surface/p/size → size、surface/p/pos/x → cx、surface/p/pos/y → cy、surface/p/go → burst:label 沒有 target,所以沒有路由。
相關 autoSurface() 把世界的參數變成控制面板:一份版面、驅動參數的路由,以及把數值回送用的綁定。 signalNames() 列出一份版面能發布的每一個訊號名稱,讓映射畫面、文件或測試不用碰面板就能列出它們。 mountRemoteHost() 手機遙控在演出這一邊:以演出端(runner)身分加入中繼,歸檔手機訊號,依世界的參數建立控制面板,並把數值回送。回傳 host。 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 import { routesFromLayout } from '@openav/surface'; 也能從 @openav/surface/auto packages/surface/auto.js 第 156 行
signalNames()#
函式 @openav/surface signalNames(layout)
列出一份版面能發布的每一個訊號名稱,讓映射畫面、文件或測試不用碰面板就能列出它們。
它依照 WIDGET_TYPES 裡的表:XY 板給 /x、/y、/down,bank 給 /1 到 /count,pads 給 /hit 和 /0 到 /<列數 × 欄數 − 1>,encoder 給它自己的名稱和 /delta,keyboard 給 midi/note/on、midi/note/off、midi/cc/64 和 midi/virtual,label 和 meter 什麼都不給。有範圍的元件另外送的 …/raw 名稱不在清單裡。
參數 名稱 型別 說明 layoutObject版面 JSON(會先正規化)。
回傳 string[] 不重複的名稱,像 surface/main/hue、surface/main/pad/x、midi/note/on,依頁面和元件的順序。
範例
js 複製
import { signalNames } from '@openav/surface/layout' ;
const names = signalNames({ pages: [{ id: 'main' , widgets: [
{ id: 'hue' , type: 'knob' },
{ id: 'pos' , type: 'xy' },
{ id: 'mix' , type: 'bank' , count: 2 },
{ id: 'title' , type: 'label' },
] }] });
document.getElementById('app' ).innerHTML = names.join('<br>' );
module 印出 surface/main/hue,然後是 pos/x、pos/y、pos/down,再來是 mix/1 和 mix/2:label 什麼都不給。
相關 WIDGET_TYPES 版面可以用的 14 種元件類型名稱:fader、knob、encoder、button、toggle、xy、bank、radio、pads、keyboard、number、text、label、meter。 normalizeLayout() 回傳一份補上預設值的版面複本:id、標題、格線和版本。原本的輸入不會被改動。 routesFromLayout() 替手寫的版面產生映射器路由:每個用 target 指定參數的元件,都會得到一條從它的訊號到那個參數的路由。 import { signalNames } from '@openav/surface'; 也能從 @openav/surface/layout packages/surface/layout.js 第 172 行
signalSink()#
函式 @openav/surface signalSink(signals)
做一個給 Surface 用的 sink,直接寫進 Signals:給跟演出在同一頁上的面板用,不需要手機,也不需要中繼。
還沒見過的名稱會先宣告:info.pulse 有設的話是 { kind: 'pulse' },不然是 { min: 0, max: 1 }。然後脈衝用 signals.pulse() 觸發,其他都是 signals.set()。所以映射器裡的路由 { source: 'surface/main/hue', target: 'hue' },不論手指在這個頁面上還是在手機上,都能運作。
它不做任何縮放:有範圍的元件送出的 …/raw 訊號,會跟其他名稱一樣歸檔,宣告的範圍是 0..1,所以請忽略它們,或自己另外宣告。
參數 名稱 型別 說明 signalsSignals要寫進去的註冊表(來自 @openav/core)。
回傳 Function 一個 (name, value, info) => {},傳給 Surface 的 sink。
範例
js 複製
import { signalSink } from '@openav/surface' ;
import { Signals } from '@openav/core' ;
const signals = new Signals();
const sink = signalSink(signals);
sink('surface/main/level' , 0.4, {}); // a continuous value
sink('surface/main/go' , 1, { pulse: true }); // an event
document.getElementById('app' ).innerHTML = [
'level = ' + signals.get ('surface/main/level' ) + ', kind ' + signals.meta.get ('surface/main/level' ).kind,
'go = ' + signals.get ('surface/main/go' ) + ', kind ' + signals.meta.get ('surface/main/go' ).kind,
].join('<br>' );
module 印出「level = 0.4, kind continuous」和「go = 1, kind pulse」:sink 第一次看到每個名稱時就替它宣告了。
相關 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 bindSignals() 把演出端的 RelayClient 接到 Signals:手機送來的每個訊號都歸進去,並多一份 phone/any/… 的複本。回傳一個復原函式。 fileSignal() 把一個收到的訊號歸進 Signals:第一次聽到名稱就先宣告,補上力度,再設值或觸發脈衝。 import { signalSink } from '@openav/surface'; packages/surface/index.js 第 34 行
stretchRows()#
函式 @openav/surface stretchRows(placements, cols)
把停在半途的最後一列拉到格線邊緣:四欄裡放五支推桿會剩下一支孤零零的,這個函式把它拉成四欄寬。
只有安全的時候才會拉長一列:它的元件高度都一樣、從左邊緣開始一個接一個沒有空隙、右邊也沒有別的東西。多出來的欄數從左到右一欄一欄分下去,所以四欄裡只有三個元件的短列,第一個元件會拿到兩欄。不符合條件的列就不動,也絕不會重疊。resolvePage() 在重排的最後會呼叫它。
參數 名稱 型別 說明 placements{ id, x, y, w, h }[]位置清單,例如來自 autoArrange() 。不會被改動,回傳的是複本。 colsNumber格線有幾欄。
範例
js 複製
import { autoArrange, stretchRows } from '@openav/surface/layout' ;
const five = autoArrange(Array.from ({ length: 5 }, (_, i) => ({ id: 'f' + i, w: 1, h: 4 })), 4).placements;
const stretched = stretchRows(five, 4);
const show = (list) => list.map((p) => p.id + ' w' + p.w).join(', ' );
document.getElementById('app' ).innerHTML = 'before: ' + show(five) + '<br>after: ' + show(stretched);
module 印出 f0 到 f3 的寬度前後都是 1,而 f4 的寬度從 1 變成 4。
相關 autoArrange() 把元件依序排進格線,一列一列找第一個放得下的位置,絕不重疊,fixed 的位置會保留,並擋住它們的格子。回傳各元件的位置和用掉的列數。 resolvePage() 算出某一頁在某個方向下每個元件要放哪裡:格線,以及每個元件的格子,還有是哪一條規則排出來的。 import { stretchRows } from '@openav/surface/layout'; packages/surface/layout.js 第 154 行
Surface#
類別 @openav/surface new Surface(container, { layout, sink, haptics = true , tabs = true , orientation = null , onPage = null } = {})
像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。
呼叫 new Surface(container, { layout, sink }),面板就出現了:推桿、旋鈕、XY 板、打擊墊、琴鍵,版面裡寫什麼就有什麼,每個都顯示即時的數字。元件刻意做得很笨:手指進去,有名字的正規化訊號從 sink 出來,面板完全不知道作品是什麼。在演出頁面上搭配 signalSink() 用,或在手機上搭配送去中繼的 sink 用:同一份版面兩邊都行。世界的參數也能用 autoSurface() 自己長出整份版面。
格線一定填滿容器,不會有任何捲動。容器轉向的時候,頁面會由 resolvePage() 依新的方向重新排:版面裡有 portrait 或 landscape 的版本就用它,沒有就用基本格線,再不行就把同樣的元件重新擺進一半或兩倍的欄數。碰到元件時,它會用自己的顏色描邊,在推桿或旋鈕上連點兩下就回到預設值。
從演出端到元件是另一條路:feedback(name, value) 讓推桿跟著時間軸動,或把音量表填起來。手指碰著的元件,在被碰著的時候和放開後大約半秒內,會忽略 feedback,不跟手搶(按鈕則只有按住的時候)。
參數 名稱 型別 說明 containerHTMLElement面板要放的地方。面板是 position: absolute,會填滿它,所以容器要有定位(position: relative)和大小,不然面板就沒有大小。 layoutObject版面 JSON(見 validateLayout() ):頁面、格線、元件。版面有問題會丟出錯誤,列出每一個問題。 可省略 sinkFunction(name, value, info) => {},每一次觸碰產生訊號都會呼叫。name 像 surface/main/hue,value 是正規化的 0..1(事件是 info.pulse,文字框則是字串)。元件的範圍不是 0..1 的時候,info.raw 帶著沒縮放的值,接著還會再呼叫一次,名稱是 …/raw。signalSink() 會寫進 Signals,手機上則傳一個呼叫 relay.set 的函式。 可省略 hapticsBoolean在按下按鈕、切換開關、選了單選項目、雙擊重設的時候讓手機震一下,瀏覽器做得到的話(Android Chrome)。false 就關掉。 可省略 預設 true tabsBoolean版面有一頁以上的時候,顯示分頁列。false 就隱藏,改用 setPage() 切換。 可省略 預設 true orientation'portrait' | 'landscape'強制指定方向。不給的話,它跟著容器的形狀走(寬大於高是橫向),容器改變它也會跟著改。 可省略 預設 null onPageFunction(pageId) => {},換頁的時候呼叫,一開始也會用第一頁呼叫一次。 可省略 預設 null
成員 名稱 說明 setLayout(raw)method setLayout(raw):依版面重建面板。validateLayout() 找得到問題的話,會丟出以 invalid surface layout: 開頭、列出每個問題的 Error。新版面有相同 id 的頁面時,會停留在目前那一頁。回傳面板本身。setPage(id)method setPage(id):顯示這個 id 的頁面,並呼叫 onPage。feedback(name, value)method feedback(name, value):演出端對元件。name 是完整的訊號名稱,像 surface/main/level,value 是正規化的 0..1。沒有任何元件在聽的名稱會被忽略。widget(page, id)method widget(page, id):某個頁面 id 與元件 id 對應的元件物件,找不到是 undefined。它的元素是 .el。disposeWidgets()method disposeWidgets():移除所有元件和它們的監聽,但保留面板。setLayout() 會先呼叫它。dispose()method dispose():不再監看容器的大小,移除元件,並把面板從頁面上拿掉。
注意 公開的欄位有 layout(正規化之後的版面)、root(面板的元素)、orientation、pageId、widgets("page/id" 對應元件的 Map)、sink、haptics。 建構函式在檢查版面之前就把面板加進容器了,所以版面在那裡丟出錯誤的話,會留下一個空的面板。版面來自檔案或使用者的話,請先跑 validateLayout() 。 範例
js 複製
import { Surface } from '@openav/surface' ;
const box = document.getElementById('app' );
box.style.cssText = 'position:relative;width:360px;height:220px' ; // the panel fills its container
const out = document.createElement('pre' );
box.after(out);
new Surface(box, {
layout: { pages: [{ id: 'main' , grid: { cols: 4, rows: 2 }, widgets: [
{ id: 'size' , type: 'fader' , label: 'Size' , x: 0, y: 0, w: 1, h: 2, min: 1, max: 10, def: 4 },
{ id: 'pad' , type: 'xy' , x: 1, y: 0, w: 2, h: 2 },
{ id: 'go' , type: 'button' , label: 'Go' , x: 3, y: 0, w: 1, h: 2 },
] }] },
sink: (name, value, info) => {
out.textContent = name + ' = ' + value + (info.raw !== undefined ? ' (raw ' + info.raw + ')' : '' );
},
});
module 一個深色面板,有一支推桿、一塊 XY 板和一顆按鈕。碰其中一個,下面那行就顯示它送出的訊號,例如 surface/main/size = 0.75 (raw 7.75)。
js 複製
import { Surface } from '@openav/surface' ;
const box = document.getElementById('app' );
box.style.cssText = 'position:relative;width:360px;height:220px' ;
const surface = new Surface(box, {
layout: { pages: [{ id: 'main' , grid: { cols: 4, rows: 2 }, widgets: [
{ id: 'level' , type: 'meter' , label: 'Level' , x: 0, y: 0, w: 1, h: 2 },
{ id: 'echo' , type: 'fader' , label: 'Echo' , x: 1, y: 0, w: 1, h: 2 },
] }] },
sink() {},
});
function frame(ms) {
const v = 0.5 + 0.5 * Math.sin(ms / 500);
surface.feedback('surface/main/level' , v); // the meter fills
surface.feedback('surface/main/echo' , 1 - v); // the fader follows
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
module 音量表隨著正弦波起落,推桿朝相反方向動:這是演出端用 feedback() 把數值送回手機。
js 複製
import { Surface } from '@openav/surface' ;
const box = document.getElementById('app' );
box.style.cssText = 'position:relative;width:360px;height:220px' ;
const surface = new Surface(box, {
layout: { pages: [{ id: 'a' , widgets: [{ id: 'x' , type: 'fader' }] }] },
sink() {},
});
try {
surface.setLayout({ pages: [{ id: 'a' , widgets: [{ id: 'x' , type: 'dial' }, { id: 'x' , type: 'knob' }] }] });
} catch (e) {
document.getElementById('app' ).after(Object.assign(document.createElement('pre' ), { textContent: e.message }));
}
module 有問題的版面會丟出錯誤,並列出每個問題(不認得的類型「dial」、重複的 id「x」),原本的面板維持不變。
相關 signalSink() 做一個給 Surface 用的 sink,直接寫進 Signals:給跟演出在同一頁上的面板用,不需要手機,也不需要中繼。 validateLayout() 檢查一份版面,回傳一串看得懂的問題清單,清單是空的就表示沒問題。 autoSurface() 把世界的參數變成控制面板:一份版面、驅動參數的路由,以及把數值回送用的綁定。 resolvePage() 算出某一頁在某個方向下每個元件要放哪裡:格線,以及每個元件的格子,還有是哪一條規則排出來的。 injectTheme() 把面板的樣式表寫進頁面,只寫一次::root 上的 15 個主題變數,以及每個元件的樣式規則。重複呼叫也沒問題。 lockViewport() 讓頁面像儀器面板,而不是網頁:不能捲動、不能雙指縮放、不會有橡皮筋回彈。回傳一個函式,可以復原其中大部分。 WIDGET_TYPES 版面可以用的 14 種元件類型名稱:fader、knob、encoder、button、toggle、xy、bank、radio、pads、keyboard、number、text、label、meter。 mountRemote() 一次呼叫就是整個手機 app:狀態列加上 感測、琴鍵、控制台、MIDI 這幾個分頁,以控制端身分跟中繼溝通。/packages/remote/ 做的就是這件事。 import { Surface } from '@openav/surface'; packages/surface/index.js 第 41 行
THEME_VARS#
常數 @openav/surface THEME_VARS = object, 15 keys
每個控制元件的外觀,收成 :root 上的 15 個 CSS 自訂屬性(--oav-bg、--oav-accent …):覆寫它們就能替整個面板換皮膚。
設計是一組規則,不是裝飾,因為控制器是在黑暗中、很快、只用眼角餘光看的:近黑底上的深色卡片、畫面上唯一飽和的東西是數值、每個觸碰目標至少 44 像素(--oav-touch-min)、正在調的數字一定看得到、被按住的元件用自己的顏色描邊。所有東西都是 CSS 變數,所以作品只要覆寫其中幾個就能換皮膚,不必改元件的程式碼。
injectTheme() 會把這些值寫進頁面一次。要覆寫的話,在那之後,對 document.documentElement 設,或在你自己的樣式表裡用排在後面的 :root 規則。
型別 Object屬性名稱對應它的預設值:--oav-bg #0a0c11、--oav-surface #141922、--oav-surface-2 #1b2230、--oav-line #283044、--oav-text #e8ecf5、--oav-dim #8691a8、--oav-accent #7ea6ff、--oav-ok #3ddc84、--oav-warn #f5a524、--oav-bad #e5484d、--oav-radius 14px、--oav-gap 8px、--oav-touch-min 44px、--oav-font(系統字型)和 --oav-mono(等寬字型)。
範例
js 複製
import { THEME_VARS } from '@openav/surface' ;
document.getElementById('app' ).innerHTML = ['--oav-bg' , '--oav-accent' , '--oav-radius' , '--oav-touch-min' ]
.map((k) => k + ': ' + THEME_VARS[k]).join('<br>' );
module 印出背景 #0a0c11、強調色 #7ea6ff、圓角 14px 和最小觸碰尺寸 44px。
相關 injectTheme() 把面板的樣式表寫進頁面,只寫一次::root 上的 15 個主題變數,以及每個元件的樣式規則。重複呼叫也沒問題。 COLOR_NAMES 元件的 color 可以用的七個顏色名稱:cyan、amber、magenta、lime、violet、coral、white。 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 import { THEME_VARS } from '@openav/surface'; packages/surface/theme.js 第 14 行
toggleFullscreen()#
函式 @openav/surface toggleFullscreen()
頁面不在全螢幕就進入,已經在就離開。要在點擊裡呼叫,不然瀏覽器會拒絕。
它要求整個頁面(document.documentElement)進入全螢幕,並把瀏覽器的導覽列藏起來,瀏覽器沒有全螢幕功能(見 canFullscreen() )的話就安靜地什麼都不做。手機 app 把它放在狀態列的 ⛶ 按鈕上。
呼叫之前
需要使用者手勢:在 click 或 pointerdown 的處理函式裡呼叫。在頁面載入時呼叫,瀏覽器會拒絕。
範例
js 複製
import { toggleFullscreen, canFullscreen } from '@openav/surface' ;
const button = document.createElement('button' );
button.textContent = '⛶ fullscreen' ;
button.hidden = !canFullscreen();
button.onclick = toggleFullscreen;
document.body.append(button);
static 一顆讓頁面進入、再離開全螢幕的按鈕,瀏覽器沒有全螢幕的地方就隱藏。
相關 canFullscreen() 頁面可以要求全螢幕的時候是 true:瀏覽器有 requestFullscreen(或 WebKit 版的)。程式碼把 iPhone 的 Safari 當成沒有。 mountRemote() 一次呼叫就是整個手機 app:狀態列加上 感測、琴鍵、控制台、MIDI 這幾個分頁,以控制端身分跟中繼溝通。/packages/remote/ 做的就是這件事。 import { toggleFullscreen } from '@openav/surface'; packages/surface/platform.js 第 36 行
validateLayout()#
函式 @openav/surface validateLayout(layout)
檢查一份版面,回傳一串看得懂的問題清單,清單是空的就表示沒問題。
它會檢查:不是物件、或沒有非空的 pages 陣列(到這裡就停),頁面沒有 id、id 重複、或 id 用了 A–Z a–z 0–9 _ - 以外的字元,元件的 type 不在 WIDGET_TYPES 裡、沒有 id、在同一頁 id 重複、或 id 用了那些以外的字元(id 會變成訊號名稱的一部分),還有 min 等於 max。它會報告找得到的每一個問題,不只是第一個。
它不看位置、格線或 target:元件互相重疊、或 target 不是任何參數,在這裡都不算問題。Surface 的 setLayout() 會跑它,清單不是空的就丟出錯誤。
參數 名稱 型別 說明 layoutObject原始的版面 JSON。
回傳 string[] 每個問題一句話,附上位置(pages[0].widgets[2]: unknown type "dial")。版面沒問題就是 []。
範例
js 複製
import { validateLayout } from '@openav/surface' ;
const good = validateLayout({ pages: [{ id: 'main' , widgets: [{ id: 'size' , type: 'fader' }] }] });
const bad = validateLayout({ pages: [{ id: 'main page' , widgets: [
{ id: 'a' , type: 'dial' }, { id: 'a' , type: 'fader' }, { type: 'knob' , min: 1, max: 1 },
] }] });
document.getElementById('app' ).innerHTML = ['good: ' + JSON.stringify(good), ...bad].join('<br>' );
module 印出「good: []」,然後是有問題的版面的五個問題:頁面 id、不認得的類型、重複的 id、缺少的 id、以及 min 等於 max。
相關 normalizeLayout() 回傳一份補上預設值的版面複本:id、標題、格線和版本。原本的輸入不會被改動。 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 WIDGET_TYPES 版面可以用的 14 種元件類型名稱:fader、knob、encoder、button、toggle、xy、bank、radio、pads、keyboard、number、text、label、meter。 import { validateLayout } from '@openav/surface'; 也能從 @openav/surface/layout packages/surface/layout.js 第 44 行
WIDGET_CLASSES = object, 14 keys
Surface 依版面建立的元件類別,每種類型一個:WIDGET_CLASSES.fader、.knob、.xy 等等(進階)。
大部分頁面用不到它:Surface 會在這裡查每個 spec.type。把它匯出來,是給只想要單一元件、不要整個面板的程式用的。spec 是元件在版面裡的那一項({ id, type, label, min, max, … }),ctx 是 { page, out(name, value, info), haptic(ms) },out 就是元件的訊號送去的地方,page 是用在訊號名稱裡的頁面 id。元件的元素是 .el,要自己把它放進頁面。
在這裡加你自己的鍵,並不會多出一種新的版面類型:validateLayout() 只接受 WIDGET_TYPES 裡的名稱。
型別 Object一個物件,鍵跟 WIDGET_TYPES 一樣有 14 個,每個值是一個類別:new WIDGET_CLASSES[type](spec, ctx)。
注意 進階用法。keyboard 元件包著 @openav/keys 的 KeysPiano。 範例
js 複製
import { WIDGET_CLASSES } from '@openav/surface' ;
const sent = [];
const ctx = { page: 'solo' , out: (name, value) => sent.push(name + ' = ' + value), haptic() {} };
const toggle = new WIDGET_CLASSES.toggle({ id: 'mute' , type: 'toggle' , label: 'Mute' }, ctx);
document.getElementById('app' ).append(toggle.el);
toggle.set (true ); // what a tap does
toggle.set (false );
document.getElementById('app' ).append(Object.assign(document.createElement('pre' ), { textContent: sent.join('\n' ) }));
module 頁面上有一個單獨的開關,下面是它被打開、關掉時送出的訊號:surface/solo/mute = 1,然後是 0。
相關 Surface 像 TouchOSC 的控制面板:把版面裡的頁面和元件畫在容器裡,每一次觸碰都變成一個有名字的訊號送出去。 WIDGET_TYPES 版面可以用的 14 種元件類型名稱:fader、knob、encoder、button、toggle、xy、bank、radio、pads、keyboard、number、text、label、meter。 import { WIDGET_CLASSES } from '@openav/surface'; packages/surface/widgets.js 第 510 行
WIDGET_TYPES = array, 14 items
版面可以用的 14 種元件類型名稱:fader、knob、encoder、button、toggle、xy、bank、radio、pads、keyboard、number、text、label、meter。
每個元件都發布在 surface/<page>/<id> 底下,正規化成 0..1。範圍不是 0..1 的話,沒縮放的值也會以 …/raw 送出。各類型送出的東西:
text
fader, knob, number <id>
button <id> 1 while held, 0 on release
toggle <id> 1 or 0
encoder <id> (phase 0..1, wraps) and <id>/delta (turns)
xy <id>/x <id>/y <id>/down
bank <id>/1 … <id>/N (count, default 8)
radio <id> = index / (n - 1), raw = index
pads <id>/hit {pad,row,col,vel} and <id>/<n> while held
keyboard midi/note/on midi/note/off midi/cc/64
text <id> a string, as a pulse
label, meter nothing: they only receive feedbacksignalNames() 可以替你的版面列出這些名稱。
每個元件都接受的欄位:id、type、label、min、max、def、step、unit、color(COLOR_NAMES 其中之一),手寫版面還可以加 target,也就是 routesFromLayout() 要變成路由的參數鍵。各類型另有:推桿的 orient(v、h 或 auto)、XY 板的 spring: true、bank 的 count 和 labels、radio 的 options、pads 的 rows、cols 和 labels。
型別 string[]DEFAULT_SIZE 的鍵,順序也一樣。validateLayout() 會拒絕不在裡面的 type。
範例
js 複製
import { WIDGET_TYPES, DEFAULT_SIZE } from '@openav/surface' ;
document.getElementById('app' ).innerHTML = WIDGET_TYPES
.map((t) => t + ' ' + DEFAULT_SIZE[t].w + '×' + DEFAULT_SIZE[t].h)
.join('<br>' );
module 列出 14 種類型和它們在格線上的預設大小,從 fader 1×4 到 meter 1×4。
相關 DEFAULT_SIZE 每種元件類型在格線上的預設大小,格式是 { w, h }(以格為單位):版面只有在在意的時候才需要寫 x、y、w、h。 WIDGET_CLASSES Surface 依版面建立的元件類別,每種類型一個:WIDGET_CLASSES.fader、.knob、.xy 等等(進階)。validateLayout() 檢查一份版面,回傳一串看得懂的問題清單,清單是空的就表示沒問題。 signalNames() 列出一份版面能發布的每一個訊號名稱,讓映射畫面、文件或測試不用碰面板就能列出它們。 COLOR_NAMES 元件的 color 可以用的七個顏色名稱:cyan、amber、magenta、lime、violet、coral、white。 import { WIDGET_TYPES } from '@openav/surface'; 也能從 @openav/surface/layout packages/surface/layout.js 第 38 行
widgetTypeFor(p, { continuousCount = 0, style = 'auto' } = {})
替一個參數宣告挑元件類型:脈衝是按鈕,其他是開關、單選、推桿或旋鈕。
規則依序比對,第一個符合的就用:參數上有 surface: { type } 提示就照它,pulse 是 button,step: 1 而範圍是 1(兩個值)是 toggle,step: 1 而範圍是 2 到 6(三到七個值)是 radio,style 要求 'knob' 或 'fader' 就照辦,其他是 fader,continuousCount 超過 6 時是 knob。沒寫 min 當 0,沒寫 max 當 1。autoSurface() 會對每個參數呼叫它。
參數 名稱 型別 說明 pObject一個參數:{ key, min?, max?, step?, pulse?, surface? }。 continuousCountNumber整個面板一共有幾個連續參數。超過 6 個,推桿就改成旋鈕。 可省略 預設 0 style'auto' | 'fader' | 'knob'連續參數強制用推桿或旋鈕。 可省略 預設 'auto'
範例
js 複製
import { widgetTypeFor } from '@openav/surface/auto' ;
const params = [
{ key: 'hue' , min: 0, max: 360 },
{ key: 'mode' , min: 0, max: 3, step: 1 },
{ key: 'mirror' , min: 0, max: 1, step: 1 },
{ key: 'burst' , pulse: true },
];
const rows = params.map((p) => p.key + ' → ' + widgetTypeFor(p));
rows.push('hue with 9 continuous params → ' + widgetTypeFor(params[0], { continuousCount: 9 }));
document.getElementById('app' ).innerHTML = rows.join('<br>' );
module 印出 hue → fader、mode → radio、mirror → toggle、burst → button,以及面板有九個連續參數時,hue 變成旋鈕。
相關 autoSurface() 把世界的參數變成控制面板:一份版面、驅動參數的路由,以及把數值回送用的綁定。 WIDGET_TYPES 版面可以用的 14 種元件類型名稱:fader、knob、encoder、button、toggle、xy、bank、radio、pads、keyboard、number、text、label、meter。 import { widgetTypeFor } from '@openav/surface'; 也能從 @openav/surface/auto packages/surface/auto.js 第 32 行
數位製造(@openav/fab)#
網格、STL/GLB/OBJ 輸出、檢查。 15
@openav/fab
candidateUrls()#
函式 @openav/fab candidateUrls(kind, opts = {})
loadKernel() 載入核心時依序嘗試的網址清單:你給的 urls、window.__LAB__.vendor、最後是 unpkg 預設的。
這個順序讓頁面可以決定核心從哪裡來,而不用動到使用它們的程式:單一頁面傳 urls,整個主機就設 window.__LAB__.vendor(實驗室就是這樣做,自己提供核心),unpkg 預設是最後的辦法。自己呼叫它,可以看出一個頁面會去哪些地方找。
參數 名稱 型別 說明 kind'smlib' | 'manifold'哪一個核心。 optsObjecturls:{ smlib, manifold },每一項是一個網址或一串網址。 可省略 預設 {}
回傳 string[] 網址清單,最先要試的排最前面,沒有重複。
範例
js 複製
import { candidateUrls } from '@openav/fab' ;
window.__LAB__ = { vendor: { smlib: 'https://lab.example/vendor/smlib.js' } }; // what a host may set
const rows = [
'mine first: ' + candidateUrls('smlib' , { urls: { smlib: 'https://mine.example/smlib.js' } }).join(' → ' ),
'manifold: ' + candidateUrls('manifold' ).join(' → ' ),
];
delete window.__LAB__;
document.getElementById('app' ).innerHTML = rows.join('<br>' );
module 依序印出 smlib 的三個網址(你的、主機的、unpkg),以及 manifold 只有 unpkg 的網址,因為沒有人設定它。
相關 loadKernel() 從第一個能用的網址 import CAD 核心(smlibwasm 或 manifold-3d),結果是可以直接用的模組。 DEFAULT_URLS 沒有別的指定時,CAD 核心從哪裡載入:smlibwasm 0.3.0 和 manifold-3d 3.5.4,都是從 unpkg。 Fab 檢查一個形狀能不能 3D 列印,並輸出成 STL、OBJ、GLB、STEP 或 DXF,吃網格、three.js 物件或 smlibwasm 的實體。 import { candidateUrls } from '@openav/fab'; packages/fab/kernel.js 第 10 行
checkMesh()#
函式 @openav/fab checkMesh(input, opts = {})
替三角形網格量 3D 列印要看的數字:封不封閉、體積、重量、懸空、最薄的壁、放不放得進列印床,每個問題有一句白話警告。
純函式、同步,不碰 DOM。重複的頂點會先合併(距離在包圍盒對角線的 1e-6 以內),因為不這樣做,每個沒有索引的網格(three.js 的 toNonIndexed()、STL、任何三角形湯)都會被看成有開口。報告裡有:triangles(合併之後的數量,塌掉的面會被丟掉)、bbox({ min, max, size },單位毫米)、volume(單位 mm³,一定是正的,繞法反了的網格也會用正確的方向去量)、watertight(每條邊剛好被兩個面用到、而且方向相反),背後是 openEdges、nonManifoldEdges、inconsistentEdges 三個計數,還有 grams:{ PETG, PLA },把體積當成實心塑膠算出的重量(1.27 和 1.24 g/cm³)。
overhang 是 { angle, area, ratio }:朝下的程度超過 angle 容許範圍的面的面積(mm²),以及它佔整個表面的比例。貼在最低 0.2 毫米內(也就是貼在床上)的面不算。minWall 是 { value, min, median, samples } 或 null:從取樣的面朝零件裡面射線,value 是量到的厚度的第 5 百分位,也就是大部分壁有多薄。min 是單一最薄的樣本,在有斜著鑽孔的零件上,它會是刀口,對壁厚說不了什麼,所以另外有比較溫和的提示。
warnings 是一串警告,每項一句話:網格不水密(附三個計數)、懸空超過 overhangWarn、壁比 minWall 薄、刀口比它的一半還薄、尺寸超出 bed。清單是空的就表示沒有要回報的事。fab.check(x) 就是 checkMesh(meshOf(x), opts)。
參數 名稱 型別 說明 inputTriMesh網格 { vertices, triangles, units? }:vertices 是 x, y, z, x, y, z … 的 Float32Array 或 Float64Array,triangles 是每個面三個頂點編號的 Uint32Array,從外面看逆時針,Z 向上,單位毫米。要從別的東西得到網格,用 meshOf() 。 optsObjectangle(45):懸空角度,單位度。minWall(1.2):允許的最薄壁,單位毫米。bed([320, 320, 325]):列印床的大小,單位毫米。wallSamples(2000):量壁厚時取樣的面數,少一點比較快。overhangWarn(0.15):懸空面積佔多少比例才警告。weld(true):先把重複的頂點合併,false 就跳過。 可省略 預設 {}
回傳 Object { units, triangles, bbox, volume, watertight, openEdges, nonManifoldEdges, inconsistentEdges, overhang, minWall, grams, warnings }。見說明。
注意 grams 是實心零件的重量:它不知道填充率或外殼層數,所以切片列印出來的會比較輕。範例
js 複製
import { checkMesh } from '@openav/fab' ;
const tetra = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
units: 'mm' ,
};
const r = checkMesh(tetra);
document.getElementById('app' ).innerHTML = [
'closed: ' + r.watertight + ' (open edges ' + r.openEdges + ')' ,
'size: ' + r.bbox.size.join(' x ' ) + ' mm' ,
'volume: ' + r.volume.toFixed(2) + ' mm³, PLA ' + r.grams.PLA + ' g' ,
'thinnest wall (5th percentile): ' + r.minWall.value.toFixed(2) + ' mm' ,
].join('<br>' );
module 印出 closed: true、尺寸 10 x 10 x 10 mm、體積 166.67 mm³、PLA 0.207 公克,壁厚 3.33 mm。
js 複製
import { checkMesh } from '@openav/fab' ;
// the same tetrahedron with its bottom face left out: a hole in the mesh
const open = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 1, 3, 0, 3, 2, 1, 2, 3]),
units: 'mm' ,
};
const r = checkMesh(open, { bed: [5, 5, 5] }); // and a printer bed that is too small
document.getElementById('app' ).innerHTML = [
'watertight: ' + r.watertight + ', open edges: ' + r.openEdges,
'warnings: ' + r.warnings.length,
].join('<br>' );
module 印出 watertight: false、3 條開放邊,以及 2 個警告:網格有開口,以及 10 毫米的零件放不進 5 毫米的床。
js 複製
import { checkMesh } from '@openav/fab' ;
// the same tetrahedron, but with every triangle listed again for each corner: 12 vertices, none shared (a "triangle soup")
const tetra = [0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10];
const faces = [[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]];
const soup = {
vertices: Float64Array.from (faces.flatMap((f) => f.flatMap((i) => tetra.slice(i * 3, i * 3 + 3)))),
triangles: Uint32Array.from ({ length: 12 }, (_, i) => i),
units: 'mm' ,
};
document.getElementById('app' ).innerHTML = [
'merged first (default): ' + checkMesh(soup).watertight,
'weld: false: ' + checkMesh(soup, { weld: false }).watertight,
].join('<br>' );
module 預設會合併,印出 true,用 weld: false 印出 false:一堆散的三角形,在頂點合併之前看起來是開的。
相關 Fab 檢查一個形狀能不能 3D 列印,並輸出成 STL、OBJ、GLB、STEP 或 DXF,吃網格、three.js 物件或 smlibwasm 的實體。 weldMesh() 把靠得比容差還近的頂點合併,並丟掉塌掉的面,回傳一個頂點有共用的新網格。 meshOf() 把你做的東西變成三角形網格 { vertices, triangles, units }:網格、three.js 物件、像網格的物件,或 smlibwasm 的形狀。 toStlBytes() 把三角形網格寫成二進位 STL 檔:84 位元組加上每個面 50 位元組,每個面的法線都會算出來。 import { checkMesh } from '@openav/fab'; packages/fab/checks.js 第 236 行
DEFAULT_URLS#
常數 @openav/fab DEFAULT_URLS = object, 2 keys
沒有別的指定時,CAD 核心從哪裡載入:smlibwasm 0.3.0 和 manifold-3d 3.5.4,都是從 unpkg。
candidateUrls() 的最後一項。版本是釘死的,所以頁面拿到的核心不會在它底下悄悄改變。要用別的副本,請把 urls 傳給 Fab 或 loadKernel() ,不要去改這個物件。
型別 Object兩個網址:{ smlib: 'https://unpkg.com/@classcad/smlibwasm@0.3.0/dist/index.js', manifold: 'https://unpkg.com/manifold-3d@3.5.4/manifold.js' }。
範例
js 複製
import { DEFAULT_URLS } from '@openav/fab' ;
document.getElementById('app' ).innerHTML = Object.entries(DEFAULT_URLS).map(([kind, url]) => kind + ': ' + url).join('<br>' );
module 印出兩個 unpkg 位址,位址裡有版本號。
相關 candidateUrls() loadKernel() 載入核心時依序嘗試的網址清單:你給的 urls、window.__LAB__.vendor、最後是 unpkg 預設的。loadKernel() 從第一個能用的網址 import CAD 核心(smlibwasm 或 manifold-3d),結果是可以直接用的模組。 Fab 檢查一個形狀能不能 3D 列印,並輸出成 STL、OBJ、GLB、STEP 或 DXF,吃網格、three.js 物件或 smlibwasm 的實體。 import { DEFAULT_URLS } from '@openav/fab'; packages/fab/kernel.js 第 5 行
exportAs()#
函式 @openav/fab async exportAs(x, fmt, opts = {}, kernel = null )
把形狀輸出成 STL、OBJ、GLB、STEP 或 DXF,結果是 { bytes, mime, ext, report }:跟 fab.export() 一樣,只是不用 Fab 類別。
stl、obj、glb 由網格產生:toStlBytes() 、toObjText() 、toGlbBytes() 。step 和 dxf 直接來自 smlibwasm 核心(toStep、toDxf),不是核心形狀的東西會被拒絕,因為網格沒有曲面可以寫,錯誤訊息會這樣說。核心實體的 glb 在核心有 toGlb 的時候會用它。報告是順便做出來的,所以檔案和它的檢查結果一定一致。
參數 名稱 型別 說明 xObject形狀:meshOf() 接受的任何東西。step 要用 smlibwasm 的 Part,dxf 要用 smlibwasm 的 Shape 或 Sketch。 fmtString'stl'、'obj'、'step'、'dxf' 或 'glb',大小寫都可以。'stp' 等於 step,'gltf' 等於 glb。其他的字會丟出列出可用格式的 Error。 optsObjectmeshOf() 的選項(scale、tessellator)、checkMesh() 的選項(angle、minWall、bed …),以及 name,也就是 OBJ 檔裡的物件名稱。 可省略 預設 {} kernel{ smlib }{ smlib: () => Promise<module> },step、dxf 和 Part 的 glb 要從哪裡拿 smlibwasm 模組。fab.export() 會傳它自己的。 可省略 預設 null
回傳 Promise<Object> bytes 是 Uint8Array,mime 來自 MIME ,ext 是不含點的副檔名(就算你要的是 'stp' 也是 'step'),report 是這個網格的 checkMesh() 報告(step 和 dxf 在形狀切不成網格,例如 2D 草圖時,可能是 null)。
範例
js 複製
import { exportAs } from '@openav/fab' ;
const tetra = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
};
const rows = [];
for (const fmt of ['STL' , 'obj' , 'gltf' ]) {
const out = await exportAs(tetra, fmt);
rows.push(fmt + ' → ' + out.ext + ', ' + out.mime + ', ' + out.bytes.length + ' bytes, watertight ' + out.report.watertight);
}
document.getElementById('app' ).innerHTML = rows.join('<br>' );
module 印出三個輸出:STL 變成 stl(284 位元組)、obj,以及別名 gltf 變成 glb,每個都附上報告的結論。
js 複製
import { exportAs } from '@openav/fab' ;
const tetra = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
};
let step, xyz;
try { await exportAs(tetra, 'step' ); } catch (e) { step = e.message; }
try { await exportAs(tetra, 'xyz' ); } catch (e) { xyz = e.message; }
document.getElementById('app' ).innerHTML = [step, xyz].join('<br>' );
module 兩個拒絕,各有一則中文訊息:網格不能變成 STEP(它沒有曲面),「xyz」也不是格式。
js 複製
import { exportAs } from '@openav/fab' ;
// a stand-in for the smlibwasm kernel: STEP only works for a kernel solid, and the kernel writes it
const solid = { faces: () => [], bbox: () => ({}), tessellate: () => ({ positions: [0, 0, 0, 10, 0, 0, 0, 10, 0], indices: [0, 1, 2] }) };
const kernel = { smlib: async () => ({ toStep: () => 'ISO-10303-21;' }) };
const out = await exportAs(solid, 'stp' , {}, kernel);
document.getElementById('app' ).textContent = out.ext + ' (' + out.mime + '): ' + new TextDecoder().decode(out.bytes);
module 印出 step (model/step): ISO-10303-21;,表示別名 stp 被認得了,位元組來自核心的 toStep。
相關 Fab 檢查一個形狀能不能 3D 列印,並輸出成 STL、OBJ、GLB、STEP 或 DXF,吃網格、three.js 物件或 smlibwasm 的實體。 MIME 每種輸出格式的媒體類型:MIME.stl 是 'model/stl',obj、step、dxf、glb 也一樣。 toStlBytes() 把三角形網格寫成二進位 STL 檔:84 位元組加上每個面 50 位元組,每個面的法線都會算出來。 toObjText() 把三角形網格寫成 OBJ 文字:先合併頂點(v x y z),再寫面(f a b c),一個物件,單位毫米。 toGlbBytes() 把三角形網格寫成最精簡的二進位 glTF(.glb):一個網格,位置已合併並帶索引,單位毫米。 checkMesh() 替三角形網格量 3D 列印要看的數字:封不封閉、體積、重量、懸空、最薄的壁、放不放得進列印床,每個問題有一句白話警告。 loadKernel() 從第一個能用的網址 import CAD 核心(smlibwasm 或 manifold-3d),結果是可以直接用的模組。 import { exportAs } from '@openav/fab'; packages/fab/export.js 第 95 行
new Fab(opts = {})
檢查一個形狀能不能 3D 列印,並輸出成 STL、OBJ、GLB、STEP 或 DXF,吃網格、three.js 物件或 smlibwasm 的實體。
做一個 Fab,把你做出來的東西交給它:fab.check(x) 回傳製造性報告(封不封閉、用了多少材料、有多少懸空、最薄的壁多薄、幾公克),await fab.export(x, 'stl') 以位元組回傳檔案。x 可以是單純的三角形網格 { vertices, triangles }、three.js 的 Mesh、Object3D 或 BufferGeometry(靠形狀辨認,從不 import three.js),或 smlibwasm 的 Part 或 Shape。meshOf() 說明了到底接受什麼。
格式:stl、obj、glb 任何輸入都行。step 只能從 smlibwasm 的 Part 輸出,dxf 只能從 smlibwasm 的 Shape 或 Sketch 輸出,因為網格沒有曲面資訊。CAD 核心沒有打包在裡面:第一次需要時,fab.smlib() 或 fab.manifold() 才從網址(預設是 unpkg)import 進來,並且記住。
fab.save() 如果你設了 fab.saver(實驗室的開發伺服器就會設)就透過它寫檔,沒有的話就當成瀏覽器下載。沒有任何執行期相依套件:在你要求核心之前,不會抓任何東西。
呼叫之前
網格和 three.js 物件不需要任何前置動作。smlibwasm 的 Part 要由核心來切成網格:在同步的 check() 之前先 await fab.smlib() 一次,或改用會自動載入的 checkAsync()、meshOfAsync() 和 export()。載入需要網路,或是 urls、window.__LAB__.vendor 指向有這個核心的地方。
參數 名稱 型別 說明 optsObject{ scale, urls, importer, download, context },每一項都可省略。scale 是 three.js 物件每個單位多少毫米(10 表示 three.js 的一個單位是 10 毫米)。urls 是 { smlib, manifold },一個網址或一串網址,用來載入 CAD 核心。importer(url) 取代載入核心用的動態 import()。download(bytes, info) 取代 save() 退而求其次用的瀏覽器下載。context() 回傳 { id, params },讓 save() 放進 info。 可省略 預設 {}
成員 名稱 說明 smlib()method smlib():smlibwasm 模組的 promise,第一次呼叫時 import 並記住(之後拿到同一個 promise)。載入失敗不會被記住,所以下次呼叫會再試一次。manifold()method manifold():manifold-3d 也一樣。WebAssembly 會替你初始化(呼叫 setup()),結果是可以直接用的模組。meshOf(x, opts = {})method meshOf(x, opts?):跟 meshOf() 一樣,另外帶上這個 Fab 的 scale,以及 smlib() 載入之後核心的切網格函式。meshOfAsync(x, opts = {})method meshOfAsync(x, opts?):跟 meshOfAsync() 一樣,需要時會自己載入 smlib 核心。check(x, opts = {})method check(x, opts?):同步。先 meshOf(x) 再 checkMesh() :回傳報告。opts 是兩者的選項合在一起(scale、angle、minWall、bed …)。checkAsync(x, opts = {})method checkAsync(x, opts?):同樣的事,但透過 meshOfAsync,所以 smlibwasm 的 Part 不用先載入核心也能用。回傳報告的 promise。export(x, fmt, opts = {})method export(x, fmt, opts?):{ bytes, mime, ext, report } 的 promise,見 exportAs() 。它會用這個 Fab 的 scale,step、dxf 和 Part 的 glb 需要時會載入核心。save(x, fmt, { name, params, note, ...rest } = {})method save(x, fmt, { name, params, note, … }?):先輸出,然後如果 fab.saver 是函式,就把位元組交給 fab.saver(bytes, info),info 是 { id, fmt, ext, mime, name, params, note, report }。saver 不存在、丟出錯誤、或什麼都沒回傳的話,就改成下載,透過 opts.download 或點一個 <a download>。回傳 saver 回傳的東西,或 { downloaded: true, file },另外附上 report。
注意 公開的欄位有 opts 和 saver(設成 async (bytes, info) => ({ file, … }),一開始是 null)。save() 的檔名是 name,沒有的話是 context() 給的 id,再沒有就是 fab。 範例
js 複製
import { Fab } from '@openav/fab' ;
// a tetrahedron: three 10 mm edges meeting at the origin (x, y, z), counter-clockwise faces seen from outside
const mesh = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
units: 'mm' ,
};
const report = new Fab().check(mesh);
document.getElementById('app' ).innerHTML = [
'watertight: ' + report.watertight,
'volume: ' + report.volume.toFixed(1) + ' mm³' ,
'PETG: ' + report.grams.PETG + ' g' ,
'overhang: ' + Math.round(report.overhang.ratio * 100) + '%' ,
'warnings: ' + report.warnings.length,
].join('<br>' );
module 印出 watertight: true、體積 166.7 mm³(10 × 10 × 10 ÷ 6)、PETG 0.212 公克、沒有懸空、沒有警告。
js 複製
import { Fab } from '@openav/fab' ;
const mesh = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
units: 'mm' ,
};
const fab = new Fab();
const stl = await fab.export (mesh, 'stl' );
const obj = await fab.export (mesh, 'obj' , { name: 'tetra' });
document.getElementById('app' ).innerHTML = [
stl.ext + ' (' + stl.mime + '): ' + stl.bytes.length + ' bytes' ,
obj.ext + ' (' + obj.mime + '): ' + new TextDecoder().decode(obj.bytes).split('\n' ).slice(0, 4).join(' | ' ),
].join('<br>' );
module 印出 STL 是 284 位元組(84 + 50 × 4),以及 OBJ 的前幾行:註解、「o tetra」和前兩個頂點。
js 複製
import { Fab } from '@openav/fab' ;
const mesh = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
units: 'mm' ,
};
const seen = [];
const fab = new Fab({ download: (bytes, info) => seen.push('download ' + info.name + '.' + info.ext + ' (' + bytes.length + ' bytes)' ) });
const first = await fab.save(mesh, 'stl' , { name: 'tetra' }); // no saver: a download
fab.saver = async (bytes, info) => ({ file: info.name + '.' + info.ext, n: 1 }); // e.g. the lab's /api/fab
const second = await fab.save(mesh, 'obj' , { name: 'tetra' }); // the saver wins
seen.push('first: ' + JSON.stringify({ downloaded: first.downloaded, file: first.file }));
seen.push('second: ' + JSON.stringify({ file: second.file, n: second.n }));
document.getElementById('app' ).innerHTML = seen.join('<br>' );
module 印出 tetra.stl 的下載(替身 download 取代了瀏覽器的下載),然後是 saver 對 tetra.obj 的回答。
待確認
這一條沒有實際跑過 STEP 和 DXF 輸出,以及真正的 smlibwasm Part 切網格:它們需要 unpkg 上的真正核心。範例用一個替身核心來示範流程。
相關 checkMesh() 替三角形網格量 3D 列印要看的數字:封不封閉、體積、重量、懸空、最薄的壁、放不放得進列印床,每個問題有一句白話警告。 exportAs() 把形狀輸出成 STL、OBJ、GLB、STEP 或 DXF,結果是 { bytes, mime, ext, report }:跟 fab.export() 一樣,只是不用 Fab 類別。 meshOf() 把你做的東西變成三角形網格 { vertices, triangles, units }:網格、three.js 物件、像網格的物件,或 smlibwasm 的形狀。 loadKernel() 從第一個能用的網址 import CAD 核心(smlibwasm 或 manifold-3d),結果是可以直接用的模組。 toStlBytes() 把三角形網格寫成二進位 STL 檔:84 位元組加上每個面 50 位元組,每個面的法線都會算出來。 import { Fab } from '@openav/fab'; packages/fab/index.js 第 16 行
fromMeshLike()#
函式 @openav/fab fromMeshLike(m, scale = 1)
把像網格的物件(位置和索引用常見的任何名稱)變成三角形網格,位置會乘上 scale。
meshOf() 處理核心的切網格結果時用的就是它,不管核心怎麼拼那些名稱。m 是空的、或沒有位置的話,會丟出 Error(中文訊息)。它不檢查面是不是朝外、網格有沒有封閉:那是 checkMesh() 的工作。
參數 名稱 型別 說明 mObject一個物件,點放在 positions、vertices、points 或 position 底下,面放在 indices、triangles、faces、index 或 triVerts 底下。點可以是攤平的數字、巢狀陣列([[x, y, z], …])或物件({ x, y, z })。沒有面的話,每三個點是一個三角形。m.units 會保留(預設 'mm')。 scaleNumber乘在每個位置上,例如每個單位多少毫米。 可省略 預設 1
回傳 TriMesh 回傳 { vertices: Float64Array, triangles: Uint32Array, units }。
範例
js 複製
import { fromMeshLike } from '@openav/fab' ;
// no faces given: every three points are one triangle; positions in centimetres, scaled to millimetres
const mesh = fromMeshLike({ positions: [0, 0, 0, 1, 0, 0, 0, 1, 0] }, 10);
document.getElementById('app' ).textContent =
'vertices: ' + [...mesh.vertices].join(', ' ) + ' | triangles: ' + [...mesh.triangles].join(', ' ) + ' | ' + mesh.units;
module 印出「vertices: 0, 0, 0, 10, 0, 0, 0, 10, 0 | triangles: 0, 1, 2 | mm」。
相關 meshOf() 把你做的東西變成三角形網格 { vertices, triangles, units }:網格、three.js 物件、像網格的物件,或 smlibwasm 的形狀。 meshOfAsync() 跟 meshOf() 一樣,但它會等回傳 promise 的 tessellate(),也能載入 smlibwasm 核心來切實體。 checkMesh() 替三角形網格量 3D 列印要看的數字:封不封閉、體積、重量、懸空、最薄的壁、放不放得進列印床,每個問題有一句白話警告。 import { fromMeshLike } from '@openav/fab'; packages/fab/mesh.js 第 22 行
fromStlBytes()#
函式 @openav/fab fromStlBytes(bytes)
把二進位 STL 檔讀回三角形網格:每個面有自己的三個頂點,32 位元浮點數。
它讀的是 toStlBytes() 寫出的東西,或任何二進位 STL:法線和兩個屬性位元組會跳過,結果是一堆三角形湯。把它交給會自己合併頂點的 checkMesh() ,或交給 weldMesh() 。以 solid 開頭的文字(ASCII)格式 STL 不支援,讀進來會是亂碼。
參數 名稱 型別 說明 bytesUint8Array二進位 STL 檔的內容。面數從檔頭讀,不會拿檔案長度去對照。
回傳 TriMesh { vertices: Float32Array(每個面 9 個), triangles: Uint32Array(0, 1, 2, 3, 4, 5 …), units: 'mm' }。
範例
js 複製
import { toStlBytes, fromStlBytes, checkMesh } from '@openav/fab' ;
const tetra = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
};
const back = fromStlBytes(toStlBytes(tetra)); // a round trip through the file format
document.getElementById('app' ).innerHTML = [
'faces: ' + back.triangles.length / 3 + ', vertices: ' + back.vertices.length / 3,
'still closed after the check merges them: ' + checkMesh(back).watertight,
].join('<br>' );
module 印出 4 個面和 12 個頂點(沒有共用),以及「true」:檢查時把它們合併後,發現形狀是封閉的。
相關 toStlBytes() 把三角形網格寫成二進位 STL 檔:84 位元組加上每個面 50 位元組,每個面的法線都會算出來。 weldMesh() 把靠得比容差還近的頂點合併,並丟掉塌掉的面,回傳一個頂點有共用的新網格。 checkMesh() 替三角形網格量 3D 列印要看的數字:封不封閉、體積、重量、懸空、最薄的壁、放不放得進列印床,每個問題有一句白話警告。 import { fromStlBytes } from '@openav/fab'; packages/fab/export.js 第 33 行
loadKernel()#
函式 @openav/fab async loadKernel(kind, opts = {})
從第一個能用的網址 import CAD 核心(smlibwasm 或 manifold-3d),結果是可以直接用的模組。
核心沒有打包,因為 open-audiovisual 沒有相依套件、也沒有建置步驟:頁面第一次需要的時候,才從網址 import 進來。順序是 candidateUrls() :opts.urls[kind],然後 window.__LAB__.vendor[kind],最後是 unpkg(DEFAULT_URLS )。每個網址都失敗的話,它會用 Error(中文)reject,訊息列出每個網址和失敗原因。它自己什麼都不記:fab.smlib() 和 fab.manifold() 會替你留著 promise,除非你想自己管理,不然呼叫那兩個。
呼叫之前
要有網路,或是 urls 指向有提供核心的地方(實驗室用的是 window.__LAB__.vendor),或是一個會回傳核心的 importer。
參數 名稱 型別 說明 kind'smlib' | 'manifold'哪一個核心:'smlib' 是 smlibwasm(實體建模、STEP 和 DXF),'manifold' 是 manifold-3d(快速的網格布林運算)。 optsObjecturls:{ smlib, manifold },每個核心一個網址或一串網址。importer:async (url) => module,用來取代動態的 import()。 可省略 預設 {}
回傳 Promise<Object> smlibwasm 模組原樣,或已經把 WebAssembly 啟動(呼叫過 setup())的 manifold-3d 模組。
範例
js 複製
import { loadKernel } from '@openav/fab' ;
const tried = [];
// a stand-in importer so nothing is fetched: the first URL fails, the second one works
const importer = async (url) => {
tried.push(url);
if (url.includes('mirror-down' )) throw new Error('503' );
return { name: 'manifold stand-in' , default : async () => ({ setup() { tried.push('setup() called' ); } }) };
};
const wasm = await loadKernel('manifold' , { urls: { manifold: ['https://mirror-down.example/m.js' , 'https://mirror.example/m.js' ] }, importer });
document.getElementById('app' ).innerHTML = tried.join('<br>' ) + '<br>result has setup: ' + (typeof wasm.setup);
module 依嘗試的順序印出兩個網址,然後是「setup() called」:manifold 的 WebAssembly 會替你啟動。
js 複製
import { loadKernel } from '@openav/fab' ;
let message;
try {
await loadKernel('smlib' , { urls: { smlib: 'https://nowhere.example/a.js' }, importer: async () => { throw new Error('no network' ); } });
} catch (e) { message = e.message; }
document.getElementById('app' ).textContent = message;
module 每個網址都失敗的時候,錯誤訊息會一個一個列出來,你的在前、unpkg 預設的在後,並附上原因(用中文寫的句子)。
相關 candidateUrls() loadKernel() 載入核心時依序嘗試的網址清單:你給的 urls、window.__LAB__.vendor、最後是 unpkg 預設的。DEFAULT_URLS 沒有別的指定時,CAD 核心從哪裡載入:smlibwasm 0.3.0 和 manifold-3d 3.5.4,都是從 unpkg。 Fab 檢查一個形狀能不能 3D 列印,並輸出成 STL、OBJ、GLB、STEP 或 DXF,吃網格、three.js 物件或 smlibwasm 的實體。 meshOfAsync() 跟 meshOf() 一樣,但它會等回傳 promise 的 tessellate(),也能載入 smlibwasm 核心來切實體。 import { loadKernel } from '@openav/fab'; packages/fab/kernel.js 第 17 行
meshOf(x, opts = {})
把你做的東西變成三角形網格 { vertices, triangles, units }:網格、three.js 物件、像網格的物件,或 smlibwasm 的形狀。
每種東西都靠形狀辨認,什麼都不 import,所以 three.js 和 smlibwasm 絕對不會被替你載入。three.js 物件會套用它的 matrixWorld(鏡射的物件仍會讓面朝外),有沒有索引都行,Y 向上會換成 Z 向上,換法是 (x, y, z) → (x, −z, y),然後全部乘上 scale。沒有給輸入、或它讀不懂的東西,會丟出 Error,訊息(中文)會說明該怎麼辦。
它是同步的,所以不能等核心:沒有 opts.tessellator 的 smlibwasm 實體會丟出一則叫你先載入核心的訊息,tessellate() 回傳 promise 也會丟出錯誤。這些情況請用 meshOfAsync() 。
參數 名稱 型別 說明 xObject要轉換的東西。接受的類型,依這個順序比對:網格 { vertices, triangles }(原樣傳回),three.js 的 Mesh、Object3D(底下每個看得見的網格)或 BufferGeometry,任何有 tessellate() 方法的東西,有 positions/vertices/points,以及可有可無的 indices/triangles/faces/index 的物件,smlibwasm 的實體或形狀(要給 opts.tessellator)。 optsObjectscale:three.js 或像網格的輸入,每個單位多少毫米(預設 1)。{ vertices, triangles } 的網格不會被縮放。tessellator(shape):把 smlibwasm 形狀變成網格的函式(Fab 類別在核心載入後會傳入核心的那個)。tessellate:傳給形狀自己的 tessellate() 的參數。 可省略 預設 {}
回傳 TriMesh { vertices, triangles, units }:從外面看逆時針的面,Z 向上,單位毫米。
範例
js 複製
import { meshOf } from '@openav/fab' ;
// what a three.js Mesh looks like to fab: a triangle in the XY plane, no index, an identity matrixWorld
const threeLike = {
isMesh: true ,
geometry: { attributes: { position: { array: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), itemSize: 3, count: 3 } }, index: null },
matrixWorld: { elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] },
};
const m = meshOf(threeLike, { scale: 10 }); // one three.js unit is 10 mm
document.getElementById('app' ).textContent = 'vertices: ' + [...m.vertices].join(', ' ) + ' (' + m.units + ')' ;
module 印出「vertices: 0, 0, 0, 10, 0, 0, 0, 0, 10 (mm)」:乘上 10,three.js 的向上(y = 1)變成 fab 的向上(z = 10)。
js 複製
import { meshOf } from '@openav/fab' ;
// a mesh-like object: nested points and faces, as some libraries hand them out
const m = meshOf({ points: [[0, 0, 0], [20, 0, 0], [0, 20, 0]], faces: [[0, 1, 2]] });
// the vertices come back flat, as numbers: x, y, z, x, y, z …
document.getElementById('app' ).textContent = [...m.vertices].join(' ' ) + ' | faces: ' + [...m.triangles].join(' ' );
module 印出三個角的九個數字,以及面「0 1 2」:巢狀陣列會被攤平。
相關 meshOfAsync() 跟 meshOf() 一樣,但它會等回傳 promise 的 tessellate(),也能載入 smlibwasm 核心來切實體。 fromMeshLike() 把像網格的物件(位置和索引用常見的任何名稱)變成三角形網格,位置會乘上 scale。 checkMesh() 替三角形網格量 3D 列印要看的數字:封不封閉、體積、重量、懸空、最薄的壁、放不放得進列印床,每個問題有一句白話警告。 Fab 檢查一個形狀能不能 3D 列印,並輸出成 STL、OBJ、GLB、STEP 或 DXF,吃網格、three.js 物件或 smlibwasm 的實體。 import { meshOf } from '@openav/fab'; packages/fab/mesh.js 第 83 行
meshOfAsync()#
函式 @openav/fab async meshOfAsync(x, opts = {})
跟 meshOf() 一樣,但它會等回傳 promise 的 tessellate(),也能載入 smlibwasm 核心來切實體。
轉換可能得等的時候用它:tessellate() 是非同步的形狀,或核心還沒載入的 smlibwasm Part。有給 opts.kernel.smlib 的話,它會等模組載好,用它的 tessellate(part)。模組沒有這個函式的話會丟出說明的錯誤。其他東西都交給 meshOf() 。fab.meshOfAsync() 會替你填好 kernel。
參數 名稱 型別 說明 xObject要轉換的東西,見 meshOf() 。 optsObjectmeshOf() 的選項,另外有 kernel:{ smlib: () => Promise<module> },遇到實體要切、又沒給 tessellator 時,從哪裡拿 smlibwasm 模組。 可省略 預設 {}
範例
js 複製
import { meshOfAsync } from '@openav/fab' ;
// a shape whose tessellate() answers later, the way a kernel running in a worker would
const shape = { tessellate: async () => ({ positions: [0, 0, 0, 5, 0, 0, 0, 5, 0], indices: [0, 1, 2] }) };
const mesh = await meshOfAsync(shape);
document.getElementById('app' ).textContent = mesh.vertices.length / 3 + ' vertices, ' + mesh.triangles.length / 3 + ' face' ;
module 印出「3 vertices, 1 face」:promise 被等到了,結果也轉成了網格。
js 複製
import { meshOfAsync } from '@openav/fab' ;
// a stand-in for the smlibwasm module: a solid has no tessellate() of its own, the module's function cuts it
const part = { faces: () => [], bbox: () => ({}) };
const kernel = { smlib: async () => ({ tessellate: (p) => ({ vertices: [0, 0, 0, 5, 0, 0, 0, 5, 0], triangles: [0, 1, 2] }) }) };
const mesh = await meshOfAsync(part, { kernel });
document.getElementById('app' ).textContent = 'tessellated by the kernel: ' + mesh.triangles.length / 3 + ' face' ;
module 印出「tessellated by the kernel: 1 face」:透過 kernel.smlib() 取得模組,由它的 tessellate 完成。
相關 meshOf() 把你做的東西變成三角形網格 { vertices, triangles, units }:網格、three.js 物件、像網格的物件,或 smlibwasm 的形狀。 Fab 檢查一個形狀能不能 3D 列印,並輸出成 STL、OBJ、GLB、STEP 或 DXF,吃網格、three.js 物件或 smlibwasm 的實體。 loadKernel() 從第一個能用的網址 import CAD 核心(smlibwasm 或 manifold-3d),結果是可以直接用的模組。 import { meshOfAsync } from '@openav/fab'; packages/fab/mesh.js 第 102 行
MIME = object, 5 keys
每種輸出格式的媒體類型:MIME.stl 是 'model/stl',obj、step、dxf、glb 也一樣。
自己處理位元組的時候,拿來當 Blob 的 type 或上傳的 Content-Type:new Blob([bytes], { type: MIME.stl })。exportAs() 的結果裡已經附上正確的那一個。
型別 Object{ stl: 'model/stl', obj: 'model/obj', step: 'model/step', dxf: 'image/vnd.dxf', glb: 'model/gltf-binary' }。它的鍵就是 exportAs() 接受的格式。
範例
js 複製
import { MIME, toStlBytes } from '@openav/fab' ;
const tetra = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
};
const blob = new Blob([toStlBytes(tetra)], { type: MIME.stl });
document.getElementById('app' ).textContent = Object.keys(MIME).join(', ' ) + ' | blob: ' + blob.type + ', ' + blob.size + ' bytes' ;
module 印出五種格式,以及一個類型是 model/stl、284 位元組的 blob。
相關 exportAs() 把形狀輸出成 STL、OBJ、GLB、STEP 或 DXF,結果是 { bytes, mime, ext, report }:跟 fab.export() 一樣,只是不用 Fab 類別。 toStlBytes() 把三角形網格寫成二進位 STL 檔:84 位元組加上每個面 50 位元組,每個面的法線都會算出來。 Fab 檢查一個形狀能不能 3D 列印,並輸出成 STL、OBJ、GLB、STEP 或 DXF,吃網格、three.js 物件或 smlibwasm 的實體。 import { MIME } from '@openav/fab'; packages/fab/export.js 第 5 行
toGlbBytes()#
函式 @openav/fab toGlbBytes(mesh)
把三角形網格寫成最精簡的二進位 glTF(.glb):一個網格,位置已合併並帶索引,單位毫米。
頂點會先用 weldMesh() 合併。檔案裡只有 POSITION 和索引:沒有法線、材質、顏色,所以檢視器會用平面著色,或自己算法線。位置維持毫米、Z 向上,asset.extras 寫著 { units: 'mm', up: 'Z' }。glTF 本身假設 Y 向上,所以不讀 extras 的檢視器會把零件顯示成躺在側面。
smlibwasm 的 Part 用 await fab.export(part, 'glb') 的話,核心有自己的 toGlb 就用它,沒有才用這個函式。
參數 名稱 型別 說明 meshTriMesh網格 { vertices, triangles },單位毫米。
回傳 Uint8Array glTF 2.0 二進位檔:glTF 檔頭、一個 JSON 區塊、一個放 32 位元浮點數位置和 32 位元索引的二進位區塊。長度是 4 的倍數。
範例
js 複製
import { toGlbBytes } from '@openav/fab' ;
const tetra = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
};
const glb = toGlbBytes(tetra);
const dv = new DataView(glb.buffer);
const json = JSON.parse(new TextDecoder().decode(glb.subarray(20, 20 + dv.getUint32(12, true ))));
document.getElementById('app' ).innerHTML = [
'magic: ' + new TextDecoder().decode(glb.subarray(0, 4)) + ', ' + glb.length + ' bytes' ,
'vertices: ' + json.accessors[0].count + ', indices: ' + json.accessors[1].count,
'bounds: ' + json.accessors[0].max.join(' x ' ),
].join('<br>' );
module 印出魔術字串「glTF」和大小、4 個頂點配 12 個索引,以及範圍 10 x 10 x 10。
相關 toStlBytes() 把三角形網格寫成二進位 STL 檔:84 位元組加上每個面 50 位元組,每個面的法線都會算出來。 toObjText() 把三角形網格寫成 OBJ 文字:先合併頂點(v x y z),再寫面(f a b c),一個物件,單位毫米。 exportAs() 把形狀輸出成 STL、OBJ、GLB、STEP 或 DXF,結果是 { bytes, mime, ext, report }:跟 fab.export() 一樣,只是不用 Fab 類別。 weldMesh() 把靠得比容差還近的頂點合併,並丟掉塌掉的面,回傳一個頂點有共用的新網格。 import { toGlbBytes } from '@openav/fab'; packages/fab/export.js 第 54 行
toObjText()#
函式 @openav/fab toObjText(mesh, name = 'fab' )
把三角形網格寫成 OBJ 文字:先合併頂點(v x y z),再寫面(f a b c),一個物件,單位毫米。
頂點會先用 weldMesh() 合併,所以三角形湯寫出來有共用的頂點,檔案也小。沒有法線、沒有貼圖座標、沒有材質。await fab.export(x, 'obj', { name }) 是同一件事,並且替你做了轉換。
參數 名稱 型別 說明 meshTriMesh網格 { vertices, triangles },單位毫米。 nameStringo 那一行的物件名稱。空格會變成底線。 可省略 預設 'fab'
回傳 String 文字內容:一行註解、o <name>、每個合併後的頂點一行 v(最多 6 位小數)、每個面一行 f,頂點編號從 1 開始。
範例
js 複製
import { toObjText } from '@openav/fab' ;
const tetra = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
};
document.getElementById('app' ).innerHTML = toObjText(tetra, 'my part' ).trim().split('\n' ).join('<br>' );
module 印出註解、「o my_part」、四行 v 和四行 f:「f 1 3 2」是第一個面。
相關 toStlBytes() 把三角形網格寫成二進位 STL 檔:84 位元組加上每個面 50 位元組,每個面的法線都會算出來。 toGlbBytes() 把三角形網格寫成最精簡的二進位 glTF(.glb):一個網格,位置已合併並帶索引,單位毫米。 exportAs() 把形狀輸出成 STL、OBJ、GLB、STEP 或 DXF,結果是 { bytes, mime, ext, report }:跟 fab.export() 一樣,只是不用 Fab 類別。 weldMesh() 把靠得比容差還近的頂點合併,並丟掉塌掉的面,回傳一個頂點有共用的新網格。 import { toObjText } from '@openav/fab'; packages/fab/export.js 第 45 行
toStlBytes()#
函式 @openav/fab toStlBytes(mesh)
把三角形網格寫成二進位 STL 檔:84 位元組加上每個面 50 位元組,每個面的法線都會算出來。
每個面各自佔 50 位元組:STL 沒有共用頂點。法線是從角的順序(右手定則)算出來的,所以從外面看逆時針的面,法線就朝外。位置原樣寫成 float32,不會換算成毫米,所以請給它已經是毫米的網格。await fab.export(x, 'stl') 是同一件事,並且替你做了轉換和檢查。
參數 名稱 型別 說明 meshTriMesh網格 { vertices, triangles },單位毫米。
回傳 Uint8Array 檔案內容:80 位元組的檔頭(文字 open-audiovisual fab STL (mm))、面數(小端序的 32 位元整數),然後每個面是法線和三個角(32 位元浮點數)加上兩個零位元組。
範例
js 複製
import { toStlBytes } from '@openav/fab' ;
const tetra = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
};
const bytes = toStlBytes(tetra);
const faces = new DataView(bytes.buffer).getUint32(80, true );
document.getElementById('app' ).textContent =
bytes.length + ' bytes = 84 + 50 × ' + faces + ' faces, header "' + new TextDecoder().decode(bytes.subarray(0, 29)) + '"' ;
module 印出 284 位元組 = 84 + 50 × 4 個面,以及檔頭文字。
js 複製
import { toStlBytes } from '@openav/fab' ;
const tetra = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
};
const bytes = toStlBytes(tetra);
const link = document.createElement('a' );
link.href = URL.createObjectURL(new Blob([bytes], { type: 'model/stl' }));
link.download = 'tetra.stl' ;
link.textContent = 'download tetra.stl (' + bytes.length + ' bytes)' ;
document.getElementById('app' ).append(link);
module 一個下載檔案的連結:用切片軟體或 3D 檢視器打開,會看到一個有直角的小金字塔。
相關 fromStlBytes() 把二進位 STL 檔讀回三角形網格:每個面有自己的三個頂點,32 位元浮點數。 exportAs() 把形狀輸出成 STL、OBJ、GLB、STEP 或 DXF,結果是 { bytes, mime, ext, report }:跟 fab.export() 一樣,只是不用 Fab 類別。 toObjText() 把三角形網格寫成 OBJ 文字:先合併頂點(v x y z),再寫面(f a b c),一個物件,單位毫米。 toGlbBytes() 把三角形網格寫成最精簡的二進位 glTF(.glb):一個網格,位置已合併並帶索引,單位毫米。 Fab 檢查一個形狀能不能 3D 列印,並輸出成 STL、OBJ、GLB、STEP 或 DXF,吃網格、three.js 物件或 smlibwasm 的實體。 import { toStlBytes } from '@openav/fab'; packages/fab/export.js 第 9 行
weldMesh()#
函式 @openav/fab weldMesh(mesh, tolerance)
把靠得比容差還近的頂點合併,並丟掉塌掉的面,回傳一個頂點有共用的新網格。
三角形湯,也就是每個面都有自己的三個頂點(STL 檔或 three.js 的 toNonIndexed() 給的就是這樣),在拓撲上沒有鄰居:每條邊都看起來是開的。合併頂點讓面共用頂點,checkMesh() 才看得出它們是接在一起的。checkMesh() 、toObjText() 和 toGlbBytes() 會自己呼叫它,toStlBytes() 用不到。
參數 名稱 型別 說明 meshTriMesh網格 { vertices, triangles }。不會被改動。 toleranceNumber距離(x、y、z 各自)小於這個值的頂點會變成一個,單位跟網格一樣。不給的話是包圍盒對角線的 1e-6 倍。 可省略
回傳 TriMesh { vertices: Float64Array, triangles: Uint32Array, units },每個位置只出現一次,也沒有任何一個面有兩個角落在同一個頂點上。
範例
js 複製
import { weldMesh, fromStlBytes, toStlBytes } from '@openav/fab' ;
const tetra = {
vertices: new Float64Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, 10]),
triangles: new Uint32Array([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]),
};
const soup = fromStlBytes(toStlBytes(tetra)); // reading an STL gives every face its own vertices
const welded = weldMesh(soup);
document.getElementById('app' ).textContent =
'soup: ' + soup.vertices.length / 3 + ' vertices, welded: ' + welded.vertices.length / 3 + ' vertices, ' + welded.triangles.length / 3 + ' faces' ;
module 印出「soup: 12 vertices, welded: 4 vertices, 4 faces」。
相關 checkMesh() 替三角形網格量 3D 列印要看的數字:封不封閉、體積、重量、懸空、最薄的壁、放不放得進列印床,每個問題有一句白話警告。 fromStlBytes() 把二進位 STL 檔讀回三角形網格:每個面有自己的三個頂點,32 位元浮點數。 toObjText() 把三角形網格寫成 OBJ 文字:先合併頂點(v x y z),再寫面(f a b c),一個物件,單位毫米。 toGlbBytes() 把三角形網格寫成最精簡的二進位 glTF(.glb):一個網格,位置已合併並帶索引,單位毫米。 import { weldMesh } from '@openav/fab'; packages/fab/checks.js 第 22 行