diff --git a/src/libraries/p1.js b/src/libraries/p1.js new file mode 100644 index 0000000..cf345ff --- /dev/null +++ b/src/libraries/p1.js @@ -0,0 +1,379 @@ +( function() { + + // Constants for audio setup. + const NUMBER_OF_TRACKS = 4; + const CONTEXTS_PER_TRACK = 3; + const TOTAL_CONTEXTS = Math.ceil( NUMBER_OF_TRACKS * CONTEXTS_PER_TRACK * 1.2 ); + + // Cache for generated note buffers. + const noteBuffers = {}; + + // Create multiple AudioContext objects. + const audioContexts = Array.from( { length: TOTAL_CONTEXTS }, () => new AudioContext() ); + + // Scheduler variables. + let schedulerInterval = null; + let schedules = []; // Array of event arrays per track. + let schedulePointers = []; // Next event index per track. + let playbackStartTime = 0; // When playback starts. + let loopDuration = 0; // Duration (in seconds) of one full loop. + const lookaheadTime = 0.5; // Seconds to schedule ahead. + const schedulerIntervalMs = 1000 * ( lookaheadTime - 0.1 ); // Scheduler check interval. + const volumeMultiplier = 0.1; // Volume multiplier. + + // iOS audio unlock flag. + let unlocked = false; + + // ----------------------------- + // Instrument synthesis functions. + // ----------------------------- + + // Helper sine component. + const sineComponent = ( x, offset ) => { + return Math.sin( x * 6.28 + offset ); + }; + + // Piano: uses a more complex modulation. + const pianoWaveform = ( x ) => { + return sineComponent( + x, + Math.pow( sineComponent( x, 0 ), 2 ) + + sineComponent( x, 0.25 ) * 0.75 + + sineComponent( x, 0.5 ) * 0.1 + ) * volumeMultiplier; + }; + + // Drum: a simple noise burst. + const drumWaveform = ( x ) => { + return ( ( Math.random() * 2 - 1 ) * Math.exp( -x / 10 ) ) * volumeMultiplier; + }; + + const cymbalWaveform = ( x ) => { + return ( 1 * Math.sin( x * 2 ) + 0.3 * ( Math.random() - 0.5 ) ) * Math.exp( -x / 15 ) * volumeMultiplier; + }; + + // Guitar: a simple modulated sine (not a true Karplus–Strong). + const guitarWaveform = ( x ) => { + return ( Math.sin( x * 6.28 ) * Math.sin( x * 3.14 ) ) * volumeMultiplier; + }; + + // Sawtooth waveform. + const sawtoothWaveform = ( x ) => { + let rad = x * 6.28; + let phase = rad % ( 2 * Math.PI ); + let norm = phase / ( 2 * Math.PI ); + return ( 2 * norm - 1 ) * volumeMultiplier; + }; + + // Square waveform. + const squareWaveform = ( x ) => { + let rad = x * 6.28; + return Math.sin( rad ) >= 0 ? volumeMultiplier : -volumeMultiplier; + }; + + // Triangle waveform. + const triangleWaveform = ( x ) => { + let rad = x * 6.28; + let phase = rad % ( 2 * Math.PI ); + let norm = phase / ( 2 * Math.PI ); + return ( 1 - 4 * Math.abs( norm - 0.5 ) ) * volumeMultiplier; + }; + + // Mapping of instrument ids to synthesis functions. + // 0: Piano (default) + // 1: Guitar + // 2: Sawtooth + // 3: Square + // 4: Triangle + // 5: Drum + // 6: Cymbal + const instrumentMapping = [ + pianoWaveform, + guitarWaveform, + sawtoothWaveform, + squareWaveform, + triangleWaveform, + drumWaveform, + cymbalWaveform, + ]; + + // ----------------------------- + // Music player with lookahead scheduling. + // ----------------------------- + + /** + * Main function to play music in the p1 format. + * + * Use it as a tag template literal. For example: + * + * p1` + * 0|f dh d T-X X T X X V| + * 1|Y Y Y Y Y Y Y Y Y Y Y Y Y Y| + * 0|V X T T c c T X| + * 0|c fVa a- X T R aQT Ta RO- X| + * [70.30] + * ` + * + * Tempo lines are detected if the line is entirely numeric (or wrapped in [ ]). + * Track lines must be in the format: instrument|track data| + * + * Passing an empty string stops playback. + * + * @param {Array|string} params Music data. + */ + function p1( params ) { + + if ( Array.isArray( params ) ) { + params = params[ 0 ]; + } + + if ( !params || params.trim() === '' ) { + p1.stop(); + return; + } + + // Default settings. + let tempo = 125; // ms per note step. + let baseNoteDuration = 0.5; // seconds per note. + schedules = []; + + // Split input into lines. + const rawLines = params.split( '\n' ).map( line => line.trim() ); + let noteInterval = tempo / 1000; // in seconds + + // Regular expression for track lines: instrument digit, then |, then track data, then | + const trackLineRegex = /^([0-9])\|(.*)\|$/; + + rawLines.forEach( + line => { + + if ( !line ) return; + + // Check for tempo/note duration line. + // Tempo lines are entirely numeric or wrapped in [ ]. + if ( ( line.startsWith( '[' ) && line.endsWith( ']' ) ) || ( /^\d+(\.\d+)?$/.test( line ) ) ) { + const timing = line.replace( /[\[\]]/g, '' ).split( '.' ); + tempo = parseFloat( timing[ 0 ] ) || tempo; + baseNoteDuration = ( parseFloat( timing[ 1 ] ) || 50 ) / 100; + noteInterval = tempo / 1000; + return; + } + + // Check for track lines in the new format. + if ( !trackLineRegex.test( line ) ) { + console.error( "Track lines must be in the format 'instrument id|track data|': " + line ); + return; + } + + const match = line.match( trackLineRegex ); + const instrumentId = parseInt( match[ 1 ], 10 ); + const instrumentFn = instrumentMapping[ instrumentId ] || instrumentMapping[ 0 ]; + const trackData = match[ 2 ].trim(); + + let events = []; + // Parse trackData character by character. + for ( let i = 0; i < trackData.length; i++ ) { + const char = trackData[ i ]; + let dashCount = 1; + while ( i + dashCount < trackData.length && trackData[ i + dashCount ] === '-' ) { + dashCount++; + } + let eventTime = i * noteInterval; + if ( char === ' ' ) { + events.push( { startTime: eventTime, noteBuffer: null } ); + i += dashCount - 1; + continue; + } + let noteValue = char.charCodeAt( 0 ); + noteValue -= noteValue > 90 ? 71 : 65; + let noteDuration = dashCount * baseNoteDuration * ( tempo / 125 ); + let noteBuffer = createNoteBuffer( noteValue, noteDuration, 44100, instrumentFn ); + events.push( { startTime: eventTime, noteBuffer: noteBuffer } ); + i += dashCount - 1; + } + + schedules.push( events ); + + } + ); + + // console.log( 'schedules', schedules ); + + schedulePointers = schedules.map( () => 0 ); + loopDuration = Math.max( + ...schedules.map( events => + events.length > 0 ? events[ events.length - 1 ].startTime + noteInterval : 0 + ) + ); + playbackStartTime = audioContexts[ 0 ].currentTime + 0.1; + + if ( schedulerInterval !== null ) { + clearInterval( schedulerInterval ); + schedulerInterval = null; + } + + schedulerInterval = setInterval( schedulerFunction, schedulerIntervalMs ); + + } + + + /** + * Lookahead scheduler to schedule note events ahead of time. + * This function is called at regular intervals to schedule note events. + * + * The scheduler uses a lookahead time to schedule events ahead of time. + * + * The scheduler will stop playback if all tracks have reached the end of their events. + * + * The scheduler will stop playback if the p1.loop property is set to false. + * + * This function iterates over scheduled events and plays them when appropriate. + * + */ + function schedulerFunction() { + const currentTime = audioContexts[ 0 ].currentTime; + schedules.forEach( + ( events, trackIndex ) => { + let pointer = schedulePointers[ trackIndex ]; + while ( true ) { + if ( events.length === 0 ) break; + + const localIndex = pointer % events.length; + const loopCount = Math.floor( pointer / events.length ); + const event = events[ localIndex ]; + const eventTime = playbackStartTime + event.startTime + loopCount * loopDuration; + if ( eventTime < currentTime + lookaheadTime ) { + if ( event.noteBuffer ) { + const contextIndex = + ( trackIndex * CONTEXTS_PER_TRACK ) + + ( localIndex % CONTEXTS_PER_TRACK ); + playNoteBuffer( event.noteBuffer, audioContexts[ contextIndex ], eventTime ); + } + pointer++; + schedulePointers[ trackIndex ] = pointer; + } else { + break; + } + } + } + ); + + if ( !p1.loop ) { + const done = schedules.every( ( events, i ) => schedulePointers[ i ] >= events.length ); + if ( done ) { + p1.stop(); + } + } + + } + + + /** + * Stop playback by clearing the scheduler. + * + * @returns {void} + */ + p1.stop = function() { + + if ( schedulerInterval !== null ) { + clearInterval( schedulerInterval ); + schedulerInterval = null; + } + + }; + + + /** + * Check if music is currently playing. + * + * @returns {boolean} True if playing, else false. + */ + p1.isPlaying = function() { + + return schedulerInterval !== null; + + }; + + + // Loop property: set to true to repeat playback. + p1.loop = true; + + + /** + * Create an audio buffer for a given note. + * + * @param {number} note - Note value. + * @param {number} durationSeconds - Duration in seconds. + * @param {number} sampleRate - Sample rate. + * @param {function} instrumentFn - Instrument synthesis function. + * @returns {AudioBuffer} The generated buffer. + */ + const createNoteBuffer = ( note, durationSeconds, sampleRate, instrumentFn ) => { + + // Include instrument function name in key for caching. + const key = note + '-' + durationSeconds + '-' + instrumentFn.name; + let buffer = noteBuffers[ key ]; + + if ( note >= 0 && !buffer ) { + const frequencyFactor = 65.406 * Math.pow( 1.06, note ) / sampleRate; + const totalSamples = Math.floor( sampleRate * durationSeconds ); + const attackSamples = 88; + const decaySamples = sampleRate * ( durationSeconds - 0.002 ); + buffer = noteBuffers[ key ] = audioContexts[ 0 ].createBuffer( 1, totalSamples, sampleRate ); + const channelData = buffer.getChannelData( 0 ); + + for ( let i = 0; i < totalSamples; i++ ) { + let amplitude; + if ( i < attackSamples ) { + amplitude = i / ( attackSamples + 0.2 ); + } else { + amplitude = Math.pow( + 1 - ( ( i - attackSamples ) / decaySamples ), + Math.pow( Math.log( 1e4 * frequencyFactor ) / 2, 2 ) + ); + } + channelData[ i ] = amplitude * instrumentFn( i * frequencyFactor ); + } + + // Unlock audio on iOS if needed. + if ( !unlocked ) { + audioContexts.forEach( + ( context ) => { + playNoteBuffer( buffer, context, context.currentTime, true ); + } + ); + unlocked = true; + } + } + return buffer; + + }; + + + /** + * Play an audio buffer using a given AudioContext at a scheduled time. + * + * @param {AudioBuffer} buffer - The note buffer. + * @param {AudioContext} context - The audio context. + * @param {number} when - Absolute time (in seconds) to start playback. + * @param {boolean} [stopImmediately=false] - Whether to stop immediately after starting. + * @returns {void} + */ + const playNoteBuffer = ( buffer, context, when, stopImmediately = false ) => { + + const source = context.createBufferSource(); + source.buffer = buffer; + source.connect( context.destination ); + source.start( when ); + + if ( stopImmediately ) { + source.stop(); + } + + }; + + + // Expose the p1 function globally. + window.p1 = p1; + +} )(); diff --git a/src/libraries/zzfxm.js b/src/libraries/zzfxm.js deleted file mode 100644 index c655257..0000000 --- a/src/libraries/zzfxm.js +++ /dev/null @@ -1,128 +0,0 @@ -/** - * ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force - */ - -/** - * @typedef Channel - * @type {Array.} - * @property {Number} 0 - Channel instrument - * @property {Number} 1 - Channel panning (-1 to +1) - * @property {Number} 2 - Note - */ - -/** - * @typedef Pattern - * @type {Array.} - */ - -/** - * @typedef Instrument - * @type {Array.} ZzFX sound parameters - */ - -/** - * Generate a song - * - * @param {Array.} instruments - Array of ZzFX sound paramaters. - * @param {Array.} patterns - Array of pattern data. - * @param {Array.} sequence - Array of pattern indexes. - * @param {Number} [speed=125] - Playback speed of the song (in BPM). - * @returns {Array.>} Left and right channel sample data. - */ - -zzfxM = ( instruments, patterns, sequence, BPM = 125 ) => { - let instrumentParameters; - let i; - let j; - let k; - let note; - let sample; - let patternChannel; - let notFirstBeat; - let stop; - let instrument; - let attenuation; - let outSampleOffset; - let isSequenceEnd; - let sampleOffset = 0; - let nextSampleOffset; - let sampleBuffer = []; - let leftChannelBuffer = []; - let rightChannelBuffer = []; - let channelIndex = 0; - let panning = 0; - let hasMore = 1; - let sampleCache = {}; - let beatLength = zzfxR / BPM * 60 >> 2; - - // for each channel in order until there are no more - for ( ; hasMore; channelIndex++ ) { - - // reset current values - sampleBuffer = [ hasMore = notFirstBeat = outSampleOffset = 0 ]; - - // for each pattern in sequence - sequence.map( ( patternIndex, sequenceIndex ) => { - // get pattern for current channel, use empty 1 note pattern if none found - patternChannel = patterns[ patternIndex ][ channelIndex ] || [ 0, 0, 0 ]; - - // check if there are more channels - hasMore |= !!patterns[ patternIndex ][ channelIndex ]; - - // get next offset, use the length of first channel - nextSampleOffset = outSampleOffset + ( patterns[ patternIndex ][ 0 ].length - 2 - !notFirstBeat ) * beatLength; - // for each beat in pattern, plus one extra if end of sequence - isSequenceEnd = sequenceIndex == sequence.length - 1; - for ( i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i ) { - - // - note = patternChannel[ i ]; - - // stop if end, different instrument or new note - stop = i == patternChannel.length + isSequenceEnd - 1 && isSequenceEnd || - instrument != ( patternChannel[ 0 ] || 0 ) | note | 0; - - // fill buffer with samples for previous beat, most cpu intensive part - for ( j = 0; j < beatLength && notFirstBeat; - - // fade off attenuation at end of beat if stopping note, prevents clicking - j++ > beatLength - 99 && stop ? attenuation += ( attenuation < 1 ) / 99 : 0 - ) { - // copy sample to stereo buffers with panning - sample = ( 1 - attenuation ) * sampleBuffer[ sampleOffset++ ] / 2 || 0; - leftChannelBuffer[ k ] = ( leftChannelBuffer[ k ] || 0 ) - sample * panning + sample; - rightChannelBuffer[ k ] = ( rightChannelBuffer[ k++ ] || 0 ) + sample * panning + sample; - } - - // set up for next note - if ( note ) { - // set attenuation - attenuation = note % 1; - panning = patternChannel[ 1 ] || 0; - if ( note |= 0 ) { - // get cached sample - sampleBuffer = sampleCache[ - [ - instrument = patternChannel[ sampleOffset = 0 ] || 0, - note - ] - ] = sampleCache[ [ instrument, note ] ] || ( - // add sample to cache - instrumentParameters = [ ...instruments[ instrument ] ], - instrumentParameters[ 2 ] *= 2 ** ( ( note - 12 ) / 12 ), - - // allow negative values to stop notes - note > 0 ? zzfxG( ...instrumentParameters ) : [] - ); - } - } - } - - // update the sample offset - outSampleOffset = nextSampleOffset; - } ); - } - - return [ leftChannelBuffer, rightChannelBuffer ]; - -}