1
0
Fork 0
mirror of https://github.com/ninjamuffin99/Funkin.git synced 2025-04-01 07:16:54 +00:00

Merge pull request #304 from FunkinCrew/feature/chart-editor-offset-toolbox

Chart Editor: Offsets Toolbox
This commit is contained in:
Cameron Taylor 2024-02-05 13:21:26 -05:00 committed by GitHub
commit 401c200070
23 changed files with 2472 additions and 131 deletions

View file

@ -125,6 +125,11 @@
"target": "windows",
"args": ["-debug", "-DLATENCY"]
},
{
"label": "Windows / Debug (Waveform Test)",
"target": "windows",
"args": ["-debug", "-DWAVEFORM"]
},
{
"label": "HTML5 / Debug",
"target": "html5",

2
assets

@ -1 +1 @@
Subproject commit b2f8b6a780316959d0a79adc6dbf61f9e4ca675f
Subproject commit 251d4640bd77ee0f0b6122a13f123274c43dd3f5

View file

@ -18,7 +18,7 @@
"name": "flixel-addons",
"type": "git",
"dir": null,
"ref": "fd3aecdeb5635fa0428dffee204fc78fc26b5885",
"ref": "a523c3b56622f0640933944171efed46929e360e",
"url": "https://github.com/FunkinCrew/flixel-addons"
},
{

View file

@ -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

View file

@ -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<FunkinSound>
{
static final MAX_VOLUME:Float = 2.0;
static var cache(default, null):FlxTypedGroup<FunkinSound> = new FlxTypedGroup<FunkinSound>();
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.
*

View file

@ -16,6 +16,8 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
public var pitch(get, set):Float;
public var playing(get, never):Bool;
public function new()
{
super();
@ -165,6 +167,13 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
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;

View file

@ -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<FunkinSound>
{
return playerVoices.members[index];
}
public function getOpponentVoice(index:Int = 0):Null<FunkinSound>
{
return opponentVoices.members[index];
}
public function buildPlayerVoiceWaveform():Null<WaveformData>
{
if (playerVoices.members.length == 0) return null;
return WaveformDataParser.interpretFlxSound(playerVoices.members[0]);
}
public function buildOpponentVoiceWaveform():Null<WaveformData>
{
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();

View file

@ -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);

View file

@ -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<Int> = [];
@:jignored
var channelData:Null<Array<WaveformDataChannel>> = null;
public function new(?version:Int, channels:Int, sampleRate:Int, samplesPerPoint:Int, bits:Int, length:Int, data:Array<Int>)
{
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<WaveformDataChannel>
{
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<Int> = 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);
}
}

View file

@ -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<WaveformData>
{
if (sound == null) return null;
// Method 1. This only works if the sound has been played before.
@:privateAccess
var soundBuffer:Null<lime.media.AudioBuffer> = 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<WaveformData>
{
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<Int> = [];
for (pointIndex in 0...outputPointCount)
{
// minChannel1, maxChannel1, minChannel2, maxChannel2, ...
var values:Array<Int> = [];
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<WaveformData>
{
var rawJson:String = openfl.Assets.getText(path).trim();
return parseWaveformDataString(rawJson, path);
}
public static function parseWaveformDataString(contents:String, ?fileName:String):Null<WaveformData>
{
var parser = new json2object.JsonParser<WaveformData>();
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<json2object.Error>, id:String = ''):Void
{
trace('[WAVEFORM] Failed to parse waveform data: ${id}');
for (error in errors)
funkin.data.DataError.printError(error);
}
}

View file

@ -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<WaveformData>;
function set_waveformData(value:Null<WaveformData>):Null<WaveformData>
{
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;
}

View file

@ -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;
}

View file

@ -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<Array<Float>> = [];
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();
}
}

View file

@ -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<VoicesGroup> = 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<PolygonVisGroup> = null;
var audioWaveforms:FlxTypedSpriteGroup<WaveformSprite> = new FlxTypedSpriteGroup<WaveformSprite>();
/**
* 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<BitmapData> = null;
/**r
/**
* The IMAGE used for the measure ticks. Updated by ChartEditorThemeHandler.
*/
var measureTickBitmap:Null<BitmapData> = null;
/**
* The IMAGE used for the offset ticks. Updated by ChartEditorThemeHandler.
*/
var offsetTickBitmap:Null<BitmapData> = 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<String, SongChartData>();
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<SongNoteData>):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

