(function () {
if (typeof window === 'undefined') {
console.warn('bitbop music: window not available.');
return;
}
if (!window.Pizzicato) {
console.warn('bitbop music: Pizzicato library not loaded.');
return;
}
const Pz = window.Pizzicato;
const NOTE_INDEX = {
C: 0,
'C#': 1,
Db: 1,
D: 2,
'D#': 3,
Eb: 3,
E: 4,
F: 5,
'F#': 6,
Gb: 6,
G: 7,
'G#': 8,
Ab: 8,
A: 9,
'A#': 10,
Bb: 10,
B: 11
};
function normalizeNoteName(note) {
if (typeof note === 'number') {
return String(note);
}
if (typeof note !== 'string') {
return '';
}
return note.trim().toUpperCase();
}
function isNumberLike(value) {
return typeof value === 'number' || (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value)));
}
function noteToFrequency(noteInput) {
if (isNumberLike(noteInput)) {
return Number(noteInput);
}
const note = String(noteInput).trim();
const match = note.match(/^([A-Ga-g])([#b]?)(-?\d)$/);
if (!match) {
throw new Error('Invalid note format: ' + noteInput);
}
const [, letterRaw, accidentalRaw, octaveRaw] = match;
const letter = letterRaw.toUpperCase();
const accidental = accidentalRaw || '';
const pitchKey = accidental === '' ? letter : letter + accidental;
const semitone = NOTE_INDEX[pitchKey];
if (typeof semitone !== 'number') {
throw new Error('Unsupported note: ' + noteInput);
}
const octave = parseInt(octaveRaw, 10);
const absoluteIndex = semitone + (octave * 12);
const a4Index = NOTE_INDEX.A + (4 * 12);
const diff = absoluteIndex - a4Index;
const frequency = 440 * Math.pow(2, diff / 12);
return Number(frequency.toFixed(4));
}
function resolvePath(root, path) {
if (!path) {
return { parent: null, key: '', value: root };
}
const segments = path.split('.').filter(Boolean);
let parent = null;
let value = root;
for (const key of segments) {
parent = value;
value = value ? value[key] : undefined;
if (value === undefined) {
break;
}
}
return { parent, key: segments[segments.length - 1] || '', value };
}
class MusicEngine {
constructor(pizzicato) {
this.pizzicato = pizzicato;
this.registry = new Map();
this.reverseRegistry = new WeakMap();
this.nextHandle = 1;
this.waveform = 'sine';
this.volume = 0.8;
this.defaultAttack = 0.01;
this.defaultRelease = 0.2;
this.libraryHandle = this.register(pizzicato, { kind: 'library' });
}
async resume() {
const context = this.pizzicato.context;
if (context && context.state === 'suspended') {
await context.resume();
}
}
register(value, meta = {}) {
if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
return value;
}
const existing = this.reverseRegistry.get(value);
if (existing) {
return existing;
}
const handle = this.nextHandle++;
const kind = meta.kind || this.identify(value);
this.registry.set(handle, { value, meta: { ...meta, kind } });
this.reverseRegistry.set(value, handle);
return handle;
}
identify(value) {
if (!value) {
return 'unknown';
}
if (this.pizzicato.Util && this.pizzicato.Util.isSound && this.pizzicato.Util.isSound(value)) {
return 'sound';
}
if (this.pizzicato.Util && this.pizzicato.Util.isEffect && this.pizzicato.Util.isEffect(value)) {
return 'effect';
}
if (value === this.pizzicato) {
return 'library';
}
return 'object';
}
resolve(handle) {
const entry = this.registry.get(handle);
if (!entry) {
throw new Error('Unknown music handle: ' + handle);
}
return entry;
}
wrapReturn(result) {
if (result === undefined) {
return undefined;
}
if (result === null) {
return null;
}
if (Array.isArray(result)) {
return result.map(item => this.wrapReturn(item));
}
if (typeof result === 'object' || typeof result === 'function') {
return this.register(result);
}
return result;
}
setWaveform(type) {
if (typeof type === 'string' && type.trim()) {
this.waveform = type.trim();
}
}
toFrequency(input) {
return noteToFrequency(input);
}
normalizeNote(note) {
return normalizeNoteName(note);
}
setVolume(value) {
if (typeof value === 'number' && value >= 0 && value <= 1) {
this.volume = value;
this.pizzicato.volume = value;
}
}
setEnvelope(envelope = {}) {
if (typeof envelope === 'object') {
if (typeof envelope.attack === 'number') {
this.defaultAttack = Math.max(0, envelope.attack);
}
if (typeof envelope.release === 'number') {
this.defaultRelease = Math.max(0, envelope.release);
}
}
}
play(note, duration = 0.5, startOffset = 0, options = {}) {
const frequency = noteToFrequency(note);
const normalizedNote = normalizeNoteName(note);
const sound = new this.pizzicato.Sound({
source: 'wave',
options: {
type: options.type || this.waveform,
frequency,
attack: typeof options.attack === 'number' ? options.attack : this.defaultAttack,
release: typeof options.release === 'number' ? options.release : this.defaultRelease
}
});
if (typeof options.volume === 'number') {
sound.volume = Math.max(0, Math.min(1, options.volume));
} else {
sound.volume = this.volume;
}
const handle = this.register(sound, { kind: 'sound', note: normalizedNote });
const startPlayback = () => {
try {
sound.play();
} catch (err) {
console.error('bitbop music: failed to play note', err);
}
if (duration && duration > 0) {
const stopTimer = setTimeout(() => {
this.stop(handle);
}, duration * 1000);
const entry = this.registry.get(handle);
if (entry) {
entry.meta.stopTimer = stopTimer;
}
}
};
if (startOffset && startOffset > 0) {
const startTimer = setTimeout(startPlayback, startOffset * 1000);
const entry = this.registry.get(handle);
if (entry) {
entry.meta.startTimer = startTimer;
}
} else {
startPlayback();
}
return handle;
}
stop(identifier) {
if (identifier === undefined || identifier === null) {
this.stopAll();
return;
}
if (typeof identifier === 'number' && this.registry.has(identifier)) {
this.dispose(identifier, { silent: true });
return;
}
const targetNote = normalizeNoteName(identifier);
if (!targetNote) {
return;
}
const handles = Array.from(this.registry.entries())
.filter(([, entry]) => entry.meta && entry.meta.note === targetNote)
.map(([handle]) => handle);
handles.forEach(handle => this.dispose(handle, { silent: true }));
}
stopAll() {
const handles = Array.from(this.registry.entries())
.filter(([, entry]) => entry.meta && entry.meta.kind === 'sound')
.map(([handle]) => handle);
handles.forEach(handle => this.dispose(handle, { silent: true }));
}
dispose(handle, { silent = false } = {}) {
const entry = this.registry.get(handle);
if (!entry) {
if (!silent) {
console.warn('bitbop music: unknown handle', handle);
}
return;
}
const { value, meta } = entry;
if (meta.startTimer) {
clearTimeout(meta.startTimer);
}
if (meta.stopTimer) {
clearTimeout(meta.stopTimer);
}
if (meta.kind === 'sound' && value && typeof value.stop === 'function') {
try {
value.stop();
} catch (err) {
if (!silent) {
console.error('bitbop music: failed to stop sound', err);
}
}
}
this.registry.delete(handle);
if (value && (typeof value === 'object' || typeof value === 'function')) {
this.reverseRegistry.delete(value);
}
}
invoke(path, args = []) {
const { parent, value } = resolvePath(this.pizzicato, path);
if (typeof value !== 'function') {
throw new Error('Pizzicato path is not callable: ' + path);
}
const result = value.apply(parent || this.pizzicato, args);
return this.wrapReturn(result);
}
getGlobal(path) {
const { value } = resolvePath(this.pizzicato, path);
return this.wrapReturn(value);
}
setGlobal(path, newValue) {
const { parent, key } = resolvePath(this.pizzicato, path);
if (!parent || !key) {
throw new Error('Cannot set property at path: ' + path);
}
parent[key] = newValue;
return this.wrapReturn(newValue);
}
create(path, args = []) {
const { parent, value } = resolvePath(this.pizzicato, path);
if (typeof value !== 'function') {
throw new Error('Cannot construct non-function at path: ' + path);
}
const instance = new value(...args);
return this.register(instance);
}
call(handle, method, args = []) {
const entry = this.resolve(handle);
const target = entry.value;
const fn = target ? target[method] : undefined;
if (typeof fn !== 'function') {
throw new Error('Handle ' + handle + ' has no method ' + method);
}
const result = fn.apply(target, args);
return this.wrapReturn(result);
}
get(handle, property) {
const entry = this.resolve(handle);
const result = entry.value[property];
return this.wrapReturn(result);
}
set(handle, property, value) {
const entry = this.resolve(handle);
entry.value[property] = value;
return this.wrapReturn(entry.value[property]);
}
listHandles() {
return Array.from(this.registry.entries()).map(([handle, entry]) => ({
handle,
kind: entry.meta.kind,
note: entry.meta.note || null
}));
}
}
window.bitbopMusic = new MusicEngine(Pz);
})();