diff --git a/.vscode/settings.json b/.vscode/settings.json index cefbadcf6..8979e4de6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -125,6 +125,11 @@ "target": "windows", "args": ["-debug", "-DLATENCY"] }, + { + "label": "Windows / Debug (Waveform Test)", + "target": "windows", + "args": ["-debug", "-DWAVEFORM"] + }, { "label": "HTML5 / Debug", "target": "html5", diff --git a/assets b/assets index b2f8b6a78..251d4640b 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit b2f8b6a780316959d0a79adc6dbf61f9e4ca675f +Subproject commit 251d4640bd77ee0f0b6122a13f123274c43dd3f5 diff --git a/hmm.json b/hmm.json index 251c5c499..c8b1d911e 100644 --- a/hmm.json +++ b/hmm.json @@ -18,7 +18,7 @@ "name": "flixel-addons", "type": "git", "dir": null, - "ref": "fd3aecdeb5635fa0428dffee204fc78fc26b5885", + "ref": "a523c3b56622f0640933944171efed46929e360e", "url": "https://github.com/FunkinCrew/flixel-addons" }, { diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx index c9198c3d4..23bc255f1 100644 --- a/source/funkin/InitState.hx +++ b/source/funkin/InitState.hx @@ -243,6 +243,8 @@ class InitState extends FlxState FlxG.switchState(new FreeplayState()); #elseif ANIMATE // -DANIMATE FlxG.switchState(new funkin.ui.debug.anim.FlxAnimateTest()); + #elseif WAVEFORM // -DWAVEFORM + FlxG.switchState(new funkin.ui.debug.WaveformTestState()); #elseif CHARTING // -DCHARTING FlxG.switchState(new funkin.ui.debug.charting.ChartEditorState()); #elseif STAGEBUILD // -DSTAGEBUILD diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx index 278578fb3..bc35cc0a7 100644 --- a/source/funkin/audio/FunkinSound.hx +++ b/source/funkin/audio/FunkinSound.hx @@ -7,6 +7,8 @@ import flash.utils.ByteArray; import flixel.sound.FlxSound; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.system.FlxAssets.FlxSoundAsset; +import funkin.util.tools.ICloneable; +import flixel.math.FlxMath; import openfl.Assets; #if (openfl >= "8.0.0") import openfl.utils.AssetType; @@ -17,10 +19,38 @@ import openfl.utils.AssetType; * - Delayed playback via negative song position. */ @:nullSafety -class FunkinSound extends FlxSound +class FunkinSound extends FlxSound implements ICloneable { + static final MAX_VOLUME:Float = 2.0; + static var cache(default, null):FlxTypedGroup = new FlxTypedGroup(); + public var muted(default, set):Bool = false; + + function set_muted(value:Bool):Bool + { + if (value == muted) return value; + muted = value; + updateTransform(); + return value; + } + + override function set_volume(value:Float):Float + { + // Uncap the volume. + fixMaxVolume(); + _volume = FlxMath.bound(value, 0.0, MAX_VOLUME); + updateTransform(); + return _volume; + } + + public var paused(get, never):Bool; + + function get_paused():Bool + { + return this._paused; + } + public var isPlaying(get, never):Bool; function get_isPlaying():Bool @@ -63,6 +93,30 @@ class FunkinSound extends FlxSound } } + public function togglePlayback():FunkinSound + { + if (playing) + { + pause(); + } + else + { + resume(); + } + return this; + } + + function fixMaxVolume():Void + { + #if lime_openal + // This code is pretty fragile, it reaches through 5 layers of private access. + @:privateAccess + var handle = this?._channel?.__source?.__backend?.handle; + if (handle == null) return; + lime.media.openal.AL.sourcef(handle, lime.media.openal.AL.MAX_GAIN, MAX_VOLUME); + #end + } + public override function play(forceRestart:Bool = false, startTime:Float = 0, ?endTime:Float):FunkinSound { if (!exists) return this; @@ -140,6 +194,33 @@ class FunkinSound extends FlxSound return this; } + /** + * Call after adjusting the volume to update the sound channel's settings. + */ + @:allow(flixel.sound.FlxSoundGroup) + override function updateTransform():Void + { + _transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end + (group != null ? group.volume : 1) * _volume * _volumeAdjust; + + if (_channel != null) _channel.soundTransform = _transform; + } + + public function clone():FunkinSound + { + var sound:FunkinSound = new FunkinSound(); + + // Clone the sound by creating one with the same data buffer. + // Reusing the `Sound` object directly causes issues with playback. + @:privateAccess + sound._sound = openfl.media.Sound.fromAudioBuffer(this._sound.__buffer); + + // Call init to ensure the FlxSound is properly initialized. + sound.init(this.looped, this.autoDestroy, this.onComplete); + + return sound; + } + /** * Creates a new `FunkinSound` object. * diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx index 528aaa80c..64104fee7 100644 --- a/source/funkin/audio/SoundGroup.hx +++ b/source/funkin/audio/SoundGroup.hx @@ -16,6 +16,8 @@ class SoundGroup extends FlxTypedGroup public var pitch(get, set):Float; + public var playing(get, never):Bool; + public function new() { super(); @@ -165,6 +167,13 @@ class SoundGroup extends FlxTypedGroup return time; } + function get_playing():Bool + { + if (getFirstAlive != null) return getFirstAlive().playing; + else + return false; + } + function get_volume():Float { if (getFirstAlive() != null) return getFirstAlive().volume; diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx index 42f31af70..70a01f9dc 100644 --- a/source/funkin/audio/VoicesGroup.hx +++ b/source/funkin/audio/VoicesGroup.hx @@ -2,6 +2,8 @@ package funkin.audio; import funkin.audio.FunkinSound; import flixel.group.FlxGroup.FlxTypedGroup; +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformDataParser; class VoicesGroup extends SoundGroup { @@ -104,6 +106,50 @@ class VoicesGroup extends SoundGroup return opponentVolume = volume; } + public function getPlayerVoice(index:Int = 0):Null + { + return playerVoices.members[index]; + } + + public function getOpponentVoice(index:Int = 0):Null + { + return opponentVoices.members[index]; + } + + public function buildPlayerVoiceWaveform():Null + { + if (playerVoices.members.length == 0) return null; + + return WaveformDataParser.interpretFlxSound(playerVoices.members[0]); + } + + public function buildOpponentVoiceWaveform():Null + { + if (opponentVoices.members.length == 0) return null; + + return WaveformDataParser.interpretFlxSound(opponentVoices.members[0]); + } + + /** + * The length of the player's vocal track, in milliseconds. + */ + public function getPlayerVoiceLength():Float + { + if (playerVoices.members.length == 0) return 0.0; + + return playerVoices.members[0].length; + } + + /** + * The length of the opponent's vocal track, in milliseconds. + */ + public function getOpponentVoiceLength():Float + { + if (opponentVoices.members.length == 0) return 0.0; + + return opponentVoices.members[0].length; + } + public override function clear():Void { playerVoices.clear(); diff --git a/source/funkin/audio/visualize/PolygonSpectogram.hx b/source/funkin/audio/visualize/PolygonSpectogram.hx index 37a6c15d1..948027a8d 100644 --- a/source/funkin/audio/visualize/PolygonSpectogram.hx +++ b/source/funkin/audio/visualize/PolygonSpectogram.hx @@ -102,7 +102,7 @@ class PolygonSpectogram extends MeshRender coolPoint.x = (curAud.balanced * waveAmplitude); coolPoint.y = (i / funnyPixels * daHeight); - add_quad(prevPoint.x, prevPoint.y, prevPoint.x + build_quad(prevPoint.x, prevPoint.y, prevPoint.x + thickness, prevPoint.y, coolPoint.x, coolPoint.y, coolPoint.x + thickness, coolPoint.y + thickness); diff --git a/source/funkin/audio/waveform/WaveformData.hx b/source/funkin/audio/waveform/WaveformData.hx new file mode 100644 index 000000000..31f8dfe02 --- /dev/null +++ b/source/funkin/audio/waveform/WaveformData.hx @@ -0,0 +1,304 @@ +package funkin.audio.waveform; + +import funkin.util.MathUtil; + +@:nullSafety +class WaveformData +{ + static final DEFAULT_VERSION:Int = 2; + + /** + * The version of the waveform data format. + * @default `2` (-1 if not specified/invalid) + */ + public var version(default, null):Int = -1; + + /** + * The number of channels in the waveform. + */ + public var channels(default, null):Int = 1; + + @:alias('sample_rate') + public var sampleRate(default, null):Int = 44100; + + /** + * Number of input audio samples per output waveform data point. + * At base zoom level this is number of samples per pixel. + * Lower values can more accurately represent the waveform when zoomed in, but take more data. + */ + @:alias('samples_per_pixel') + public var samplesPerPoint(default, null):Int = 256; + + /** + * Number of bits to use for each sample value. Valid values are `8` and `16`. + */ + public var bits(default, null):Int = 16; + + /** + * The length of the data array, in points. + */ + public var length(default, null):Int = 0; + + /** + * Array of Int16 values representing the waveform. + * TODO: Use an `openfl.Vector` for performance. + */ + public var data(default, null):Array = []; + + @:jignored + var channelData:Null> = null; + + public function new(?version:Int, channels:Int, sampleRate:Int, samplesPerPoint:Int, bits:Int, length:Int, data:Array) + { + this.version = version ?? DEFAULT_VERSION; + this.channels = channels; + this.sampleRate = sampleRate; + this.samplesPerPoint = samplesPerPoint; + this.bits = bits; + this.length = length; + this.data = data; + } + + function buildChannelData():Array + { + channelData = []; + for (i in 0...channels) + { + channelData.push(new WaveformDataChannel(this, i)); + } + return channelData; + } + + public function channel(index:Int) + { + return (channelData == null) ? buildChannelData()[index] : channelData[index]; + } + + public function get(index:Int):Int + { + return data[index] ?? 0; + } + + public function set(index:Int, value:Int) + { + data[index] = value; + } + + /** + * Maximum possible value for a waveform data point. + * The minimum possible value is (-1 * maxSampleValue) + */ + public function maxSampleValue():Int + { + if (_maxSampleValue != 0) return _maxSampleValue; + return _maxSampleValue = Std.int(Math.pow(2, bits)); + } + + /** + * Cache the value because `Math.pow` is expensive and the value gets used a lot. + */ + @:jignored + var _maxSampleValue:Int = 0; + + /** + * @return The length of the waveform in samples. + */ + public function lenSamples():Int + { + return length * samplesPerPoint; + } + + /** + * @return The length of the waveform in seconds. + */ + public function lenSeconds():Float + { + return inline lenSamples() / sampleRate; + } + + /** + * Given the time in seconds, return the waveform data point index. + */ + public function secondsToIndex(seconds:Float):Int + { + return Std.int(seconds * inline pointsPerSecond()); + } + + /** + * Given a waveform data point index, return the time in seconds. + */ + public function indexToSeconds(index:Int):Float + { + return index / inline pointsPerSecond(); + } + + /** + * The number of data points this waveform data provides per second of audio. + */ + public inline function pointsPerSecond():Float + { + return sampleRate / samplesPerPoint; + } + + /** + * Given the percentage progress through the waveform, return the waveform data point index. + */ + public function percentToIndex(percent:Float):Int + { + return Std.int(percent * length); + } + + /** + * Given a waveform data point index, return the percentage progress through the waveform. + */ + public function indexToPercent(index:Int):Float + { + return index / length; + } + + /** + * Resample the waveform data to create a new WaveformData object matching the desired `samplesPerPoint` value. + * This is useful for zooming in/out of the waveform in a performant manner. + * + * @param newSamplesPerPoint The new value for `samplesPerPoint`. + */ + public function resample(newSamplesPerPoint:Int):WaveformData + { + var result = this.clone(); + + var ratio = newSamplesPerPoint / samplesPerPoint; + if (ratio == 1) return result; + if (ratio < 1) trace('[WARNING] Downsampling will result in a low precision.'); + + var inputSampleCount = this.lenSamples(); + var outputSampleCount = Std.int(inputSampleCount * ratio); + + var inputPointCount = this.length; + var outputPointCount = Std.int(inputPointCount / ratio); + var outputChannelCount = this.channels; + + // TODO: Actually figure out the dumbass logic for this. + + return result; + } + + /** + * Create a new WaveformData whose parameters match the current object. + */ + public function clone(?newData:Array = null):WaveformData + { + if (newData == null) + { + newData = this.data.clone(); + } + + var clone = new WaveformData(this.version, this.channels, this.sampleRate, this.samplesPerPoint, this.bits, newData.length, newData); + + return clone; + } +} + +@:nullSafety +class WaveformDataChannel +{ + var parent:WaveformData; + var channelId:Int; + + public function new(parent:WaveformData, channelId:Int) + { + this.parent = parent; + this.channelId = channelId; + } + + /** + * Retrieve a given minimum point at an index. + */ + public function minSample(i:Int) + { + var offset = (i * parent.channels + this.channelId) * 2; + return inline parent.get(offset); + } + + /** + * Mapped to a value between 0 and 1. + */ + public function minSampleMapped(i:Int) + { + return inline minSample(i) / inline parent.maxSampleValue(); + } + + /** + * Minimum value within the range of samples. + * NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead. + */ + public function minSampleRange(start:Int, end:Int) + { + var min = inline parent.maxSampleValue(); + for (i in start...end) + { + var sample = inline minSample(i); + if (sample < min) min = sample; + } + return min; + } + + /** + * Maximum value within the range of samples, mapped to a value between 0 and 1. + */ + public function minSampleRangeMapped(start:Int, end:Int) + { + return inline minSampleRange(start, end) / inline parent.maxSampleValue(); + } + + /** + * Retrieve a given maximum point at an index. + */ + public function maxSample(i:Int) + { + var offset = (i * parent.channels + this.channelId) * 2 + 1; + return inline parent.get(offset); + } + + /** + * Mapped to a value between 0 and 1. + */ + public function maxSampleMapped(i:Int) + { + return inline maxSample(i) / inline parent.maxSampleValue(); + } + + /** + * Maximum value within the range of samples. + * NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead. + */ + public function maxSampleRange(start:Int, end:Int) + { + var max = -(inline parent.maxSampleValue()); + for (i in start...end) + { + var sample = inline maxSample(i); + if (sample > max) max = sample; + } + return max; + } + + /** + * Maximum value within the range of samples, mapped to a value between 0 and 1. + */ + public function maxSampleRangeMapped(start:Int, end:Int) + { + return inline maxSampleRange(start, end) / inline parent.maxSampleValue(); + } + + public function setMinSample(i:Int, value:Int) + { + var offset = (i * parent.channels + this.channelId) * 2; + inline parent.set(offset, value); + } + + public function setMaxSample(i:Int, value:Int) + { + var offset = (i * parent.channels + this.channelId) * 2 + 1; + inline parent.set(offset, value); + } +} diff --git a/source/funkin/audio/waveform/WaveformDataParser.hx b/source/funkin/audio/waveform/WaveformDataParser.hx new file mode 100644 index 000000000..2e5c52d13 --- /dev/null +++ b/source/funkin/audio/waveform/WaveformDataParser.hx @@ -0,0 +1,140 @@ +package funkin.audio.waveform; + +class WaveformDataParser +{ + static final INT16_MAX:Int = 32767; + static final INT16_MIN:Int = -32768; + + static final INT8_MAX:Int = 127; + static final INT8_MIN:Int = -128; + + public static function interpretFlxSound(sound:flixel.sound.FlxSound):Null + { + if (sound == null) return null; + + // Method 1. This only works if the sound has been played before. + @:privateAccess + var soundBuffer:Null = sound?._channel?.__source?.buffer; + + if (soundBuffer == null) + { + // Method 2. This works if the sound has not been played before. + @:privateAccess + soundBuffer = sound?._sound?.__buffer; + + if (soundBuffer == null) + { + trace('[WAVEFORM] Failed to interpret FlxSound: ${sound}'); + return null; + } + else + { + trace('[WAVEFORM] Method 2 worked.'); + } + } + else + { + trace('[WAVEFORM] Method 1 worked.'); + } + + return interpretAudioBuffer(soundBuffer); + } + + public static function interpretAudioBuffer(soundBuffer:lime.media.AudioBuffer):Null + { + var sampleRate = soundBuffer.sampleRate; + var channels = soundBuffer.channels; + var bitsPerSample = soundBuffer.bitsPerSample; + var samplesPerPoint:Int = 256; // I don't think we need to configure this. + var pointsPerSecond:Float = sampleRate / samplesPerPoint; // 172 samples per second for most songs is plenty precise while still being performant.. + + // TODO: Make this work better on HTML5. + var soundData:lime.utils.Int16Array = cast soundBuffer.data; + + var soundDataRawLength:Int = soundData.length; + var soundDataSampleCount:Int = Std.int(Math.ceil(soundDataRawLength / channels / (bitsPerSample == 16 ? 2 : 1))); + var outputPointCount:Int = Std.int(Math.ceil(soundDataSampleCount / samplesPerPoint)); + + trace('Interpreting audio buffer:'); + trace(' sampleRate: ${sampleRate}'); + trace(' channels: ${channels}'); + trace(' bitsPerSample: ${bitsPerSample}'); + trace(' samplesPerPoint: ${samplesPerPoint}'); + trace(' pointsPerSecond: ${pointsPerSecond}'); + trace(' soundDataRawLength: ${soundDataRawLength}'); + trace(' soundDataSampleCount: ${soundDataSampleCount}'); + trace(' soundDataRawLength/4: ${soundDataRawLength / 4}'); + trace(' outputPointCount: ${outputPointCount}'); + + var minSampleValue:Int = bitsPerSample == 16 ? INT16_MIN : INT8_MIN; + var maxSampleValue:Int = bitsPerSample == 16 ? INT16_MAX : INT8_MAX; + + var outputData:Array = []; + + for (pointIndex in 0...outputPointCount) + { + // minChannel1, maxChannel1, minChannel2, maxChannel2, ... + var values:Array = []; + + for (i in 0...channels) + { + values.push(bitsPerSample == 16 ? INT16_MAX : INT8_MAX); + values.push(bitsPerSample == 16 ? INT16_MIN : INT8_MIN); + } + + var rangeStart = pointIndex * samplesPerPoint; + var rangeEnd = rangeStart + samplesPerPoint; + if (rangeEnd > soundDataSampleCount) rangeEnd = soundDataSampleCount; + + for (sampleIndex in rangeStart...rangeEnd) + { + for (channelIndex in 0...channels) + { + var sampleIndex:Int = sampleIndex * channels + channelIndex; + var sampleValue = soundData[sampleIndex]; + + if (sampleValue < values[channelIndex * 2]) values[(channelIndex * 2)] = sampleValue; + if (sampleValue > values[channelIndex * 2 + 1]) values[(channelIndex * 2) + 1] = sampleValue; + } + } + + // We now have the min and max values for the range. + for (value in values) + outputData.push(value); + } + + var outputDataLength:Int = Std.int(outputData.length / channels / 2); + var result = new WaveformData(null, channels, sampleRate, samplesPerPoint, bitsPerSample, outputPointCount, outputData); + + return result; + } + + public static function parseWaveformData(path:String):Null + { + var rawJson:String = openfl.Assets.getText(path).trim(); + return parseWaveformDataString(rawJson, path); + } + + public static function parseWaveformDataString(contents:String, ?fileName:String):Null + { + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + trace('[WAVEFORM] Parsing waveform data: ${contents}'); + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return parser.value; + } + + static function printErrors(errors:Array, id:String = ''):Void + { + trace('[WAVEFORM] Failed to parse waveform data: ${id}'); + + for (error in errors) + funkin.data.DataError.printError(error); + } +} diff --git a/source/funkin/audio/waveform/WaveformSprite.hx b/source/funkin/audio/waveform/WaveformSprite.hx new file mode 100644 index 000000000..32ced2fbd --- /dev/null +++ b/source/funkin/audio/waveform/WaveformSprite.hx @@ -0,0 +1,449 @@ +package funkin.audio.waveform; + +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformDataParser; +import funkin.graphics.rendering.MeshRender; +import flixel.util.FlxColor; + +class WaveformSprite extends MeshRender +{ + static final DEFAULT_COLOR:FlxColor = FlxColor.WHITE; + static final DEFAULT_DURATION:Float = 5.0; + static final DEFAULT_ORIENTATION:WaveformOrientation = HORIZONTAL; + static final DEFAULT_X:Float = 0.0; + static final DEFAULT_Y:Float = 0.0; + static final DEFAULT_WIDTH:Float = 100.0; + static final DEFAULT_HEIGHT:Float = 100.0; + + /** + * Set this to true to tell the waveform to rebuild itself. + * Do this any time the data or drawable area of the waveform changes. + * This often (but not always) needs to be done every frame. + */ + var isWaveformDirty:Bool = true; + + /** + * If true, force the waveform to redraw every frame. + * Useful if the waveform's clipRect is constantly changing. + */ + public var forceUpdate:Bool = false; + + public var waveformData(default, set):Null; + + function set_waveformData(value:Null):Null + { + if (waveformData == value) return value; + + waveformData = value; + isWaveformDirty = true; + return waveformData; + } + + /** + * The color to render the waveform with. + */ + public var waveformColor(default, set):FlxColor; + + function set_waveformColor(value:FlxColor):FlxColor + { + if (waveformColor == value) return value; + + waveformColor = value; + // We don't need to dirty the waveform geometry, just rebuild the texture. + rebuildGraphic(); + return waveformColor; + } + + public var orientation(default, set):WaveformOrientation; + + function set_orientation(value:WaveformOrientation):WaveformOrientation + { + if (orientation == value) return value; + + orientation = value; + isWaveformDirty = true; + return orientation; + } + + /** + * Time, in seconds, at which the waveform starts. + */ + public var time(default, set):Float; + + function set_time(value:Float) + { + if (time == value) return value; + + time = value; + isWaveformDirty = true; + return time; + } + + /** + * The duration, in seconds, that the waveform represents. + * The section of waveform from `time` to `time + duration` and `width` are used to determine how many samples each pixel represents. + */ + public var duration(default, set):Float; + + function set_duration(value:Float) + { + if (duration == value) return value; + + duration = value; + isWaveformDirty = true; + return duration; + } + + /** + * Set the physical size of the waveform with `this.height = value`. + */ + override function set_height(value:Float):Float + { + if (height == value) return super.set_height(value); + + isWaveformDirty = true; + return super.set_height(value); + } + + /** + * Set the physical size of the waveform with `this.width = value`. + */ + override function set_width(value:Float):Float + { + if (width == value) return super.set_width(value); + + isWaveformDirty = true; + return super.set_width(value); + } + + /** + * The minimum size, in pixels, that a waveform will display with. + * Useful for preventing the waveform from becoming too small to see. + * + * NOTE: This is technically doubled since it's applied above and below the center of the waveform. + */ + public var minWaveformSize:Int = 1; + + /** + * A multiplier on the size of the waveform. + * Still capped at the width and height set for the sprite. + */ + public var amplitude:Float = 1.0; + + public function new(?waveformData:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float) + { + super(DEFAULT_X, DEFAULT_Y, DEFAULT_COLOR); + this.waveformColor = color ?? DEFAULT_COLOR; + this.width = DEFAULT_WIDTH; + this.height = DEFAULT_HEIGHT; + + this.waveformData = waveformData; + this.orientation = orientation ?? DEFAULT_ORIENTATION; + this.time = 0.0; + this.duration = duration ?? DEFAULT_DURATION; + + this.forceUpdate = false; + } + + /** + * Manually tell the waveform to rebuild itself, even if none of its properties have changed. + */ + public function markDirty():Void + { + isWaveformDirty = true; + } + + public override function update(elapsed:Float) + { + super.update(elapsed); + + if (forceUpdate || isWaveformDirty) + { + // Recalculate the waveform vertices. + drawWaveform(); + isWaveformDirty = false; + } + } + + function rebuildGraphic():Void + { + // The waveform is rendered using a single colored pixel as a texture. + // If you want something more elaborate, make sure to modify `build_vertex` below to use the UVs you want. + makeGraphic(1, 1, this.waveformColor); + } + + /** + * @param offsetX Horizontal offset to draw the waveform at, in samples. + */ + function drawWaveform():Void + { + // For each sample in the waveform... + // Add a MAX vertex and a MIN vertex. + // If previous MAX/MIN is empty, store. + // If previous MAX/MIN is not empty, draw a quad using current and previous MAX/MIN. Then store current MAX/MIN. + // Continue until end of waveform. + + this.clear(); + + if (waveformData == null) return; + + // Center point of the waveform. When horizontal this is half the height, when vertical this is half the width. + var waveformCenterPos:Int = orientation == HORIZONTAL ? Std.int(this.height / 2) : Std.int(this.width / 2); + + var oneSecondInIndices:Int = waveformData.secondsToIndex(1); + + var startTime:Float = time; + var endTime:Float = time + duration; + + var startIndex:Int = waveformData.secondsToIndex(startTime); + var endIndex:Int = waveformData.secondsToIndex(endTime); + + var pixelsPerIndex:Float = (orientation == HORIZONTAL ? this.width : this.height) / (endIndex - startIndex); + var indexesPerPixel:Float = 1 / pixelsPerIndex; + + var topLeftVertexIndex:Int = -1; + var topRightVertexIndex:Int = -1; + var bottomLeftVertexIndex:Int = -1; + var bottomRightVertexIndex:Int = -1; + + if (clipRect != null) + { + topLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y); + topRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y); + bottomLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y + clipRect.height); + bottomRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y + clipRect.height); + } + + if (pixelsPerIndex >= 1.0) + { + // Each index is at least one pixel wide, so we render each index. + var prevVertexTopIndex:Int = -1; + var prevVertexBottomIndex:Int = -1; + for (i in startIndex...endIndex) + { + var pixelPos:Int = Std.int((i - startIndex) * pixelsPerIndex); + + var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y); + + if (isBeforeClipRect) continue; + + var isAfterClipRect:Bool = (clipRect != null) + && ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height)); + + if (isAfterClipRect) + { + break; + }; + + var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleMapped(i) * amplitude, 1.0); + var sampleMin:Float = Math.max(waveformData.channel(0).minSampleMapped(i) * amplitude, -1.0); + var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize; + var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize; + var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize); + var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize); + + if (vertexBottomY - vertexTopY < minWaveformSize) vertexTopY = vertexBottomY - minWaveformSize; + + var vertexTopIndex:Int = -1; + var vertexBottomIndex:Int = -1; + + if (clipRect != null) + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + else + { + vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + } + else + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = this.build_vertex(pixelPos, vertexTopY); + vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY); + } + else + { + vertexTopIndex = this.build_vertex(vertexTopY, pixelPos); + vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos); + } + } + + // Don't render if we don't have a previous different set of vertices to create a quad from. + if (prevVertexTopIndex != -1 + && prevVertexBottomIndex != -1 + && prevVertexTopIndex != vertexTopIndex + && prevVertexBottomIndex != vertexBottomIndex) + { + switch (orientation) // the line of code that makes you gay + { + case HORIZONTAL: + this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex); + case VERTICAL: + this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex); + } + } + + prevVertexTopIndex = vertexTopIndex; + prevVertexBottomIndex = vertexBottomIndex; + } + } + else + { + // Indexes are less than one pixel wide, so for each pixel we render the maximum of the samples that fall within it. + var prevVertexTopIndex:Int = -1; + var prevVertexBottomIndex:Int = -1; + var waveformLengthPixels:Int = orientation == HORIZONTAL ? Std.int(this.width) : Std.int(this.height); + for (i in 0...waveformLengthPixels) + { + var pixelPos:Int = i; + + var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y); + + if (isBeforeClipRect) continue; + + var isAfterClipRect:Bool = (clipRect != null) + && ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height)); + + if (isAfterClipRect) + { + break; + }; + + // Wrap Std.int around the whole range calculation, not just indexesPerPixel, otherwise you get weird issues with zooming. + var rangeStart:Int = Std.int(i * indexesPerPixel + startIndex); + var rangeEnd:Int = Std.int((i + 1) * indexesPerPixel + startIndex); + + var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleRangeMapped(rangeStart, rangeEnd) * amplitude, 1.0); + var sampleMin:Float = Math.max(waveformData.channel(0).minSampleRangeMapped(rangeStart, rangeEnd) * amplitude, -1.0); + var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize; + var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize; + var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize); + var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize); + + var vertexTopIndex:Int = -1; + var vertexBottomIndex:Int = -1; + + if (clipRect != null) + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + else + { + vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + } + else + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = this.build_vertex(pixelPos, vertexTopY); + vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY); + } + else + { + vertexTopIndex = this.build_vertex(vertexTopY, pixelPos); + vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos); + } + } + + if (prevVertexTopIndex != -1 && prevVertexBottomIndex != -1) + { + switch (orientation) + { + case HORIZONTAL: + this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex); + case VERTICAL: + this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex); + } + } + prevVertexTopIndex = vertexTopIndex; + prevVertexBottomIndex = vertexBottomIndex; + } + } + } + + function buildClippedVertex(x:Int, y:Int, topLeftVertexIndex:Int, topRightVertexIndex:Int, bottomLeftVertexIndex:Int, bottomRightVertexIndex:Int):Int + { + var shouldClipXLeft = x < clipRect.x; + var shouldClipXRight = x > (clipRect.x + clipRect.width); + var shouldClipYTop = y < clipRect.y; + var shouldClipYBottom = y > (clipRect.y + clipRect.height); + + // If the vertex is fully outside the clipRect, use a pre-existing vertex. + // Else, if the vertex is outside the clipRect on one axis, create a new vertex constrained on that axis. + // Else, create a whole new vertex. + if (shouldClipXLeft && shouldClipYTop) + { + return topLeftVertexIndex; + } + else if (shouldClipXRight && shouldClipYTop) + { + return topRightVertexIndex; + } + else if (shouldClipXLeft && shouldClipYBottom) + { + return bottomLeftVertexIndex; + } + else if (shouldClipXRight && shouldClipYBottom) + { + return bottomRightVertexIndex; + } + else if (shouldClipXLeft) + { + return this.build_vertex(clipRect.x, y); + } + else if (shouldClipXRight) + { + return this.build_vertex(clipRect.x + clipRect.width, y); + } + else if (shouldClipYTop) + { + return this.build_vertex(x, clipRect.y); + } + else if (shouldClipYBottom) + { + return this.build_vertex(x, clipRect.y + clipRect.height); + } + else + { + return this.build_vertex(x, y); + } + } + + public static function buildFromWaveformData(data:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float) + { + return new WaveformSprite(data, orientation, color, duration); + } + + public static function buildFromFunkinSound(sound:FunkinSound, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float) + { + // TODO: Build waveform data from FunkinSound. + var data = null; + + return buildFromWaveformData(data, orientation, color, duration); + } +} + +enum WaveformOrientation +{ + HORIZONTAL; + VERTICAL; +} diff --git a/source/funkin/graphics/rendering/MeshRender.hx b/source/funkin/graphics/rendering/MeshRender.hx index 39402808a..a06d53337 100644 --- a/source/funkin/graphics/rendering/MeshRender.hx +++ b/source/funkin/graphics/rendering/MeshRender.hx @@ -12,22 +12,19 @@ class MeshRender extends FlxStrip public var vertex_count(default, null):Int = 0; public var index_count(default, null):Int = 0; - var tri_offset:Int = 0; - public function new(x, y, ?col:FlxColor = FlxColor.WHITE) { super(x, y); makeGraphic(1, 1, col); } - public inline function start() + /** + * Add a vertex. + */ + public inline function build_vertex(x:Float, y:Float, u:Float = 0, v:Float = 0):Int { - tri_offset = vertex_count; - } - - public inline function add_vertex(x:Float, y:Float, u:Float = 0, v:Float = 0) - { - final pos = vertex_count << 1; + final index = vertex_count; + final pos = index << 1; vertices[pos] = x; vertices[pos + 1] = y; @@ -36,48 +33,72 @@ class MeshRender extends FlxStrip uvtData[pos + 1] = v; vertex_count++; + return index; } - public function add_tri(a:Int, b:Int, c:Int) + /** + * Build a triangle from three vertex indexes. + * @param a + * @param b + * @param c + */ + public function add_tri(a:Int, b:Int, c:Int):Void { - indices[index_count] = a + tri_offset; - indices[index_count + 1] = b + tri_offset; - indices[index_count + 2] = c + tri_offset; + indices[index_count] = a; + indices[index_count + 1] = b; + indices[index_count + 2] = c; index_count += 3; } - /** - * - * top left - a - * - * top right - b - * - * bottom left - c - * - * bottom right - d - */ - public function add_quad(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, dx:Float, dy:Float, au:Float = 0, av:Float = 0, bu:Float = 0, - bv:Float = 0, cu:Float = 0, cv:Float = 0, du:Float = 0, dv:Float = 0) + public function build_tri(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, au:Float = 0, av:Float = 0, bu:Float = 0, bv:Float = 0, cu:Float = 0, + cv:Float = 0):Void { - start(); - // top left - add_vertex(bx, by, bu, bv); - // top right - add_vertex(ax, ay, au, av); - // bottom left - add_vertex(cx, cy, cu, cv); - // bottom right - add_vertex(dx, dy, du, dv); + add_tri(build_vertex(ax, ay, au, av), build_vertex(bx, by, bu, bv), build_vertex(cx, cy, cu, cv)); + } - add_tri(0, 1, 2); - add_tri(0, 2, 3); + /** + * @param a top left vertex + * @param b top right vertex + * @param c bottom right vertex + * @param d bottom left vertex + */ + public function add_quad(a:Int, b:Int, c:Int, d:Int):Void + { + add_tri(a, b, c); + add_tri(a, c, d); + } + + /** + * Build a quad from four points. + * + * top right - a + * top left - b + * bottom right - c + * bottom left - d + */ + public function build_quad(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, dx:Float, dy:Float, au:Float = 0, av:Float = 0, bu:Float = 0, + bv:Float = 0, cu:Float = 0, cv:Float = 0, du:Float = 0, dv:Float = 0):Void + { + // top left + var b = build_vertex(bx, by, bu, bv); + // top right + var a = build_vertex(ax, ay, au, av); + // bottom left + var c = build_vertex(cx, cy, cu, cv); + // bottom right + var d = build_vertex(dx, dy, du, dv); + + add_tri(a, b, c); + add_tri(a, c, d); } public function clear() { vertices.length = 0; indices.length = 0; + uvtData.length = 0; + colors.length = 0; vertex_count = 0; index_count = 0; } diff --git a/source/funkin/ui/debug/WaveformTestState.hx b/source/funkin/ui/debug/WaveformTestState.hx new file mode 100644 index 000000000..f3566db85 --- /dev/null +++ b/source/funkin/ui/debug/WaveformTestState.hx @@ -0,0 +1,191 @@ +package funkin.ui.debug; + +import flixel.math.FlxRect; +import flixel.FlxSprite; +import flixel.util.FlxColor; +import funkin.audio.FunkinSound; +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformSprite; +import funkin.audio.waveform.WaveformDataParser; +import funkin.graphics.rendering.MeshRender; + +class WaveformTestState extends MusicBeatState +{ + public function new() + { + super(); + } + + var waveformData:WaveformData; + var waveformData2:WaveformData; + + var waveformAudio:FunkinSound; + + // var waveformSprite:WaveformSprite; + // var waveformSprite2:WaveformSprite; + var timeMarker:FlxSprite; + + var polygonSprite:MeshRender; + var vertexCount:Int = 3; + + public override function create():Void + { + super.create(); + + var testSprite = new FlxSprite(0, 0); + testSprite.loadGraphic(Paths.image('funkay')); + testSprite.updateHitbox(); + testSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height); + // add(testSprite); + + waveformAudio = FunkinSound.load(Paths.inst('bopeebo', '-erect')); + + waveformData = WaveformDataParser.interpretFlxSound(waveformAudio); + + polygonSprite = new MeshRender(FlxG.width / 2, FlxG.height / 2, FlxColor.WHITE); + + setPolygonVertices(vertexCount); + add(polygonSprite); + + // waveformSprite = WaveformSprite.buildFromWaveformData(waveformData, HORIZONTAL, FlxColor.fromString("#ADD8E6")); + // waveformSprite.duration = 5.0 * 160; + // waveformSprite.width = FlxG.width * 160; + // waveformSprite.height = FlxG.height; // / 2; + // waveformSprite.amplitude = 2.0; + // waveformSprite.minWaveformSize = 25; + // waveformSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height); + // add(waveformSprite); + // + // waveformSprite2 = WaveformSprite.buildFromWaveformData(waveformData2, HORIZONTAL, FlxColor.fromString("#FF0000"), 5.0); + // waveformSprite2.width = FlxG.width; + // waveformSprite2.height = FlxG.height / 2; + // waveformSprite2.y = FlxG.height / 2; + // add(waveformSprite2); + + timeMarker = new FlxSprite(0, FlxG.height * 1 / 6); + timeMarker.makeGraphic(1, Std.int(FlxG.height * 2 / 3), FlxColor.RED); + add(timeMarker); + + // drawWaveform(time, duration); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (FlxG.keys.justPressed.SPACE) + { + if (waveformAudio.isPlaying) + { + waveformAudio.stop(); + } + else + { + waveformAudio.play(); + } + } + + if (FlxG.keys.justPressed.ENTER) + { + // if (waveformSprite.orientation == HORIZONTAL) + // { + // // waveformSprite.orientation = VERTICAL; + // // waveformSprite2.orientation = VERTICAL; + // } + // else + // { + // // waveformSprite.orientation = HORIZONTAL; + // // waveformSprite2.orientation = HORIZONTAL; + // } + } + + if (waveformAudio.isPlaying) + { + // waveformSprite takes a time in fractional seconds, not milliseconds. + var timeSeconds = waveformAudio.time / 1000; + // waveformSprite.time = timeSeconds; + // waveformSprite2.time = timeSeconds; + } + + if (FlxG.keys.justPressed.UP) + { + vertexCount += 1; + setPolygonVertices(vertexCount); + // waveformSprite.duration += 1.0; + // waveformSprite2.duration += 1.0; + } + if (FlxG.keys.justPressed.DOWN) + { + vertexCount -= 1; + setPolygonVertices(vertexCount); + // waveformSprite.duration -= 1.0; + // waveformSprite2.duration -= 1.0; + } + if (FlxG.keys.justPressed.LEFT) + { + // waveformSprite.time -= 1.0; + // waveformSprite2.time -= 1.0; + } + if (FlxG.keys.justPressed.RIGHT) + { + // waveformSprite.time += 1.0; + // waveformSprite2.time += 1.0; + } + } + + function setPolygonVertices(count:Int) + { + polygonSprite.clear(); + + var size = 100.0; + + // Build a polygon with count vertices. + + var vertices:Array> = []; + + var angle = 0.0; + + for (i in 0...count) + { + var x = Math.cos(angle) * size; + var y = Math.sin(angle) * size; + + vertices.push([x, y]); + + angle += 2 * Math.PI / count; + } + + trace('vertices: ${vertices}'); + + var centerVertex = polygonSprite.build_vertex(0, 0); + var firstVertex = -1; + var lastVertex = -1; + + for (vertex in vertices) + { + var x = vertex[0]; + var y = vertex[1]; + + var newVertex = polygonSprite.build_vertex(x, y); + + if (firstVertex == -1) + { + firstVertex = newVertex; + } + + if (lastVertex != -1) + { + polygonSprite.add_tri(centerVertex, lastVertex, newVertex); + } + + lastVertex = newVertex; + } + + polygonSprite.add_tri(centerVertex, lastVertex, firstVertex); + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 467e36f74..d0326be30 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -1,5 +1,6 @@ package funkin.ui.debug.charting; +import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox; import funkin.util.logging.CrashHandler; import haxe.ui.containers.HBox; import haxe.ui.containers.Grid; @@ -23,6 +24,7 @@ import flixel.system.FlxAssets.FlxSoundAsset; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.tweens.misc.VarTween; +import funkin.audio.waveform.WaveformSprite; import haxe.ui.Toolkit; import flixel.util.FlxColor; import flixel.util.FlxSort; @@ -128,7 +130,6 @@ import haxe.ui.focus.FocusManager; import openfl.display.BitmapData; import funkin.audio.visualize.PolygonSpectogram; import flixel.group.FlxGroup.FlxTypedGroup; -import funkin.audio.visualize.PolygonVisGroup; import flixel.input.mouse.FlxMouseEvent; import flixel.text.FlxText; import flixel.system.debug.log.LogStyle; @@ -158,6 +159,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState public static final CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata'); public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties'); public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata'); + public static final CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:String = Paths.ui('chart-editor/toolbox/offsets'); public static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty'); public static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview'); public static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview'); @@ -393,13 +395,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState gridTiledSprite.y = -scrollPositionInPixels + (GRID_INITIAL_Y_POS); measureTicks.y = gridTiledSprite.y; - if (audioVisGroup != null && audioVisGroup.playerVis != null) + for (member in audioWaveforms.members) { - audioVisGroup.playerVis.y = Math.max(gridTiledSprite.y, GRID_INITIAL_Y_POS - GRID_TOP_PAD); - } - if (audioVisGroup != null && audioVisGroup.opponentVis != null) - { - audioVisGroup.opponentVis.y = Math.max(gridTiledSprite.y, GRID_INITIAL_Y_POS - GRID_TOP_PAD); + member.time = scrollPositionInMs / Constants.MS_PER_SEC; + + // Doing this desyncs the waveforms from the grid. + // member.y = Math.max(this.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); } } } @@ -501,8 +502,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function get_playheadPositionInMs():Float { - if (audioVisGroup != null && audioVisGroup.playerVis != null) - audioVisGroup.playerVis.realtimeStartOffset = -Conductor.instance.getStepTimeInMs(playheadPositionInSteps); return Conductor.instance.getStepTimeInMs(playheadPositionInSteps); } @@ -510,7 +509,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { playheadPositionInSteps = Conductor.instance.getTimeInSteps(value); - if (audioVisGroup != null && audioVisGroup.playerVis != null) audioVisGroup.playerVis.realtimeStartOffset = -value; return value; } @@ -1106,14 +1104,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState * `null` until vocal track(s) are loaded. * When switching characters, the elements of the VoicesGroup will be swapped to match the new character. */ - var audioVocalTrackGroup:Null = null; + var audioVocalTrackGroup:VoicesGroup = new VoicesGroup(); /** - * The audio vis for the inst/vocals. + * The audio waveform visualization for the inst/vocals. * `null` until vocal track(s) are loaded. - * When switching characters, the elements of the PolygonVisGroup will be swapped to match the new character. + * When switching characters, the elements will be swapped to match the new character. */ - var audioVisGroup:Null = null; + var audioWaveforms:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); /** * A map of the audio tracks for each character's vocals. @@ -1446,19 +1444,28 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return value; } - var currentVocalOffset(get, set):Float; + var currentVocalOffsetPlayer(get, set):Float; - function get_currentVocalOffset():Float + function get_currentVocalOffsetPlayer():Float { - // Currently there's only one vocal offset, so we just grab the player's offset since both should be the same. - // Should probably make it so we can set offsets for player + opponent individually, though. return currentSongOffsets.getVocalOffset(currentPlayerChar); } - function set_currentVocalOffset(value:Float):Float + function set_currentVocalOffsetPlayer(value:Float):Float { - // Currently there's only one vocal offset, so we just apply it to both characters. currentSongOffsets.setVocalOffset(currentPlayerChar, value); + return value; + } + + var currentVocalOffsetOpponent(get, set):Float; + + function get_currentVocalOffsetOpponent():Float + { + return currentSongOffsets.getVocalOffset(currentOpponentChar); + } + + function set_currentVocalOffsetOpponent(value:Float):Float + { currentSongOffsets.setVocalOffset(currentOpponentChar, value); return value; } @@ -1863,11 +1870,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var notePreviewViewportBitmap:Null = null; - /**r + /** * The IMAGE used for the measure ticks. Updated by ChartEditorThemeHandler. */ var measureTickBitmap:Null = null; + /** + * The IMAGE used for the offset ticks. Updated by ChartEditorThemeHandler. + */ + var offsetTickBitmap:Null = null; + /** * The tiled sprite used to display the grid. * The height is the length of the song, and scrolling is done by simply the sprite. @@ -2252,8 +2264,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Initialize the song chart data. songChartData = new Map(); - - audioVocalTrackGroup = new VoicesGroup(); } /** @@ -2340,8 +2350,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState add(healthIconBF); healthIconBF.zIndex = 30; - audioVisGroup = new PolygonVisGroup(); - add(audioVisGroup); + add(audioWaveforms); } function buildMeasureTicks():Void @@ -2885,13 +2894,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState menubarItemVolumeVocalsPlayer.onChange = event -> { var volume:Float = event.value.toFloat() / 100.0; - if (audioVocalTrackGroup != null) audioVocalTrackGroup.playerVolume = volume; + audioVocalTrackGroup.playerVolume = volume; menubarLabelVolumeVocalsPlayer.text = 'Player - ${Std.int(event.value)}%'; }; menubarItemVolumeVocalsOpponent.onChange = event -> { var volume:Float = event.value.toFloat() / 100.0; - if (audioVocalTrackGroup != null) audioVocalTrackGroup.opponentVolume = volume; + audioVocalTrackGroup.opponentVolume = volume; menubarLabelVolumeVocalsOpponent.text = 'Enemy - ${Std.int(event.value)}%'; }; @@ -2900,7 +2909,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState pitch = Math.floor(pitch / 0.25) * 0.25; // Round to nearest 0.25. #if FLX_PITCH if (audioInstTrack != null) audioInstTrack.pitch = pitch; - if (audioVocalTrackGroup != null) audioVocalTrackGroup.pitch = pitch; + audioVocalTrackGroup.pitch = pitch; #end var pitchDisplay:Float = Std.int(pitch * 100) / 100; // Round to 2 decimal places. menubarLabelPlaybackSpeed.text = 'Playback Speed - ${pitchDisplay}x'; @@ -2908,6 +2917,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState menubarItemToggleToolboxDifficulty.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value); menubarItemToggleToolboxMetadata.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value); + menubarItemToggleToolboxOffsets.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT, event.value); menubarItemToggleToolboxNotes.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value); menubarItemToggleToolboxEventData.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT, event.value); menubarItemToggleToolboxPlaytestProperties.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT, event.value); @@ -3210,7 +3220,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState Conductor.instance.update(audioInstTrack.time); handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset); // Resync vocals. - if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) + if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) { audioVocalTrackGroup.time = audioInstTrack.time; } @@ -3228,7 +3238,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState Conductor.instance.update(audioInstTrack.time); handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset); // Resync vocals. - if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) + if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) { audioVocalTrackGroup.time = audioInstTrack.time; } @@ -5316,7 +5326,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { FlxG.sound.music = audioInstTrack; } - if (audioVocalTrackGroup != null) targetState.vocals = audioVocalTrackGroup; + targetState.vocals = audioVocalTrackGroup; // Kill and replace the UI camera so it doesn't get destroyed during the state transition. uiCamera.kill(); @@ -5432,7 +5442,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (audioInstTrack != null) { audioInstTrack.play(false, audioInstTrack.time); - if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(false, audioInstTrack.time); + audioVocalTrackGroup.play(false, audioInstTrack.time); } playbarPlay.text = '||'; // Pause @@ -5670,7 +5680,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState audioInstTrack.time = scrollPositionInMs + playheadPositionInMs - Conductor.instance.instrumentalOffset; // Update the songPosition in the Conductor. Conductor.instance.update(audioInstTrack.time); - if (audioVocalTrackGroup != null) audioVocalTrackGroup.time = audioInstTrack.time; + audioVocalTrackGroup.time = audioInstTrack.time; } // We need to update the note sprites because we changed the scroll position. @@ -5891,7 +5901,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function stopAudioPlayback():Void { if (audioInstTrack != null) audioInstTrack.pause(); - if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause(); + audioVocalTrackGroup.pause(); playbarPlay.text = '>'; } @@ -5926,7 +5936,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Keep the track at the end. audioInstTrack.time = audioInstTrack.length; } - if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause(); + audioVocalTrackGroup.pause(); }; } else @@ -5940,12 +5950,22 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState healthIconsDirty = true; } + function hardRefreshOffsetsToolbox():Void + { + var offsetsToolbox:ChartEditorOffsetsToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT); + if (offsetsToolbox != null) + { + offsetsToolbox.refreshAudioPreview(); + offsetsToolbox.refresh(); + } + } + /** * Clear the voices group. */ public function clearVocals():Void { - if (audioVocalTrackGroup != null) audioVocalTrackGroup.clear(); + audioVocalTrackGroup.clear(); } function isNoteSelected(note:Null):Bool @@ -5970,7 +5990,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Stop the music. if (welcomeMusic != null) welcomeMusic.destroy(); if (audioInstTrack != null) audioInstTrack.destroy(); - if (audioVocalTrackGroup != null) audioVocalTrackGroup.destroy(); + audioVocalTrackGroup.destroy(); } function applyCanQuickSave():Void diff --git a/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx new file mode 100644 index 000000000..aef402244 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx @@ -0,0 +1,109 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongDataUtils; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; + +/** + * Command that copies a given set of notes and song events to the clipboard, + * without deleting them from the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class SetAudioOffsetCommand implements ChartEditorCommand +{ + var type:AudioOffsetType; + var oldOffset:Float = 0; + var newOffset:Float; + var refreshOffsetsToolbox:Bool; + + public function new(type:AudioOffsetType, newOffset:Float, refreshOffsetsToolbox:Bool = true) + { + this.type = type; + this.newOffset = newOffset; + this.refreshOffsetsToolbox = refreshOffsetsToolbox; + } + + public function execute(state:ChartEditorState):Void + { + switch (type) + { + case INSTRUMENTAL: + oldOffset = state.currentInstrumentalOffset; + state.currentInstrumentalOffset = newOffset; + + // Update rendering. + Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset; + state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset; + case PLAYER: + oldOffset = state.currentVocalOffsetPlayer; + state.currentVocalOffsetPlayer = newOffset; + + // Update rendering. + state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer; + case OPPONENT: + oldOffset = state.currentVocalOffsetOpponent; + state.currentVocalOffsetOpponent = newOffset; + + // Update rendering. + state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent; + } + + // Update the offsets toolbox. + if (refreshOffsetsToolbox) state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT); + } + + public function undo(state:ChartEditorState):Void + { + switch (type) + { + case INSTRUMENTAL: + state.currentInstrumentalOffset = oldOffset; + + // Update rendering. + Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset; + state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset; + case PLAYER: + state.currentVocalOffsetPlayer = oldOffset; + + // Update rendering. + state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer; + case OPPONENT: + state.currentVocalOffsetOpponent = oldOffset; + + // Update rendering. + state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent; + } + + // Update the offsets toolbox. + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (newOffset != oldOffset); + } + + public function toString():String + { + switch (type) + { + case INSTRUMENTAL: + return 'Set Inst. Audio Offset to $newOffset'; + case PLAYER: + return 'Set Player Audio Offset to $newOffset'; + case OPPONENT: + return 'Set Opponent Audio Offset to $newOffset'; + } + } +} + +enum AudioOffsetType +{ + INSTRUMENTAL; + PLAYER; + OPPONENT; +} diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx index e1fcd1cb0..8e40cfc42 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx @@ -3,11 +3,14 @@ package funkin.ui.debug.charting.handlers; import flixel.system.FlxAssets.FlxSoundAsset; import flixel.system.FlxSound; import funkin.audio.VoicesGroup; -import funkin.audio.visualize.PolygonVisGroup; import funkin.audio.FunkinSound; import funkin.play.character.BaseCharacter.CharacterType; import funkin.util.FileUtil; import funkin.util.assets.SoundUtil; +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformDataParser; +import funkin.audio.waveform.WaveformSprite; +import flixel.util.FlxColor; import haxe.io.Bytes; import haxe.io.Path; import openfl.utils.Assets; @@ -134,6 +137,8 @@ class ChartEditorAudioHandler result = playVocals(state, DAD, opponentId, instId); // if (!result) return false; + state.hardRefreshOffsetsToolbox(); + return true; } @@ -175,7 +180,6 @@ class ChartEditorAudioHandler var vocalTrack:Null = SoundUtil.buildSoundFromBytes(vocalTrackData); if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup(); - if (state.audioVisGroup == null) state.audioVisGroup = new PolygonVisGroup(); if (vocalTrack != null) { @@ -183,26 +187,49 @@ class ChartEditorAudioHandler { case BF: state.audioVocalTrackGroup.addPlayerVoice(vocalTrack); - state.audioVisGroup.addPlayerVis(vocalTrack); - state.audioVisGroup.playerVis.x = 885; - state.audioVisGroup.playerVis.realtimeVisLenght = Conductor.instance.getStepTimeInMs(16) * 0.00195; - state.audioVisGroup.playerVis.daHeight = (ChartEditorState.GRID_SIZE) * 16; - state.audioVisGroup.playerVis.detail = 1; - state.audioVisGroup.playerVis.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); - state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffset; + var waveformData:Null = WaveformDataParser.interpretFlxSound(vocalTrack); + if (waveformData != null) + { + var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001; + var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE); + waveformSprite.x = 840; + waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); + waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16; + waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2; + waveformSprite.time = 0; + waveformSprite.duration = duration; + state.audioWaveforms.add(waveformSprite); + } + else + { + trace('[WARN] Failed to parse waveform data for vocal track.'); + } + + state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer; return true; case DAD: state.audioVocalTrackGroup.addOpponentVoice(vocalTrack); - state.audioVisGroup.addOpponentVis(vocalTrack); - state.audioVisGroup.opponentVis.x = 405; - state.audioVisGroup.opponentVis.realtimeVisLenght = Conductor.instance.getStepTimeInMs(16) * 0.00195; - state.audioVisGroup.opponentVis.daHeight = (ChartEditorState.GRID_SIZE) * 16; - state.audioVisGroup.opponentVis.detail = 1; - state.audioVisGroup.opponentVis.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); + var waveformData:Null = WaveformDataParser.interpretFlxSound(vocalTrack); + if (waveformData != null) + { + var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001; + var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE); + waveformSprite.x = 360; + waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); + waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16; + waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2; + waveformSprite.time = 0; + waveformSprite.duration = duration; + state.audioWaveforms.add(waveformSprite); + } + else + { + trace('[WARN] Failed to parse waveform data for vocal track.'); + } - state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffset; + state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent; return true; case OTHER: @@ -219,13 +246,10 @@ class ChartEditorAudioHandler public static function stopExistingVocals(state:ChartEditorState):Void { - if (state.audioVocalTrackGroup != null) + state.audioVocalTrackGroup.clear(); + if (state.audioWaveforms != null) { - state.audioVocalTrackGroup.clear(); - } - if (state.audioVisGroup != null) - { - state.audioVisGroup.clearAllVis(); + state.audioWaveforms.clear(); } } diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx index 9c86269e8..04d89e3f4 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx @@ -132,11 +132,8 @@ class ChartEditorImportExportHandler state.audioInstTrack.stop(); state.audioInstTrack = null; } - if (state.audioVocalTrackGroup != null) - { - state.audioVocalTrackGroup.stop(); - state.audioVocalTrackGroup.clear(); - } + state.audioVocalTrackGroup.stop(); + state.audioVocalTrackGroup.clear(); } /** diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx index 89fd4d5d3..b1af0ce4c 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx @@ -82,6 +82,7 @@ class ChartEditorThemeHandler updateBackground(state); updateGridBitmap(state); updateMeasureTicks(state); + updateOffsetTicks(state); updateSelectionSquare(state); updateNotePreview(state); } @@ -231,6 +232,9 @@ class ChartEditorThemeHandler // Else, gridTiledSprite will be built later. } + /** + * Vertical measure ticks. + */ static function updateMeasureTicks(state:ChartEditorState):Void { var measureTickWidth:Int = 6; @@ -286,6 +290,59 @@ class ChartEditorThemeHandler state.measureTickBitmap.fillRect(new Rectangle(0, stepTick16Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); } + /** + * Horizontal offset ticks. + */ + static function updateOffsetTicks(state:ChartEditorState):Void + { + var majorTickWidth:Int = 6; + var minorTickWidth:Int = 3; + + var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure); // 10 minor ticks wide. + var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 grid squares tall. + state.offsetTickBitmap = new BitmapData(ticksWidth, ticksHeight, true); + state.offsetTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK); + + // Draw the major ticks. + var leftTickX:Float = 0; + var middleTickX:Float = state.offsetTickBitmap.width / 2 - (majorTickWidth / 2); + var rightTickX:Float = state.offsetTickBitmap.width - (majorTickWidth / 2); + var majorTickLength:Float = state.offsetTickBitmap.height; + state.offsetTickBitmap.fillRect(new Rectangle(leftTickX, 0, majorTickWidth / 2, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(middleTickX, 0, majorTickWidth, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(rightTickX, 0, majorTickWidth / 2, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + + // Draw the minor ticks. + var minorTick2X:Float = state.offsetTickBitmap.width * 1 / 10 - (minorTickWidth / 2); + var minorTick3X:Float = state.offsetTickBitmap.width * 2 / 10 - (minorTickWidth / 2); + var minorTick4X:Float = state.offsetTickBitmap.width * 3 / 10 - (minorTickWidth / 2); + var minorTick5X:Float = state.offsetTickBitmap.width * 4 / 10 - (minorTickWidth / 2); + var minorTick7X:Float = state.offsetTickBitmap.width * 6 / 10 - (minorTickWidth / 2); + var minorTick8X:Float = state.offsetTickBitmap.width * 7 / 10 - (minorTickWidth / 2); + var minorTick9X:Float = state.offsetTickBitmap.width * 8 / 10 - (minorTickWidth / 2); + var minorTick10X:Float = state.offsetTickBitmap.width * 9 / 10 - (minorTickWidth / 2); + var minorTickLength:Float = state.offsetTickBitmap.height * 1 / 3; + state.offsetTickBitmap.fillRect(new Rectangle(minorTick2X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick3X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick4X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick5X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick7X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick8X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick9X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick10X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + + // Draw the offset ticks. + // var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE * TOTAL_COLUMN_COUNT); // 1 grid squares wide. + // var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 measure tall. + // state.offsetTickBitmap = new BitmapData(ticksWidth, ticksHeight, true); + // state.offsetTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK); + // + //// Draw the offset ticks. + // state.offsetTickBitmap.fillRect(new Rectangle(0, 0, offsetTickWidth / 2, state.offsetTickBitmap.height), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + // var rightTickX:Float = state.offsetTickBitmap.width - (offsetTickWidth / 2); + // state.offsetTickBitmap.fillRect(new Rectangle(rightTickX, 0, offsetTickWidth / 2, state.offsetTickBitmap.height), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + } + static function updateSelectionSquare(state:ChartEditorState):Void { var selectionSquareBorderColor:FlxColor = switch (state.currentTheme) diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx index f97b26933..b246e653f 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx @@ -35,6 +35,7 @@ import haxe.ui.containers.dialogs.Dialog.DialogButton; import haxe.ui.containers.dialogs.Dialog.DialogEvent; import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox; import funkin.ui.debug.charting.toolboxes.ChartEditorMetadataToolbox; +import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox; import funkin.ui.debug.charting.toolboxes.ChartEditorEventDataToolbox; import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox; import haxe.ui.containers.Frame; @@ -89,6 +90,8 @@ class ChartEditorToolboxHandler case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT: // TODO: Fix this. cast(toolbox, ChartEditorBaseToolbox).refresh(); + case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT: + cast(toolbox, ChartEditorBaseToolbox).refresh(); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT: onShowToolboxPlayerPreview(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT: @@ -124,8 +127,6 @@ class ChartEditorToolboxHandler onHideToolboxEventData(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT: onHideToolboxPlaytestProperties(state, toolbox); - case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT: - onHideToolboxMetadata(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT: onHideToolboxPlayerPreview(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT: @@ -202,6 +203,8 @@ class ChartEditorToolboxHandler toolbox = buildToolboxDifficultyLayout(state); case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT: toolbox = buildToolboxMetadataLayout(state); + case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT: + toolbox = buildToolboxOffsetsLayout(state); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT: toolbox = buildToolboxPlayerPreviewLayout(state); case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT: @@ -304,8 +307,6 @@ class ChartEditorToolboxHandler static function onHideToolboxNoteData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} - static function onHideToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} - static function onHideToolboxEventData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} static function onShowToolboxPlaytestProperties(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} @@ -373,6 +374,15 @@ class ChartEditorToolboxHandler return toolbox; } + static function buildToolboxOffsetsLayout(state:ChartEditorState):Null + { + var toolbox:ChartEditorBaseToolbox = ChartEditorOffsetsToolbox.build(state); + + if (toolbox == null) return null; + + return toolbox; + } + static function buildToolboxEventDataLayout(state:ChartEditorState):Null { var toolbox:ChartEditorBaseToolbox = ChartEditorEventDataToolbox.build(state); diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx index 46c2b19aa..5d8c25bae 100644 --- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx @@ -35,8 +35,6 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox var buttonCharacterGirlfriend:Button; var buttonCharacterOpponent:Button; var inputBPM:NumberStepper; - var inputOffsetInst:NumberStepper; - var inputOffsetVocal:NumberStepper; var labelScrollSpeed:Label; var inputScrollSpeed:Slider; var frameVariation:Frame; @@ -138,25 +136,6 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox chartEditorState.updateTimeSignature(); }; - inputOffsetInst.onChange = function(event:UIEvent) { - if (event.value == null) return; - - chartEditorState.currentInstrumentalOffset = event.value; - Conductor.instance.instrumentalOffset = event.value; - // Update song length. - chartEditorState.songLengthInMs = (chartEditorState.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset; - }; - - inputOffsetVocal.onChange = function(event:UIEvent) { - if (event.value == null) return; - - chartEditorState.currentVocalOffset = event.value; - if (chartEditorState.audioVocalTrackGroup != null) - { - chartEditorState.audioVocalTrackGroup.playerVoicesOffset = event.value; - chartEditorState.audioVocalTrackGroup.opponentVoicesOffset = event.value; - } - }; inputScrollSpeed.onChange = function(event:UIEvent) { var valid:Bool = event.target.value != null && event.target.value > 0; @@ -196,8 +175,6 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox inputStage.value = chartEditorState.currentSongMetadata.playData.stage; inputNoteStyle.value = chartEditorState.currentSongMetadata.playData.noteStyle; inputBPM.value = chartEditorState.currentSongMetadata.timeChanges[0].bpm; - inputOffsetInst.value = chartEditorState.currentSongMetadata.offsets.getInstrumentalOffset(); - inputOffsetVocal.value = chartEditorState.currentSongMetadata.offsets.getVocalOffset(chartEditorState.currentSongMetadata.playData.characters.player); inputScrollSpeed.value = chartEditorState.currentSongChartScrollSpeed; labelScrollSpeed.text = 'Scroll Speed: ${chartEditorState.currentSongChartScrollSpeed}x'; frameVariation.text = 'Variation: ${chartEditorState.selectedVariation.toTitleCase()}'; diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx new file mode 100644 index 000000000..5b26419b2 --- /dev/null +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx @@ -0,0 +1,852 @@ +package funkin.ui.debug.charting.toolboxes; + +import funkin.audio.SoundGroup; +import haxe.ui.components.Button; +import haxe.ui.components.HorizontalSlider; +import haxe.ui.components.Label; +import flixel.addons.display.FlxTiledSprite; +import flixel.math.FlxMath; +import haxe.ui.components.NumberStepper; +import haxe.ui.components.Slider; +import haxe.ui.backend.flixel.components.SpriteWrapper; +import funkin.ui.debug.charting.commands.SetAudioOffsetCommand; +import funkin.ui.haxeui.components.WaveformPlayer; +import funkin.audio.waveform.WaveformDataParser; +import haxe.ui.containers.VBox; +import haxe.ui.containers.Absolute; +import haxe.ui.containers.ScrollView; +import haxe.ui.containers.Frame; +import haxe.ui.core.Screen; +import haxe.ui.events.DragEvent; +import haxe.ui.events.MouseEvent; +import haxe.ui.events.UIEvent; + +/** + * The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM. + */ +// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/offsets.xml")) +class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox +{ + var waveformContainer:Absolute; + var waveformScrollview:ScrollView; + var waveformPlayer:WaveformPlayer; + var waveformOpponent:WaveformPlayer; + var waveformInstrumental:WaveformPlayer; + var offsetButtonZoomIn:Button; + var offsetButtonZoomOut:Button; + var offsetButtonPause:Button; + var offsetButtonPlay:Button; + var offsetButtonStop:Button; + var offsetStepperPlayer:NumberStepper; + var offsetStepperOpponent:NumberStepper; + var offsetStepperInstrumental:NumberStepper; + var offsetTicksContainer:Absolute; + var playheadSprite:SpriteWrapper; + + static final TICK_LABEL_X_OFFSET:Float = 4.0; + + static final PLAYHEAD_RIGHT_PAD:Float = 10.0; + + static final BASE_SCALE:Float = 64.0; + static final MIN_SCALE:Float = 4.0; + static final WAVEFORM_ZOOM_MULT:Float = 1.5; + + static final MAGIC_SCALE_BASE_TIME:Float = 5.0; + + var waveformScale:Float = BASE_SCALE; + + var playheadAbsolutePos(get, set):Float; + + function get_playheadAbsolutePos():Float + { + return playheadSprite.left; + } + + function set_playheadAbsolutePos(value:Float):Float + { + return playheadSprite.left = value; + } + + var playheadRelativePos(get, set):Float; + + function get_playheadRelativePos():Float + { + return playheadSprite.left - waveformScrollview.hscrollPos; + } + + function set_playheadRelativePos(value:Float):Float + { + return playheadSprite.left = waveformScrollview.hscrollPos + value; + } + + /** + * The amount you need to multiply the zoom by such that, at the base zoom level, one tick is equal to `MAGIC_SCALE_BASE_TIME` seconds. + */ + var waveformMagicFactor:Float = 1.0; + + var audioPreviewTracks:SoundGroup; + + var tickTiledSprite:FlxTiledSprite; + + var tickLabels:Array