View file

@ -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;
}

View file

@ -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<FunkinSound> = 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<WaveformData> = 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<WaveformData> = 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();
}
}

View file

@ -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();
}
/**

View file

@ -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)

View file

@ -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<ChartEditorBaseToolbox>
{
var toolbox:ChartEditorBaseToolbox = ChartEditorOffsetsToolbox.build(state);
if (toolbox == null) return null;
return toolbox;
}
static function buildToolboxEventDataLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
{
var toolbox:ChartEditorBaseToolbox = ChartEditorEventDataToolbox.build(state);

View file

@ -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()}';

View file

@ -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<Label> = [];
// Local store of the audio offsets, so we can detect when they change.
var audioPreviewPlayerOffset:Float = 0;
var audioPreviewOpponentOffset:Float = 0;
var audioPreviewInstrumentalOffset:Float = 0;
public function new(chartEditorState2:ChartEditorState)
{
super(chartEditorState2);
initialize();
this.onDialogClosed = onClose;
}
function onClose(event:UIEvent)
{
chartEditorState.menubarItemToggleToolboxOffsets.selected = false;
}
function initialize():Void
{
// Starting position.
// TODO: Save and load this.
this.x = 150;
this.y = 250;
offsetPlayerVolume.onChange = (_) -> {
var targetVolume = offsetPlayerVolume.value * 2 / 100;
setTrackVolume(PLAYER, targetVolume);
};
offsetPlayerMute.onClick = (_) -> {
toggleMuteTrack(PLAYER);
};
offsetPlayerSolo.onClick = (_) -> {
soloTrack(PLAYER);
};
offsetOpponentVolume.onChange = (_) -> {
var targetVolume = offsetOpponentVolume.value * 2 / 100;
setTrackVolume(OPPONENT, targetVolume);
};
offsetOpponentMute.onClick = (_) -> {
toggleMuteTrack(OPPONENT);
};
offsetOpponentSolo.onClick = (_) -> {
soloTrack(OPPONENT);
};
offsetInstrumentalVolume.onChange = (_) -> {
var targetVolume = offsetInstrumentalVolume.value * 2 / 100;
setTrackVolume(INSTRUMENTAL, targetVolume);
};
offsetInstrumentalMute.onClick = (_) -> {
toggleMuteTrack(INSTRUMENTAL);
};
offsetInstrumentalSolo.onClick = (_) -> {
soloTrack(INSTRUMENTAL);
};
offsetButtonZoomIn.onClick = (_) -> {
zoomWaveformIn();
};
offsetButtonZoomOut.onClick = (_) -> {
zoomWaveformOut();
};
offsetButtonPause.onClick = (_) -> {
pauseAudioPreview();
};
offsetButtonPlay.onClick = (_) -> {
playAudioPreview();
};
offsetButtonStop.onClick = (_) -> {
stopAudioPreview();
};
offsetStepperPlayer.onChange = (event:UIEvent) -> {
if (event.value == chartEditorState.currentVocalOffsetPlayer) return;
if (dragWaveform != null) return;
chartEditorState.performCommand(new SetAudioOffsetCommand(PLAYER, event.value));
refresh();
}
offsetStepperOpponent.onChange = (event:UIEvent) -> {
if (event.value == chartEditorState.currentVocalOffsetOpponent) return;
if (dragWaveform != null) return;
chartEditorState.performCommand(new SetAudioOffsetCommand(OPPONENT, event.value));
refresh();
}
offsetStepperInstrumental.onChange = (event:UIEvent) -> {
if (event.value == chartEditorState.currentInstrumentalOffset) return;
if (dragWaveform != null) return;
chartEditorState.performCommand(new SetAudioOffsetCommand(INSTRUMENTAL, event.value));
refresh();
}
waveformScrollview.onScroll = (_) -> {
if (!audioPreviewTracks.playing)
{
// Move the playhead if it would go out of view.
var prevPlayheadRelativePos = playheadRelativePos;
playheadRelativePos = FlxMath.bound(playheadRelativePos, 0, waveformScrollview.width - PLAYHEAD_RIGHT_PAD);
trace('newPos: ${playheadRelativePos}');
var diff = playheadRelativePos - prevPlayheadRelativePos;
if (diff != 0)
{
// We have to change the song time to match the playhead position when we move it.
var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
var targetSongTimeSeconds:Float = waveformPlayer.waveform.waveformData.indexToSeconds(currentWaveformIndex);
audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
}
addOffsetsToAudioPreview();
}
else
{
// The scrollview probably changed because the song position changed.
// If we try to move the song now it will glitch.
}
// Either way, clipRect has changed, so we need to refresh the waveforms.
refresh();
};
initializeTicks();
refreshAudioPreview();
refresh();
refreshTicks();
waveformPlayer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
onStartDragWaveform(PLAYER);
});
waveformOpponent.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
onStartDragWaveform(OPPONENT);
});
waveformInstrumental.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
onStartDragWaveform(INSTRUMENTAL);
});
offsetTicksContainer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
onStartDragPlayhead();
});
}
function initializeTicks():Void
{
tickTiledSprite = new FlxTiledSprite(chartEditorState.offsetTickBitmap, 100, chartEditorState.offsetTickBitmap.height, true, false);
offsetTicksSprite.sprite = tickTiledSprite;
tickTiledSprite.width = 5000;
}
/**
* Pull the audio tracks from the chart editor state and create copies of them to play in the Offsets Toolbox.
* These must be DEEP CLONES or else the editor will affect the audio preview!
*/
public function refreshAudioPreview():Void
{
if (audioPreviewTracks == null)
{
audioPreviewTracks = new SoundGroup();
// Make sure audioPreviewTracks (and all its children) receives update() calls.
chartEditorState.add(audioPreviewTracks);
}
else
{
audioPreviewTracks.stop();
audioPreviewTracks.clear();
}
var instTrack = chartEditorState.audioInstTrack.clone();
audioPreviewTracks.add(instTrack);
var playerVoice = chartEditorState.audioVocalTrackGroup.getPlayerVoice();
if (playerVoice != null) audioPreviewTracks.add(playerVoice.clone());
var opponentVoice = chartEditorState.audioVocalTrackGroup.getOpponentVoice();
if (opponentVoice != null) audioPreviewTracks.add(opponentVoice.clone());
// Build player waveform.
// waveformPlayer.waveform.forceUpdate = true;
waveformPlayer.waveform.waveformData = WaveformDataParser.interpretFlxSound(playerVoice);
// Set the width and duration to render the full waveform, with the clipRect applied we only render a segment of it.
waveformPlayer.waveform.duration = playerVoice.length / Constants.MS_PER_SEC;
// Build opponent waveform.
// waveformOpponent.waveform.forceUpdate = true;
waveformOpponent.waveform.waveformData = WaveformDataParser.interpretFlxSound(opponentVoice);
waveformOpponent.waveform.duration = opponentVoice.length / Constants.MS_PER_SEC;
// Build instrumental waveform.
// waveformInstrumental.waveform.forceUpdate = true;
waveformInstrumental.waveform.waveformData = WaveformDataParser.interpretFlxSound(instTrack);
waveformInstrumental.waveform.duration = instTrack.length / Constants.MS_PER_SEC;
addOffsetsToAudioPreview();
}
public function refreshTicks():Void
{
while (tickLabels.length > 0)
{
var label = tickLabels.pop();
offsetTicksContainer.removeComponent(label);
}
var labelYPos:Float = chartEditorState.offsetTickBitmap.height / 2;
var labelHeight:Float = chartEditorState.offsetTickBitmap.height / 2;
var numberOfTicks:Int = Math.floor(waveformInstrumental.waveform.width / chartEditorState.offsetTickBitmap.width * 2) + 1;
for (index in 0...numberOfTicks)
{
var tickPos = chartEditorState.offsetTickBitmap.width / 2 * index;
var tickTime = tickPos * (waveformScale / BASE_SCALE * waveformMagicFactor) / waveformInstrumental.waveform.waveformData.pointsPerSecond();
var tickLabel:Label = new Label();
tickLabel.text = formatTime(tickTime);
tickLabel.styleNames = "offset-ticks-label";
tickLabel.height = labelHeight;
// Positioning within offsetTicksContainer is absolute (relative to the container itself).
tickLabel.top = labelYPos;
tickLabel.left = tickPos + TICK_LABEL_X_OFFSET;
offsetTicksContainer.addComponent(tickLabel);
tickLabels.push(tickLabel);
}
}
function formatTime(seconds:Float):String
{
if (seconds <= 0) return "0.0";
var integerSeconds = Math.floor(seconds);
var decimalSeconds = Math.floor((seconds - integerSeconds) * 10);
if (integerSeconds < 60)
{
return '${integerSeconds}.${decimalSeconds}';
}
else
{
var integerMinutes = Math.floor(integerSeconds / 60);
var remainingSeconds = integerSeconds % 60;
var remainingSecondsPad:String = remainingSeconds < 10 ? '0$remainingSeconds' : '$remainingSeconds';
return '${integerMinutes}:${remainingSecondsPad}${decimalSeconds > 0 ? '.$decimalSeconds' : ''}';
}
}
function buildTickLabel():Void {}
public function onStartDragPlayhead():Void
{
Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
movePlayheadToMouse();
}
public function onDragPlayhead(event:MouseEvent):Void
{
movePlayheadToMouse();
}
public function onStopDragPlayhead(event:MouseEvent):Void
{
// Stop dragging.
Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
}
function movePlayheadToMouse():Void
{
// Determine the position of the mouse relative to the
var mouseXPos = FlxG.mouse.x;
var relativeMouseXPos = mouseXPos - waveformScrollview.screenX;
var targetPlayheadPos = relativeMouseXPos + waveformScrollview.hscrollPos;
// Move the playhead to the mouse position.
playheadAbsolutePos = targetPlayheadPos;
// Move the audio preview to the playhead position.
var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
var targetSongTimeSeconds:Float = waveformPlayer.waveform.waveformData.indexToSeconds(currentWaveformIndex);
audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
}
public function onStartDragWaveform(waveform:Waveform):Void
{
dragMousePosition = FlxG.mouse.x;
dragWaveform = waveform;
Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
}
var dragMousePosition:Float = 0;
var dragWaveform:Waveform = null;
var dragOffsetMs:Float = 0;
public function onDragWaveform(event:MouseEvent):Void
{
var newDragMousePosition = FlxG.mouse.x;
var deltaMousePosition = newDragMousePosition - dragMousePosition;
if (deltaMousePosition == 0) return;
var deltaPixels:Float = deltaMousePosition * (waveformScale / BASE_SCALE * waveformMagicFactor);
var deltaMilliseconds:Float = switch (dragWaveform)
{
case PLAYER:
deltaPixels / waveformPlayer.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC;
case OPPONENT:
deltaPixels / waveformOpponent.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC;
case INSTRUMENTAL:
deltaPixels / waveformInstrumental.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC;
};
trace('Moving waveform by ${deltaMousePosition} -> ${deltaPixels} -> ${deltaMilliseconds} milliseconds.');
switch (dragWaveform)
{
case PLAYER:
// chartEditorState.currentVocalOffsetPlayer += deltaMilliseconds;
dragOffsetMs += deltaMilliseconds;
offsetStepperPlayer.value += deltaMilliseconds;
case OPPONENT:
// chartEditorState.currentVocalOffsetOpponent += deltaMilliseconds;
dragOffsetMs += deltaMilliseconds;
offsetStepperOpponent.value += deltaMilliseconds;
case INSTRUMENTAL:
// chartEditorState.currentInstrumentalOffset += deltaMilliseconds;
dragOffsetMs += deltaMilliseconds;
offsetStepperInstrumental.value += deltaMilliseconds;
}
dragMousePosition = newDragMousePosition;
refresh();
}
public function onStopDragWaveform(event:MouseEvent):Void
{
// Stop dragging.
Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
// Apply the offset change after dragging happens.
// We only do this once per drag so we don't get 20 commands a second in the history.
if (dragOffsetMs != 0)
{
// false to not refresh this toolbox, we will manually do that later.
switch (dragWaveform)
{
case PLAYER:
chartEditorState.performCommand(new SetAudioOffsetCommand(PLAYER, chartEditorState.currentVocalOffsetPlayer + dragOffsetMs, false));
case OPPONENT:
chartEditorState.performCommand(new SetAudioOffsetCommand(OPPONENT, chartEditorState.currentVocalOffsetOpponent + dragOffsetMs, false));
case INSTRUMENTAL:
chartEditorState.performCommand(new SetAudioOffsetCommand(INSTRUMENTAL, chartEditorState.currentInstrumentalOffset + dragOffsetMs, false));
}
}
dragOffsetMs = 0;
dragMousePosition = 0;
dragWaveform = null;
refresh();
addOffsetsToAudioPreview();
}
public function playAudioPreview():Void
{
audioPreviewTracks.play(false, audioPreviewTracks.time);
}
public function addOffsetsToAudioPreview():Void
{
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset;
trackInst.time -= audioPreviewInstrumentalOffset;
}
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer;
trackPlayer.time -= audioPreviewPlayerOffset;
}
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent;
trackOpponent.time -= audioPreviewOpponentOffset;
}
}
public function pauseAudioPreview():Void
{
audioPreviewTracks.pause();
}
public function stopAudioPreview():Void
{
audioPreviewTracks.stop();
audioPreviewTracks.time = 0;
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset;
trackInst.time = -audioPreviewInstrumentalOffset;
}
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer;
trackPlayer.time = -audioPreviewPlayerOffset;
}
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent;
trackOpponent.time = -audioPreviewOpponentOffset;
}
waveformScrollview.hscrollPos = 0;
playheadAbsolutePos = 0 + playheadSprite.width;
refresh();
addOffsetsToAudioPreview();
}
public function zoomWaveformIn():Void
{
if (waveformScale > MIN_SCALE)
{
waveformScale = waveformScale / WAVEFORM_ZOOM_MULT;
if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
trace('Zooming in, scale: ${waveformScale}');
// Update the playhead too!
playheadAbsolutePos = playheadAbsolutePos * WAVEFORM_ZOOM_MULT;
// Recenter the scroll view on the playhead.
var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
refresh();
refreshTicks();
}
else
{
waveformScale = MIN_SCALE;
}
}
public function zoomWaveformOut():Void
{
waveformScale = waveformScale * WAVEFORM_ZOOM_MULT;
if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
trace('Zooming out, scale: ${waveformScale}');
// Update the playhead too!
playheadAbsolutePos = playheadAbsolutePos / WAVEFORM_ZOOM_MULT;
// Recenter the scroll view on the playhead.
var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
refresh();
refreshTicks();
}
public function setTrackVolume(target:Waveform, volume:Float):Void
{
switch (target)
{
case Waveform.INSTRUMENTAL:
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
trackInst.volume = volume;
}
case Waveform.PLAYER:
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
trackPlayer.volume = volume;
}
case Waveform.OPPONENT:
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
trackOpponent.volume = volume;
}
}
}
public function muteTrack(target:Waveform):Void
{
switch (target)
{
case Waveform.INSTRUMENTAL:
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
trackInst.muted = true;
offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute";
}
case Waveform.PLAYER:
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
trackPlayer.muted = true;
offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute";
}
case Waveform.OPPONENT:
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
trackOpponent.muted = true;
offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute";
}
}
}
public function unmuteTrack(target:Waveform):Void
{
switch (target)
{
case Waveform.INSTRUMENTAL:
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
trackInst.muted = false;
offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute";
}
case Waveform.PLAYER:
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
trackPlayer.muted = false;
offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute";
}
case Waveform.OPPONENT:
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
trackOpponent.muted = false;
offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute";
}
}
}
public function toggleMuteTrack(target:Waveform):Void
{
switch (target)
{
case Waveform.INSTRUMENTAL:
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
trackInst.muted = !trackInst.muted;
offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute";
}
case Waveform.PLAYER:
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
trackPlayer.muted = !trackPlayer.muted;
offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute";
}
case Waveform.OPPONENT:
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
trackOpponent.muted = !trackOpponent.muted;
offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute";
}
}
}
/**
* Clicking the solo button will unmute the track and mute all other tracks.
* @param target
*/
public function soloTrack(target:Waveform):Void
{
switch (target)
{
case Waveform.PLAYER:
muteTrack(Waveform.OPPONENT);
muteTrack(Waveform.INSTRUMENTAL);
unmuteTrack(Waveform.PLAYER);
case Waveform.OPPONENT:
muteTrack(Waveform.PLAYER);
muteTrack(Waveform.INSTRUMENTAL);
unmuteTrack(Waveform.OPPONENT);
case Waveform.INSTRUMENTAL:
muteTrack(Waveform.PLAYER);
muteTrack(Waveform.OPPONENT);
unmuteTrack(Waveform.INSTRUMENTAL);
}
}
public override function update(elapsed:Float)
{
super.update(elapsed);
if (audioPreviewTracks.playing)
{
trace('Playback time: ${audioPreviewTracks.time}');
var targetScrollPos:Float = waveformInstrumental.waveform.waveformData.secondsToIndex(audioPreviewTracks.time / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
// waveformScrollview.hscrollPos = targetScrollPos;
var prevPlayheadAbsolutePos = playheadAbsolutePos;
playheadAbsolutePos = targetScrollPos;
var playheadDiff = playheadAbsolutePos - prevPlayheadAbsolutePos;
// BEHAVIOR A.
// Just move the scroll view with the playhead, constraining it so that the playhead is always visible.
// waveformScrollview.hscrollPos += playheadDiff;
// waveformScrollview.hscrollPos = FlxMath.bound(waveformScrollview.hscrollPos, playheadAbsolutePos - playheadSprite.width, playheadAbsolutePos);
// BEHAVIOR B.
// Keep `playheadAbsolutePos` within the bounds of the screen.
// The scroll view will eventually move to where the playhead is 1/8th of the way from the left. This looks kinda nice!
// TODO: This causes a hard snap to scroll when the playhead is to the right of the playheadCenterPoint.
// var playheadCenterPoint = waveformScrollview.width / 8;
// waveformScrollview.hscrollPos = FlxMath.bound(waveformScrollview.hscrollPos, playheadAbsolutePos - playheadCenterPoint, playheadAbsolutePos);
// playheadRelativePos = 0;
// BEHAVIOR C.
// Copy Audacity!
// If the playhead is out of view, jump forward or backward by one screen width until it's in view.
if (playheadAbsolutePos < waveformScrollview.hscrollPos)
{
waveformScrollview.hscrollPos -= waveformScrollview.width;
}
if (playheadAbsolutePos > waveformScrollview.hscrollPos + waveformScrollview.width)
{
waveformScrollview.hscrollPos += waveformScrollview.width;
}
}
if (chartEditorState.currentInstrumentalOffset != audioPreviewInstrumentalOffset)
{
var track = audioPreviewTracks.members[0];
if (track != null)
{
track.time += audioPreviewInstrumentalOffset;
track.time -= chartEditorState.currentInstrumentalOffset;
audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset;
}
}
if (chartEditorState.currentVocalOffsetPlayer != audioPreviewPlayerOffset)
{
var track = audioPreviewTracks.members[1];
if (track != null)
{
track.time += audioPreviewPlayerOffset;
track.time -= chartEditorState.currentVocalOffsetPlayer;
audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer;
}
}
if (chartEditorState.currentVocalOffsetOpponent != audioPreviewOpponentOffset)
{
var track = audioPreviewTracks.members[2];
if (track != null)
{
track.time += audioPreviewOpponentOffset;
track.time -= chartEditorState.currentVocalOffsetOpponent;
audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent;
}
}
// Keep the playhead in view.
// playheadRelativePos = FlxMath.bound(playheadRelativePos, waveformScrollview.hscrollPos + 1,
// Math.min(waveformScrollview.hscrollPos + waveformScrollview.width, waveformContainer.width));
}
public override function refresh():Void
{
super.refresh();
waveformMagicFactor = MAGIC_SCALE_BASE_TIME / (chartEditorState.offsetTickBitmap.width / waveformInstrumental.waveform.waveformData.pointsPerSecond());
var currentZoomFactor = waveformScale / BASE_SCALE * waveformMagicFactor;
var maxWidth:Int = -1;
offsetStepperPlayer.value = chartEditorState.currentVocalOffsetPlayer;
offsetStepperOpponent.value = chartEditorState.currentVocalOffsetOpponent;
offsetStepperInstrumental.value = chartEditorState.currentInstrumentalOffset;
waveformPlayer.waveform.time = -chartEditorState.currentVocalOffsetPlayer / Constants.MS_PER_SEC; // Negative offsets make the song start early.
waveformPlayer.waveform.width = (waveformPlayer.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
if (waveformPlayer.waveform.width > maxWidth) maxWidth = Std.int(waveformPlayer.waveform.width);
waveformPlayer.waveform.height = 65;
waveformOpponent.waveform.time = -chartEditorState.currentVocalOffsetOpponent / Constants.MS_PER_SEC;
waveformOpponent.waveform.width = (waveformOpponent.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
if (waveformOpponent.waveform.width > maxWidth) maxWidth = Std.int(waveformOpponent.waveform.width);
waveformOpponent.waveform.height = 65;
waveformInstrumental.waveform.time = -chartEditorState.currentInstrumentalOffset / Constants.MS_PER_SEC;
waveformInstrumental.waveform.width = (waveformInstrumental.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
if (waveformInstrumental.waveform.width > maxWidth) maxWidth = Std.int(waveformInstrumental.waveform.width);
waveformInstrumental.waveform.height = 65;
// Live update the drag, but don't actually change the underlying offset until we release the mouse to finish dragging.
if (dragWaveform != null) switch (dragWaveform)
{
case PLAYER:
// chartEditorState.currentVocalOffsetPlayer += deltaMilliseconds;
waveformPlayer.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC;
offsetStepperPlayer.value += dragOffsetMs;
case OPPONENT:
// chartEditorState.currentVocalOffsetOpponent += deltaMilliseconds;
waveformOpponent.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC;
offsetStepperOpponent.value += dragOffsetMs;
case INSTRUMENTAL:
// chartEditorState.currentInstrumentalOffset += deltaMilliseconds;
waveformInstrumental.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC;
offsetStepperInstrumental.value += dragOffsetMs;
default:
// No drag, no
}
waveformPlayer.waveform.markDirty();
waveformOpponent.waveform.markDirty();
waveformInstrumental.waveform.markDirty();
waveformContainer.width = maxWidth;
tickTiledSprite.width = maxWidth;
}
public static function build(chartEditorState:ChartEditorState):ChartEditorOffsetsToolbox
{
return new ChartEditorOffsetsToolbox(chartEditorState);
}
}
enum Waveform
{
PLAYER;
OPPONENT;
INSTRUMENTAL;
}

View file

@ -0,0 +1,17 @@
package funkin.ui.haxeui.components;
import funkin.audio.waveform.WaveformSprite;
import funkin.audio.waveform.WaveformData;
import haxe.ui.backend.flixel.components.SpriteWrapper;
class WaveformPlayer extends SpriteWrapper
{
public var waveform(default, null):WaveformSprite;
public function new(?waveformData:WaveformData)
{
super();
this.waveform = new WaveformSprite(waveformData);
this.sprite = waveform;
}
}

View file

@ -3,6 +3,7 @@ package funkin.util.logging;
import openfl.Lib;
import openfl.events.UncaughtErrorEvent;
import flixel.util.FlxSignal.FlxTypedSignal;
import flixel.FlxG.FlxRenderMethod;
/**
* A custom crash handler that writes to a log file and displays a message box.
@ -118,6 +119,7 @@ class CrashHandler
var driverInfo = FlxG?.stage?.context3D?.driverInfo ?? 'N/A';
fullContents += 'Driver info: ${driverInfo}\n';
fullContents += 'Platform: ${Sys.systemName()}\n';
fullContents += 'Render method: ${renderMethod()}\n';
fullContents += '\n';
@ -196,4 +198,32 @@ class CrashHandler
{
throw "This is an example of an uncaught exception.";
}
public static function induceNullObjectReference():Void
{
var obj:Dynamic = null;
var value = obj.test;
}
public static function induceNullObjectReference2():Void
{
var obj:Dynamic = null;
var value = obj.test();
}
public static function induceNullObjectReference3():Void
{
var obj:Dynamic = null;
var value = obj();
}
static function renderMethod():String
{
return switch (FlxG.renderMethod)
{
case FlxRenderMethod.DRAW_TILES: 'DRAW_TILES';
case FlxRenderMethod.BLITTING: 'BLITTING';
default: 'UNKNOWN';
}
}
}