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.