PageSourceSearch

https://filipelopes.net/makeymakey/js/app.js

js filipelopes.net collected 2026-09-25 21:27:50 UTC 32,009 bytes, 1,006 lines download raw bytes

1import { zipSync, unzipSync, strToU8, strFromU8 } from 'https://cdn.jsdelivr.net/npm/[email protected]/esm/browser.js';
2
3const MAX_SLOTS = 12;
4const DEFAULT_SLOT_VOLUME = 1;
5const FADE_IN_SEC = 0.08;
6const FADE_OUT_SEC = 0.15;
7const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
8const MAX_RECORDING_MS = 7 * 60 * 1000;
9const ALLOWED_AUDIO_EXTENSIONS = new Set(['wav', 'aif', 'aiff', 'mp3']);
10const SLOT_FILE_ACCEPT = '.wav,.aif,.aiff,.mp3,audio/wav,audio/x-wav,audio/mpeg,audio/mp3,audio/aiff,audio/x-aiff';
11const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
12
13function msIcon(name) {
14  return `<span class="material-symbols-outlined" aria-hidden="true">${name}</span>`;
15}
16
17function fileExtension(name) {
18  const parts = name.split('.');
19  return parts.length > 1 ? parts.pop().toLowerCase() : '';
20}
21
22function validateAudioFileMeta(file) {
23  if (!file) return { ok: false, message: 'Nenhum ficheiro selecionado' };
24  const ext = fileExtension(file.name);
25  if (!ALLOWED_AUDIO_EXTENSIONS.has(ext)) {
26    return { ok: false, message: 'Formato inválido — use WAV, AIF ou MP3' };
27  }
28  if (file.size > MAX_UPLOAD_BYTES) {
29    return { ok: false, message: 'Ficheiro demasiado grande (máx. 40 MB)' };
30  }
31  return { ok: true };
32}
33
34const slotsContainer = document.getElementById('slots');
35const warningBox = document.getElementById('warning');
36const stopAllBtn = document.getElementById('stopAllBtn');
37const resetBtn = document.getElementById('resetBtn');
38const masterVolumeInput = document.getElementById('masterVolume');
39const masterVolumeLabel = document.getElementById('masterVolumeLabel');
40const midiSelect = document.getElementById('midiSelect');
41const midiStatus = document.getElementById('midiStatus');
42const enableOverlay = document.getElementById('enableAudioOverlay');
43const enableAudioBtn = document.getElementById('enableAudioBtn');
44const exportProjectBtn = document.getElementById('exportProjectBtn');
45const importProjectInput = document.getElementById('importProjectInput');
46const recordBtn = document.getElementById('recordBtn');
47const stopRecordBtn = document.getElementById('stopRecordBtn');
48const playRecordingBtn = document.getElementById('playRecordingBtn');
49const downloadPerformanceBtn = document.getElementById('downloadPerformanceBtn');
50const recordingStatus = document.getElementById('recordingStatus');
51const recordingTimer = document.getElementById('recordingTimer');
52
53const slots = [];
54let audioCtx = null;
55let masterGain = null;
56let recordDestination = null;
57let audioReady = false;
58
59let masterVolume = 1;
60let midiAccess = null;
61let activeMidiInput = null;
62let midiMessageHandler = null;
63
64let mediaRecorder = null;
65let recordChunks = [];
66let recordingBlob = null;
67let recordingUrl = '';
68let recordingPreview = null;
69let recordStartTime = 0;
70let recordTimerInterval = null;
71let isRecording = false;
72let recordingStoppedByLimit = false;
73
74function midiNoteName(note) {
75  if (note === '' || note === null || note === undefined) return '';
76  const n = Number(note);
77  if (Number.isNaN(n) || n < 0 || n > 127) return 'inválida';
78  const octave = Math.floor(n / 12) - 1;
79  return `${NOTE_NAMES[n % 12]}${octave}`;
80}
81
82function ensureAudioContext() {
83  if (!audioCtx) {
84    audioCtx = new AudioContext();
85    masterGain = audioCtx.createGain();
86    recordDestination = audioCtx.createMediaStreamDestination();
87    masterGain.connect(audioCtx.destination);
88    masterGain.connect(recordDestination);
89    masterGain.gain.value = masterVolume;
90    recordingPreview = new Audio();
91  }
92  return audioCtx;
93}
94
95async function ensureAudioRunning() {
96  ensureAudioContext();
97  if (audioCtx.state === 'suspended') {
98    await audioCtx.resume();
99  }
100  audioReady = true;
101  enableOverlay.hidden = true;
102}
103
104function normalizeKey(key) {
105  if (!key) return '';
106  if (key === ' ') return 'Space';
107  if (key.length === 1) return key.toLowerCase();
108  return key;
109}
110
111function normalizeTypedKey(value) {
112  const trimmed = value.trim();
113  if (!trimmed) return '';
114  if (trimmed.toLowerCase() === 'space') return 'Space';
115  if (trimmed.length === 1) return trimmed.toLowerCase();
116  return trimmed;
117}
118
119function reverseBuffer(ctx, buffer) {
120  const reversed = ctx.createBuffer(
121    buffer.numberOfChannels,
122    buffer.length,
123    buffer.sampleRate
124  );
125  for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
126    const src = buffer.getChannelData(ch);
127    const dst = reversed.getChannelData(ch);
128    for (let i = 0; i < buffer.length; i++) {
129      dst[i] = src[buffer.length - 1 - i];
130    }
131  }
132  return reversed;
133}
134
135function getPlaybackBuffer(slot) {
136  if (slot.presetReverse && slot.reversedBuffer) return slot.reversedBuffer;
137  return slot.originalBuffer;
138}
139
140function stopSlotVoice(index) {
141  const slot = slots[index];
142  if (!slot?.activeVoice) return;
143  const { source, gain } = slot.activeVoice;
144  try {
145    source.stop();
146  } catch {
147    /* already stopped */
148  }
149  try {
150    source.disconnect();
151    gain.disconnect();
152  } catch {
153    /* ignore */
154  }
155  slot.activeVoice = null;
156}
157
158function voicePeakGain(slot, velocity) {
159  return (velocity / 127) * slot.volume;
160}
161
162function applyActiveVoiceVolume(index) {
163  const slot = slots[index];
164  if (!slot?.activeVoice || !audioCtx) return;
165  const { gain, velocity } = slot.activeVoice;
166  const now = audioCtx.currentTime;
167  const peak = voicePeakGain(slot, velocity);
168  gain.gain.cancelScheduledValues(now);
169  gain.gain.setValueAtTime(peak, now);
170}
171
172function flashSlot(index) {
173  const el = slots[index].wrapper;
174  el.classList.add('live');
175  setTimeout(() => el.classList.remove('live'), 180);
176}
177
178function triggerSlot(index, { velocity = 127, source = 'ui' } = {}) {
179  const slot = slots[index];
180  if (!slot?.originalBuffer) {
181    if (slot?.statusEl) slot.statusEl.textContent = 'Carregue um ficheiro primeiro';
182    return;
183  }
184
185  if (!audioReady) {
186    if (slot.statusEl) slot.statusEl.textContent = 'Clique em Continue (Welcome!) para começar';
187    return;
188  }
189
190  ensureAudioContext();
191  const buffer = getPlaybackBuffer(slot);
192  if (!buffer) return;
193
194  stopSlotVoice(index);
195
196  const now = audioCtx.currentTime;
197  const src = audioCtx.createBufferSource();
198  src.buffer = buffer;
199  src.loop = slot.loop;
200
201  const gain = audioCtx.createGain();
202  const peak = voicePeakGain(slot, velocity);
203
204  if (slot.presetFadeIn) {
205    gain.gain.setValueAtTime(0, now);
206    gain.gain.linearRampToValueAtTime(peak, now + FADE_IN_SEC);
207  } else {
208    gain.gain.setValueAtTime(peak, now);
209  }
210
211  if (slot.presetFadeOut && !slot.loop) {
212    const duration = buffer.duration;
213    const fadeStart = Math.max(now, now + duration - FADE_OUT_SEC);
214    gain.gain.setValueAtTime(peak, fadeStart);
215    gain.gain.linearRampToValueAtTime(0, now + duration);
216  }
217
218  src.connect(gain);
219  gain.connect(masterGain);
220  src.start(0);
221
222  slot.activeVoice = { source: src, gain, velocity };
223  src.onended = () => {
224    if (slot.activeVoice?.source === src) slot.activeVoice = null;
225  };
226
227  flashSlot(index);
228  const parts = [];
229  if (slot.key) parts.push(`tecla «${slot.key}»`);
230  if (slot.midiNote !== '') parts.push(`MIDI ${slot.midiNote}`);
231  slot.statusEl.textContent =
232    source === 'midi'
233      ? `MIDI (${velocity})${parts.length ? ` · ${parts.join(', ')}` : ''}`
234      : parts.length
235        ? `A tocar · ${parts.join(', ')}`
236        : 'A tocar';
237}
238
239function stopSlot(index) {
240  const slot = slots[index];
241  if (!slot) return;
242  stopSlotVoice(index);
243  slot.statusEl.textContent = 'Parado';
244}
245
246function stopAll() {
247  slots.forEach((_, i) => stopSlotVoice(i));
248  slots.forEach((slot) => {
249    if (slot.statusEl) slot.statusEl.textContent = 'Parado';
250  });
251}
252
253async function loadFileIntoSlot(index, file) {
254  const slot = slots[index];
255  const meta = validateAudioFileMeta(file);
256  if (!meta.ok) {
257    throw new Error(meta.message);
258  }
259
260  await ensureAudioRunning();
261  const arrayBuffer = await file.arrayBuffer();
262  const decoded = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
263
264  stopSlotVoice(index);
265  slot.presetReverse = false;
266  slot.reversedBuffer = null;
267  slot.wrapper.querySelector(`#reverse-${index}`).classList.remove('active-preset');
268
269  slot.file = file;
270  slot.storedFileName = file.name;
271  slot.originalBuffer = decoded;
272
273  slot.filenameEl.textContent = file.name;
274  slot.statusEl.textContent = 'Carregado';
275}
276
277function resetSlot(index) {
278  const slot = slots[index];
279  if (!slot) return;
280
281  stopSlotVoice(index);
282
283  slot.file = null;
284  slot.storedFileName = '';
285  slot.originalBuffer = null;
286  slot.reversedBuffer = null;
287  slot.key = '';
288  slot.midiNote = '';
289  slot.volume = DEFAULT_SLOT_VOLUME;
290  slot.loop = false;
291  slot.stopOnNoteOff = false;
292  slot.presetReverse = false;
293  slot.presetFadeIn = false;
294  slot.presetFadeOut = false;
295
296  slot.keyInput.value = '';
297  slot.midiInput.value = '';
298  slot.midiHint.textContent = '';
299  slot.volumeInput.value = String(DEFAULT_SLOT_VOLUME * 100);
300  slot.volumeLabel.textContent = '100%';
301  slot.wrapper.querySelector(`#file-${index}`).value = '';
302  slot.filenameEl.textContent = 'Nenhum ficheiro';
303  slot.wrapper.querySelector(`#loop-${index}`).checked = false;
304  slot.wrapper.querySelector(`#stop-note-off-${index}`).checked = false;
305  slot.wrapper.querySelector(`#fade-in-${index}`).classList.remove('active-preset');
306  slot.wrapper.querySelector(`#fade-out-${index}`).classList.remove('active-preset');
307  slot.wrapper.querySelector(`#reverse-${index}`).classList.remove('active-preset');
308  slot.statusEl.textContent = '';
309
310  refreshWarnings();
311}
312
313function refreshWarnings() {
314  const keyUsed = {};
315  const midiUsed = {};
316  const messages = [];
317
318  slots.forEach((slot, i) => {
319    if (slot.key) {
320      if (keyUsed[slot.key] !== undefined) {
321        messages.push(
322          `Tecla «${slot.key}» no Som ${keyUsed[slot.key] + 1} e Som ${i + 1}`
323        );
324      } else {
325        keyUsed[slot.key] = i;
326      }
327    }
328    if (slot.midiNote !== '' && slot.midiNote !== null) {
329      const note = Number(slot.midiNote);
330      if (!Number.isNaN(note)) {
331        if (midiUsed[note] !== undefined) {
332          messages.push(
333            `Nota MIDI ${note} no Som ${midiUsed[note] + 1} e Som ${i + 1}`
334          );
335        } else {
336          midiUsed[note] = i;
337        }
338      }
339    }
340  });
341
342  warningBox.textContent = messages.length
343    ? `Avisos: ${messages.join(' | ')}`
344    : '';
345}
346
347function applyReversePreset(index) {
348  const slot = slots[index];
349  if (!slot.originalBuffer) {
350    slot.statusEl.textContent = 'Carregue um ficheiro primeiro';
351    return;
352  }
353  ensureAudioContext();
354  slot.presetReverse = !slot.presetReverse;
355  const btn = slot.wrapper.querySelector(`#reverse-${index}`);
356  if (slot.presetReverse) {
357    slot.reversedBuffer = reverseBuffer(audioCtx, slot.originalBuffer);
358    btn.classList.add('active-preset');
359    slot.statusEl.textContent = 'Preset: invertido';
360  } else {
361    slot.reversedBuffer = null;
362    btn.classList.remove('active-preset');
363    slot.statusEl.textContent = 'Preset invertido desligado';
364  }
365}
366
367function toggleFadeIn(index) {
368  const slot = slots[index];
369  slot.presetFadeIn = !slot.presetFadeIn;
370  const btn = slot.wrapper.querySelector(`#fade-in-${index}`);
371  btn.classList.toggle('active-preset', slot.presetFadeIn);
372  slot.statusEl.textContent = slot.presetFadeIn ? 'Fade in ativo' : 'Fade in desligado';
373}
374
375function toggleFadeOut(index) {
376  const slot = slots[index];
377  slot.presetFadeOut = !slot.presetFadeOut;
378  const btn = slot.wrapper.querySelector(`#fade-out-${index}`);
379  btn.classList.toggle('active-preset', slot.presetFadeOut);
380  if (slot.loop && slot.presetFadeOut) {
381    slot.statusEl.textContent = 'Fade out só funciona sem loop';
382  } else {
383    slot.statusEl.textContent = slot.presetFadeOut ? 'Fade out ativo' : 'Fade out desligado';
384  }
385}
386
387function createSlot(index) {
388  const slot = {
389    index,
390    key: '',
391    midiNote: '',
392    file: null,
393    storedFileName: '',
394    originalBuffer: null,
395    reversedBuffer: null,
396    volume: DEFAULT_SLOT_VOLUME,
397    loop: false,
398    stopOnNoteOff: false,
399    presetReverse: false,
400    presetFadeIn: false,
401    presetFadeOut: false,
402    activeVoice: null,
403  };
404
405  const wrapper = document.createElement('div');
406  wrapper.className = 'slot';
407
408  wrapper.innerHTML = `
409    <h3>Som ${index + 1}</h3>
410
411    <div class="row">
412      <input id="file-${index}" class="file-input" type="file" accept="${SLOT_FILE_ACCEPT}" hidden>
413      <label for="file-${index}" class="file-button">Escolher ficheiro</label>
414      <div class="filename" id="filename-${index}">Nenhum ficheiro</div>
415    </div>
416
417    <div class="row">
418      <label for="key-${index}">Tecla (Makey Makey)</label>
419      <input
420        id="key-${index}"
421        class="key-input"
422        type="text"
423        maxlength="20"
424        placeholder="Prima aqui para definir a tecla"
425        autocomplete="off"
426      >
427    </div>
428
429    <div class="row">
430      <label for="midi-${index}">Nota MIDI (0–127)</label>
431      <input
432        id="midi-${index}"
433        class="key-input midi-input"
434        type="number"
435        min="0"
436        max="127"
437        placeholder="ex.: 60"
438        autocomplete="off"
439      >
440      <div class="midi-hint" id="midi-hint-${index}"></div>
441    </div>
442
443    <div class="row row-checks">
444      <label><input type="checkbox" id="loop-${index}"> Loop</label>
445      <label><input type="checkbox" id="stop-note-off-${index}"> Parar no note off</label>
446    </div>
447
448    <div class="row">
449      <label for="volume-${index}">Volume do slot</label>
450      <input type="range" id="volume-${index}" min="0" max="100" value="100">
451      <span class="volume-label" id="volume-label-${index}">100%</span>
452    </div>
453
454    <div class="presets">
455      <span class="presets-label">Editar som:</span>
456      <button type="button" class="icon-btn secondary preset-btn" id="reverse-${index}" aria-label="Inverter" title="Inverter">${msIcon('fast_rewind')}</button>
457      <button type="button" class="icon-btn secondary preset-btn" id="fade-in-${index}" aria-label="Fade in" title="Fade in">${msIcon('trending_up')}</button>
458      <button type="button" class="icon-btn secondary preset-btn" id="fade-out-${index}" aria-label="Fade out" title="Fade out">${msIcon('trending_down')}</button>
459    </div>
460
461    <div class="actions">
462      <span class="presets-label">Ações:</span>
463      <button type="button" class="icon-btn" id="play-${index}" aria-label="Tocar" title="Tocar">${msIcon('play_arrow')}</button>
464      <button type="button" class="icon-btn secondary" id="stop-${index}" aria-label="Parar" title="Parar">${msIcon('stop')}</button>
465      <button type="button" class="icon-btn secondary" id="reset-slot-${index}" aria-label="Reset" title="Reset">${msIcon('restart_alt')}</button>
466    </div>
467
468    <div class="status" id="status-${index}" aria-live="polite"></div>
469  `;
470
471  const fileInput = wrapper.querySelector(`#file-${index}`);
472  const keyInput = wrapper.querySelector(`#key-${index}`);
473  const midiInput = wrapper.querySelector(`#midi-${index}`);
474  const midiHint = wrapper.querySelector(`#midi-hint-${index}`);
475  const loopInput = wrapper.querySelector(`#loop-${index}`);
476  const stopNoteOffInput = wrapper.querySelector(`#stop-note-off-${index}`);
477  const volumeInput = wrapper.querySelector(`#volume-${index}`);
478  const volumeLabel = wrapper.querySelector(`#volume-label-${index}`);
479  const filenameEl = wrapper.querySelector(`#filename-${index}`);
480  const status = wrapper.querySelector(`#status-${index}`);
481
482  slot.wrapper = wrapper;
483  slot.keyInput = keyInput;
484  slot.midiInput = midiInput;
485  slot.midiHint = midiHint;
486  slot.filenameEl = filenameEl;
487  slot.statusEl = status;
488  slot.volumeInput = volumeInput;
489  slot.volumeLabel = volumeLabel;
490
491  fileInput.addEventListener('change', async (e) => {
492    const file = e.target.files[0];
493    if (!file) return;
494    try {
495      await loadFileIntoSlot(index, file);
496    } catch (err) {
497      status.textContent = err?.message || 'Erro ao descodificar áudio';
498      fileInput.value = '';
499    }
500  });
501
502  keyInput.addEventListener('keydown', (e) => {
503    e.preventDefault();
504    const pressed = normalizeKey(e.key);
505    if (!pressed) return;
506    keyInput.value = pressed;
507    slot.key = pressed;
508    refreshWarnings();
509  });
510
511  keyInput.addEventListener('input', () => {
512    slot.key = normalizeTypedKey(keyInput.value);
513    keyInput.value = slot.key;
514    refreshWarnings();
515  });
516
517  midiInput.addEventListener('input', () => {
518    const raw = midiInput.value.trim();
519    if (raw === '') {
520      slot.midiNote = '';
521      midiHint.textContent = '';
522    } else {
523      let n = parseInt(raw, 10);
524      if (Number.isNaN(n)) {
525        slot.midiNote = '';
526        midiHint.textContent = 'Número inválido';
527      } else {
528        n = Math.max(0, Math.min(127, n));
529        midiInput.value = String(n);
530        slot.midiNote = n;
531        midiHint.textContent = `= ${midiNoteName(n)}`;
532      }
533    }
534    refreshWarnings();
535  });
536
537  loopInput.addEventListener('change', () => {
538    slot.loop = loopInput.checked;
539    status.textContent = loopInput.checked ? 'Loop ligado' : 'Loop desligado';
540    if (slot.loop && slot.presetFadeOut) {
541      status.textContent += ' (fade out ignorado com loop)';
542    }
543  });
544
545  stopNoteOffInput.addEventListener('change', () => {
546    slot.stopOnNoteOff = stopNoteOffInput.checked;
547  });
548
549  volumeInput.addEventListener('input', () => {
550    slot.volume = Number(volumeInput.value) / 100;
551    volumeLabel.textContent = `${volumeInput.value}%`;
552    applyActiveVoiceVolume(index);
553  });
554
555  wrapper.querySelector(`#reverse-${index}`).addEventListener('click', () => applyReversePreset(index));
556  wrapper.querySelector(`#fade-in-${index}`).addEventListener('click', () => toggleFadeIn(index));
557  wrapper.querySelector(`#fade-out-${index}`).addEventListener('click', () => toggleFadeOut(index));
558  wrapper.querySelector(`#play-${index}`).addEventListener('click', () => triggerSlot(index, { velocity: 127, source: 'ui' }));
559  wrapper.querySelector(`#stop-${index}`).addEventListener('click', () => stopSlot(index));
560  wrapper.querySelector(`#reset-slot-${index}`).addEventListener('click', () => resetSlot(index));
561
562  slots.push(slot);
563  slotsContainer.appendChild(wrapper);
564}
565
566function handleMidiMessage(event) {
567  const [status, note, velocity] = event.data;
568  const command = status & 0xf0;
569
570  const isNoteOn = command === 0x90 && velocity > 0;
571  const isNoteOff = command === 0x80 || (command === 0x90 && velocity === 0);
572
573  if (isNoteOn) {
574    slots.forEach((slot, index) => {
575      if (slot.midiNote !== '' && Number(slot.midiNote) === note) {
576        triggerSlot(index, { velocity, source: 'midi' });
577      }
578    });
579  }
580
581  if (isNoteOff) {
582    slots.forEach((slot, index) => {
583      if (slot.stopOnNoteOff && slot.midiNote !== '' && Number(slot.midiNote) === note) {
584        stopSlot(index);
585      }
586    });
587  }
588}
589
590function bindMidiInput(input) {
591  if (activeMidiInput && midiMessageHandler) {
592    activeMidiInput.removeEventListener('midimessage', midiMessageHandler);
593  }
594  activeMidiInput = input;
595  if (input) {
596    midiMessageHandler = handleMidiMessage;
597    input.addEventListener('midimessage', midiMessageHandler);
598    midiStatus.textContent = `Ligado: ${input.name}`;
599  } else {
600    midiMessageHandler = null;
601    midiStatus.textContent = 'Nenhum dispositivo selecionado';
602  }
603}
604
605function populateMidiInputs(preferredId) {
606  if (!midiAccess) return;
607  const previous = midiSelect.value;
608  midiSelect.innerHTML = '';
609
610  const none = document.createElement('option');
611  none.value = '';
612  none.textContent = '— Nenhum —';
613  midiSelect.appendChild(none);
614
615  const inputs = [...midiAccess.inputs.values()];
616  inputs.forEach((input) => {
617    const opt = document.createElement('option');
618    opt.value = input.id;
619    opt.textContent = input.name || input.id;
620    midiSelect.appendChild(opt);
621  });
622
623  const targetId = preferredId || previous;
624  if (targetId && inputs.some((i) => i.id === targetId)) {
625    midiSelect.value = targetId;
626    bindMidiInput(midiAccess.inputs.get(targetId));
627  } else if (inputs.length === 1) {
628    midiSelect.value = inputs[0].id;
629    bindMidiInput(inputs[0]);
630  } else {
631    midiSelect.value = '';
632    bindMidiInput(null);
633  }
634
635  if (inputs.length === 0) {
636    midiStatus.textContent = 'Sem entradas MIDI disponíveis';
637  }
638}
639
640async function initMidi() {
641  if (!navigator.requestMIDIAccess) {
642    midiStatus.textContent = 'Web MIDI não disponível neste browser';
643    midiSelect.disabled = true;
644    return;
645  }
646  try {
647    midiAccess = await navigator.requestMIDIAccess({ sysex: false });
648    populateMidiInputs();
649    midiAccess.onstatechange = () => populateMidiInputs(midiSelect.value);
650  } catch {
651    midiStatus.textContent = 'Permissão MIDI recusada ou indisponível';
652  }
653}
654
655midiSelect.addEventListener('change', () => {
656  if (!midiAccess) return;
657  const id = midiSelect.value;
658  if (!id) {
659    bindMidiInput(null);
660    return;
661  }
662  bindMidiInput(midiAccess.inputs.get(id) || null);
663});
664
665function formatTime(ms) {
666  const s = Math.floor(ms / 1000);
667  const m = Math.floor(s / 60);
668  const sec = s % 60;
669  return `${m}:${String(sec).padStart(2, '0')}`;
670}
671
672function updateRecordingButtons() {
673  const hasTake = Boolean(recordingBlob && recordingBlob.size > 0);
674  playRecordingBtn.disabled = !hasTake;
675  downloadPerformanceBtn.disabled = !hasTake;
676}
677
678function clearRecordingTake() {
679  if (recordingUrl) URL.revokeObjectURL(recordingUrl);
680  recordingBlob = null;
681  recordingUrl = '';
682  recordingPreview.pause();
683  recordingPreview.removeAttribute('src');
684  updateRecordingButtons();
685}
686
687async function startRecording() {
688  await ensureAudioRunning();
689  clearRecordingTake();
690  recordChunks = [];
691  const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
692    ? 'audio/webm;codecs=opus'
693    : 'audio/webm';
694  mediaRecorder = new MediaRecorder(recordDestination.stream, { mimeType: mime });
695  mediaRecorder.ondataavailable = (e) => {
696    if (e.data.size > 0) recordChunks.push(e.data);
697  };
698  mediaRecorder.onstop = () => {
699    recordingBlob = new Blob(recordChunks, { type: mediaRecorder.mimeType });
700    if (recordingBlob.size === 0) {
701      recordingStatus.textContent = 'Nada gravado — tente disparar sons durante a gravação.';
702      updateRecordingButtons();
703      return;
704    }
705    recordingUrl = URL.createObjectURL(recordingBlob);
706    recordingPreview.src = recordingUrl;
707    recordingStatus.textContent = recordingStoppedByLimit
708      ? 'Gravação limitada a 7 minutos. Pode ouvir ou descarregar (WAV).'
709      : 'Gravação pronta. Pode ouvir ou descarregar (WAV).';
710    recordingStoppedByLimit = false;
711    updateRecordingButtons();
712  };
713  mediaRecorder.start(250);
714  isRecording = true;
715  recordingStoppedByLimit = false;
716  recordStartTime = Date.now();
717  recordBtn.disabled = true;
718  recordBtn.classList.add('is-recording');
719  stopRecordBtn.disabled = false;
720  recordingStatus.textContent = 'A gravar… (máx. 7 min)';
721  recordingTimer.hidden = false;
722  recordTimerInterval = setInterval(() => {
723    const elapsed = Date.now() - recordStartTime;
724    recordingTimer.textContent = formatTime(elapsed);
725    if (elapsed >= MAX_RECORDING_MS) {
726      recordingStoppedByLimit = true;
727      stopRecording();
728    }
729  }, 200);
730}
731
732function stopRecording() {
733  if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
734  mediaRecorder.stop();
735  isRecording = false;
736  recordBtn.disabled = false;
737  recordBtn.classList.remove('is-recording');
738  stopRecordBtn.disabled = true;
739  clearInterval(recordTimerInterval);
740  recordingTimer.hidden = true;
741  recordingTimer.textContent = '0:00';
742}
743
744function playRecording() {
745  if (!recordingUrl) return;
746  playRecordingBtn.classList.add('is-playing');
747  recordingPreview.onended = () => playRecordingBtn.classList.remove('is-playing');
748  recordingPreview.play().catch(() => {
749    playRecordingBtn.classList.remove('is-playing');
750    recordingStatus.textContent = 'Não foi possível reproduzir a gravação';
751  });
752}
753
754function downloadBlob(blob, filename) {
755  const a = document.createElement('a');
756  a.href = URL.createObjectURL(blob);
757  a.download = filename;
758  a.click();
759  setTimeout(() => URL.revokeObjectURL(a.href), 500);
760}
761
762function encodeWavFromAudioBuffer(buffer) {
763  const numChannels = buffer.numberOfChannels;
764  const sampleRate = buffer.sampleRate;
765  const length = buffer.length;
766  const interleaved = new Float32Array(length * numChannels);
767
768  for (let i = 0; i < length; i++) {
769    for (let ch = 0; ch < numChannels; ch++) {
770      interleaved[i * numChannels + ch] = buffer.getChannelData(ch)[i];
771    }
772  }
773
774  const pcm = new Int16Array(interleaved.length);
775  for (let i = 0; i < interleaved.length; i++) {
776    const s = Math.max(-1, Math.min(1, interleaved[i]));
777    pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
778  }
779
780  const byteRate = sampleRate * numChannels * 2;
781  const blockAlign = numChannels * 2;
782  const dataSize = pcm.length * 2;
783  const bufferOut = new ArrayBuffer(44 + dataSize);
784  const view = new DataView(bufferOut);
785
786  const writeStr = (offset, str) => {
787    for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
788  };
789
790  writeStr(0, 'RIFF');
791  view.setUint32(4, 36 + dataSize, true);
792  writeStr(8, 'WAVE');
793  writeStr(12, 'fmt ');
794  view.setUint32(16, 16, true);
795  view.setUint16(20, 1, true);
796  view.setUint16(22, numChannels, true);
797  view.setUint32(24, sampleRate, true);
798  view.setUint32(28, byteRate, true);
799  view.setUint16(32, blockAlign, true);
800  view.setUint16(34, 16, true);
801  writeStr(36, 'data');
802  view.setUint32(40, dataSize, true);
803
804  let offset = 44;
805  for (let i = 0; i < pcm.length; i++, offset += 2) {
806    view.setInt16(offset, pcm[i], true);
807  }
808
809  return new Blob([bufferOut], { type: 'audio/wav' });
810}
811
812async function downloadRecordingWav() {
813  if (!recordingBlob) return;
814  await ensureAudioRunning();
815  const ab = await recordingBlob.arrayBuffer();
816  const decoded = await audioCtx.decodeAudioData(ab.slice(0));
817  const wav = encodeWavFromAudioBuffer(decoded);
818  downloadBlob(wav, `performance-${Date.now()}.wav`);
819}
820
821function slotExportPath(index) {
822  const slot = slots[index];
823  const ext = slot.storedFileName.includes('.')
824    ? slot.storedFileName.split('.').pop()
825    : 'dat';
826  return `sounds/slot-${String(index + 1).padStart(2, '0')}.${ext}`;
827}
828
829function buildProjectJson() {
830  return {
831    version: 1,
832    masterVolume,
833    midiDeviceId: midiSelect.value || '',
834    midiDeviceName: midiSelect.selectedOptions[0]?.textContent || '',
835    slots: slots.map((slot, index) => ({
836      key: slot.key,
837      midiNote: slot.midiNote === '' ? null : Number(slot.midiNote),
838      loop: slot.loop,
839      stopOnNoteOff: slot.stopOnNoteOff,
840      volume: slot.volume,
841      presetReverse: slot.presetReverse,
842      presetFadeIn: slot.presetFadeIn,
843      presetFadeOut: slot.presetFadeOut,
844      audioFile: slot.file ? slotExportPath(index) : null,
845      originalFileName: slot.storedFileName || null,
846    })),
847  };
848}
849
850async function exportProject() {
851  const files = { 'project.json': strToU8(JSON.stringify(buildProjectJson(), null, 2)) };
852
853  for (let i = 0; i < slots.length; i++) {
854    const slot = slots[i];
855    if (slot.file) {
856      const buf = new Uint8Array(await slot.file.arrayBuffer());
857      files[slotExportPath(i)] = buf;
858    }
859  }
860
861  const zipped = zipSync(files, { level: 6 });
862  downloadBlob(new Blob([zipped], { type: 'application/zip' }), `projeto-audio-${Date.now()}.zip`);
863}
864
865async function importProject(file) {
866  const buf = new Uint8Array(await file.arrayBuffer());
867  let unzipped;
868  try {
869    unzipped = unzipSync(buf);
870  } catch {
871    warningBox.textContent = 'Ficheiro ZIP inválido';
872    return;
873  }
874
875  const jsonEntry = unzipped['project.json'];
876  if (!jsonEntry) {
877    warningBox.textContent = 'ZIP sem project.json';
878    return;
879  }
880
881  let project;
882  try {
883    project = JSON.parse(strFromU8(jsonEntry));
884  } catch {
885    warningBox.textContent = 'project.json inválido';
886    return;
887  }
888
889  stopAll();
890  slots.forEach((_, i) => resetSlot(i));
891
892  masterVolume = typeof project.masterVolume === 'number' ? project.masterVolume : 1;
893  masterVolumeInput.value = String(Math.round(masterVolume * 100));
894  masterVolumeLabel.textContent = `${masterVolumeInput.value}%`;
895  if (masterGain) masterGain.gain.value = masterVolume;
896
897  await ensureAudioRunning();
898
899  const errors = [];
900
901  for (let i = 0; i < MAX_SLOTS; i++) {
902    const data = project.slots?.[i];
903    if (!data) continue;
904    const slot = slots[i];
905
906    slot.key = data.key || '';
907    slot.keyInput.value = slot.key;
908
909    if (data.midiNote !== null && data.midiNote !== undefined) {
910      slot.midiNote = data.midiNote;
911      slot.midiInput.value = String(data.midiNote);
912      slot.midiHint.textContent = `= ${midiNoteName(data.midiNote)}`;
913    }
914
915    slot.loop = Boolean(data.loop);
916    slot.wrapper.querySelector(`#loop-${i}`).checked = slot.loop;
917    slot.stopOnNoteOff = Boolean(data.stopOnNoteOff);
918    slot.wrapper.querySelector(`#stop-note-off-${i}`).checked = slot.stopOnNoteOff;
919
920    slot.volume = typeof data.volume === 'number' ? data.volume : DEFAULT_SLOT_VOLUME;
921    slot.volumeInput.value = String(Math.round(slot.volume * 100));
922    slot.volumeLabel.textContent = `${slot.volumeInput.value}%`;
923
924    slot.presetFadeIn = Boolean(data.presetFadeIn);
925    slot.wrapper.querySelector(`#fade-in-${i}`).classList.toggle('active-preset', slot.presetFadeIn);
926    slot.presetFadeOut = Boolean(data.presetFadeOut);
927    slot.wrapper.querySelector(`#fade-out-${i}`).classList.toggle('active-preset', slot.presetFadeOut);
928
929    if (data.audioFile) {
930      const audioBytes = unzipped[data.audioFile];
931      if (!audioBytes) {
932        errors.push(`Som ${i + 1}: ficheiro em falta (${data.audioFile})`);
933        continue;
934      }
935      const name = data.originalFileName || data.audioFile.split('/').pop();
936      const audioFile = new File([audioBytes], name, { type: 'audio/*' });
937      try {
938        await loadFileIntoSlot(i, audioFile);
939        if (data.presetReverse) {
940          slot.presetReverse = true;
941          slot.reversedBuffer = reverseBuffer(audioCtx, slot.originalBuffer);
942          slot.wrapper.querySelector(`#reverse-${i}`).classList.add('active-preset');
943        }
944      } catch (err) {
945        errors.push(`Som ${i + 1}: ${err?.message || 'erro ao descodificar áudio'}`);
946      }
947    }
948  }
949
950  if (project.midiDeviceId && midiAccess) {
951    populateMidiInputs(project.midiDeviceId);
952  }
953
954  refreshWarnings();
955  warningBox.textContent = errors.length ? errors.join(' | ') : '';
956}
957
958document.addEventListener('keydown', (e) => {
959  const tag = document.activeElement?.tagName;
960  const isTyping =
961    tag === 'INPUT' ||
962    tag === 'TEXTAREA' ||
963    document.activeElement?.isContentEditable;
964
965  if (isTyping) return;
966  if (e.repeat) return;
967
968  const pressed = normalizeKey(e.key);
969  slots.forEach((slot, index) => {
970    if (slot.key && slot.key === pressed) {
971      e.preventDefault();
972      triggerSlot(index, { velocity: 127, source: 'keyboard' });
973    }
974  });
975});
976
977masterVolumeInput.addEventListener('input', () => {
978  masterVolume = Number(masterVolumeInput.value) / 100;
979  masterVolumeLabel.textContent = `${masterVolumeInput.value}%`;
980  if (masterGain) masterGain.gain.value = masterVolume;
981});
982
983stopAllBtn.addEventListener('click', stopAll);
984resetBtn.addEventListener('click', () => {
985  if (isRecording) stopRecording();
986  stopAll();
987  slots.forEach((_, i) => resetSlot(i));
988});
989
990enableAudioBtn.addEventListener('click', () => ensureAudioRunning());
991
992exportProjectBtn.addEventListener('click', () => exportProject());
993importProjectInput.addEventListener('change', async (e) => {
994  const file = e.target.files[0];
995  if (file) await importProject(file);
996  importProjectInput.value = '';
997});
998
999recordBtn.addEventListener('click', () => startRecording());
1000stopRecordBtn.addEventListener('click', () => stopRecording());
1001playRecordingBtn.addEventListener('click', () => playRecording());
1002downloadPerformanceBtn.addEventListener('click', () => downloadRecordingWav());
1003
1004for (let i = 0; i < MAX_SLOTS; i++) createSlot(i);
1005updateRecordingButtons();
1006initMidi();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.