Flutter音频序列控制插件flutter_sequencer的使用
Flutter音频序列控制插件flutter_sequencer的使用
1. 插件简介
flutter_sequencer
是一个Flutter插件,允许你设置采样乐器并创建多轨音符序列。你可以为序列指定循环范围,并安排音量自动化。该插件在Android和iOS上使用 sfizz
SFZ播放器,SFZ格式可以用于创建高质量的基于样本的乐器并进行减法合成。这些乐器可以通过Flutter UI进行调制参数控制。插件还支持在两个平台上播放SF2(SoundFont)文件,在iOS上可以加载任何AudioUnit乐器。
2. 使用方法
2.1 创建序列
final sequence = Sequence(tempo: 120.0, endBeat: 8.0);
你需要在创建序列时设置节拍速度(tempo)和结束拍号(endBeat)。
2.2 创建乐器
final instruments = [
Sf2Instrument(path: "assets/sf2/TR-808.sf2", isAsset: true),
SfzInstrument(
path: "assets/sfz/GMPiano.sfz",
isAsset: true,
tuningPath: "assets/sfz/meanquar.scl",
),
RuntimeSfzInstrument(
id: "Sampled Synth",
sampleRoot: "assets/wav",
isAsset: true,
sfz: Sfz(
groups: [
SfzGroup(
regions: [
SfzRegion(sample: "D3.wav", noteNumber: 62),
SfzRegion(sample: "F3.wav", noteNumber: 65),
SfzRegion(sample: "Gsharp3.wav", noteNumber: 68),
],
),
],
),
),
RuntimeSfzInstrument(
id: "Generated Synth",
// 这个SFZ不使用任何样本文件,所以只需将sampleRoot设置为"/"作为占位符。
sampleRoot: "/",
isAsset: false,
// 基于Unison Oscillator示例
sfz: Sfz(
groups: [
SfzGroup(
regions: [
SfzRegion(
sample: "*saw",
otherOpcodes: {
"oscillator_multi": "5",
"oscillator_detune": "50",
}
)
]
)
]
)
),
];
乐器有四种类型:
- SfzInstrument:用于加载
.sfz
文件及其引用的样本文件。 - RuntimeSfzInstrument:用于在运行时构建SFZ。
- Sf2Instrument:用于加载
.sf2
SoundFont文件。 - AudioUnitInstrument:仅在iOS上有效,用于加载AudioUnit。
对于SF2或SFZ乐器,如果要从Flutter资产目录加载路径,请传递 isAsset: true
。如果你想从文件系统加载用户提供的或下载的声音,请传递 isAsset: false
。
2.3 重要注意事项
- 在Android上,使用
isAsset: true
加载的SFZ文件和样本将被提取到应用文件目录中,因为sfizz
无法直接从Android资产读取。这意味着它们会在设备上存在两份——压缩形式存在于APK中,未压缩形式存在于文件目录中。因此,我只建议在样本较小的情况下使用isAsset: true
。如果你想要包含高质量的音色库,应用程序应在运行时下载它们。 - 在任一平台上,Flutter资产路径会被URL编码。例如,如果你在资产文件夹中放置了一个名为 “Piano G#5.wav” 的文件,它最终会被称为 “Piano%20G%235.wav”。因此,如果你打包了一个SFZ文件作为资产,确保你删除了样本文件名中的任何特殊字符和空格,或者更新SFZ文件以引用URL编码的样本路径。我建议删除所有空格和特殊字符。
2.4 可选:保持引擎运行
GlobalState().setKeepEngineRunning(true);
这将使音频引擎即使在所有序列暂停时也保持运行。如果你需要在序列暂停时触发声音,请将其设置为 true
。否则不要这样做,因为它会增加能耗。
2.5 创建轨道
sequence.createTracks(instruments).then((tracks) {
setState(() {
this.tracks = tracks;
...
});
});
createTracks
返回一个 Future<List<Track>>
。你可能希望将它完成后的值存储在小部件的状态中。
2.6 在轨道上调度事件
track.addNote(noteNumber: 60, velocity: 0.7, startBeat: 0.0, durationBeats: 2.0);
这将在第0拍处添加中间C(MIDI音符编号60),并在2拍后停止。
track.addVolumeChange(volume: 0.75, beat: 2.0);
这将安排一个音量变化。它可以用于音量自动化。注意,轨道音量是在线性尺度上,而不是对数尺度上。你可能想要使用对数尺度并转换为线性。
track.addMidiCC(ccNumber: 127, ccValue: 127, beat: 2.0);
这将安排一个MIDI CC事件。这些可以用于更改音色库中的参数。例如,在SFZ中,你可以使用 cutoff_cc1
和 cutoff
操作码来定义一个滤波器,其中截止频率可以通过MIDI CC事件更改。
track.addMidiPitchBend(value: 1.0, beat: 2.0);
这将安排一个MIDI弯音事件。值可以从 -1.0 到 1.0。请注意,该值不是半音数。音色库定义了当弯音值设置为 -1.0 和 1.0 时,音高变化多少个音分。例如,在SFZ中,你可以使用 bend_down
和 bend_up
操作码来定义弯音范围。
2.7 控制播放
sequence.play();
sequence.pause();
sequence.stop();
开始、暂停或停止序列。
sequence.setBeat(double beat);
设置序列中的播放位置,以拍为单位。
sequence.setEndBeat(double beat);
设置序列的长度,以拍为单位。
sequence.setTempo(120.0);
设置每分钟的节拍数(BPM)。
sequence.setLoop(double loopStartBeat, double loopEndBeat);
sequence.unsetLoop();
启用或禁用循环。
2.8 获取实时信息
你可以使用 SingleTickerProviderStateMixin
在每一帧上调用以下方法:
sequence.getPosition(); // 获取序列的播放位置,以拍为单位
sequence.getIsPlaying(); // 获取序列是否正在播放
track.getVolume(); // 获取轨道的音量
2.9 实时播放
track.startNoteNow(noteNumber: 60, velocity: 0.75);
track.stopNoteNow(noteNumber: 60);
track.changeVolumeNow(volume: 0.5);
立即发送MIDI音符开、关消息或更改轨道音量。注意,这是线性增益,而不是对数增益。
3. 完整示例Demo
以下是一个完整的示例代码,展示了如何使用 flutter_sequencer
创建一个简单的鼓机应用。
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_sequencer/global_state.dart';
import 'package:flutter_sequencer/models/sfz.dart';
import 'package:flutter_sequencer/models/instrument.dart';
import 'package:flutter_sequencer/sequence.dart';
import 'package:flutter_sequencer/track.dart';
const INITIAL_TEMPO = 120.0;
const INITIAL_STEP_COUNT = 8;
const INITIAL_IS_LOOPING = true;
void main() {
runApp(MyApp());
}
class MyApp extends StatefulWidget {
[@override](/user/override)
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
final sequence = Sequence(tempo: INITIAL_TEMPO, endBeat: INITIAL_STEP_COUNT.toDouble());
Map<int, StepSequencerState?> trackStepSequencerStates = {};
List<Track> tracks = [];
Map<int, double> trackVolumes = {};
Track? selectedTrack;
late Ticker ticker;
double tempo = INITIAL_TEMPO;
int stepCount = INITIAL_STEP_COUNT;
double position = 0.0;
bool isPlaying = false;
bool isLooping = INITIAL_IS_LOOPING;
[@override](/user/override)
void initState() {
super.initState();
GlobalState().setKeepEngineRunning(true);
final instruments = [
Sf2Instrument(path: "assets/sf2/TR-808.sf2", isAsset: true),
SfzInstrument(
path: "assets/sfz/GMPiano.sfz",
isAsset: true,
tuningPath: "assets/sfz/meanquar.scl",
),
RuntimeSfzInstrument(
id: "Sampled Synth",
sampleRoot: "assets/wav",
isAsset: true,
sfz: Sfz(groups: [
SfzGroup(regions: [
SfzRegion(sample: "D3.wav", key: 62),
SfzRegion(sample: "F3.wav", key: 65),
SfzRegion(sample: "Gsharp3.wav", key: 68),
])
])),
RuntimeSfzInstrument(
id: "Generated Synth",
sampleRoot: "/",
isAsset: false,
sfz: Sfz(groups: [
SfzGroup(regions: [
SfzRegion(sample: "*saw", otherOpcodes: {
"oscillator_multi": "5",
"oscillator_detune": "50",
})
])
])),
];
sequence.createTracks(instruments).then((tracks) {
this.tracks = tracks;
tracks.forEach((track) {
trackVolumes[track.id] = 0.0;
trackStepSequencerStates[track.id] = StepSequencerState();
});
setState(() {
this.selectedTrack = tracks[0];
});
});
ticker = this.createTicker((Duration elapsed) {
setState(() {
tempo = sequence.getTempo();
position = sequence.getBeat();
isPlaying = sequence.getIsPlaying();
tracks.forEach((track) {
trackVolumes[track.id] = track.getVolume();
});
});
});
ticker.start();
}
handleTogglePlayPause() {
if (isPlaying) {
sequence.pause();
} else {
sequence.play();
}
}
handleStop() {
sequence.stop();
}
handleSetLoop(bool nextIsLooping) {
if (nextIsLooping) {
sequence.setLoop(0, stepCount.toDouble());
} else {
sequence.unsetLoop();
}
setState(() {
isLooping = nextIsLooping;
});
}
handleToggleLoop() {
final nextIsLooping = !isLooping;
handleSetLoop(nextIsLooping);
}
handleStepCountChange(int nextStepCount) {
if (nextStepCount < 1) return;
sequence.setEndBeat(nextStepCount.toDouble());
if (isLooping) {
final nextLoopEndBeat = nextStepCount.toDouble();
sequence.setLoop(0, nextLoopEndBeat);
}
setState(() {
stepCount = nextStepCount;
tracks.forEach((track) => syncTrack(track));
});
}
handleTempoChange(double nextTempo) {
if (nextTempo <= 0) return;
sequence.setTempo(nextTempo);
}
handleTrackChange(Track? nextTrack) {
setState(() {
selectedTrack = nextTrack;
});
}
handleVolumeChange(double nextVolume) {
if (selectedTrack != null) {
selectedTrack!.changeVolumeNow(volume: nextVolume);
}
}
handleVelocitiesChange(int trackId, int step, int noteNumber, double velocity) {
final track = tracks.firstWhere((track) => track.id == trackId);
trackStepSequencerStates[trackId]!.setVelocity(step, noteNumber, velocity);
syncTrack(track);
}
syncTrack(track) {
track.clearEvents();
trackStepSequencerStates[track.id]!
.iterateEvents((step, noteNumber, velocity) {
if (step < stepCount) {
track.addNote(
noteNumber: noteNumber,
velocity: velocity,
startBeat: step.toDouble(),
durationBeats: 1.0);
}
});
track.syncBuffer();
}
loadProjectState(ProjectState projectState) {
handleStop();
trackStepSequencerStates[tracks[0].id] = projectState.drumState;
trackStepSequencerStates[tracks[1].id] = projectState.pianoState;
trackStepSequencerStates[tracks[2].id] = projectState.bassState;
trackStepSequencerStates[tracks[3].id] = projectState.synthState;
handleStepCountChange(projectState.stepCount);
handleTempoChange(projectState.tempo);
handleSetLoop(projectState.isLooping);
tracks.forEach(syncTrack);
}
handleReset() {
loadProjectState(ProjectState.empty());
}
handleLoadDemo() {
loadProjectState(ProjectState.demo());
}
Widget _getMainView() {
if (selectedTrack == null) return Text('Loading...');
final isDrumTrackSelected = selectedTrack == tracks[0];
return Center(
child: Column(children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
Transport(
isPlaying: isPlaying,
isLooping: isLooping,
onTogglePlayPause: handleTogglePlayPause,
onStop: handleStop,
onToggleLoop: handleToggleLoop,
),
PositionView(position: position),
]),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
StepCountSelector(
stepCount: stepCount, onChange: handleStepCountChange),
TempoSelector(
selectedTempo: tempo,
handleChange: handleTempoChange,
),
],
),
TrackSelector(
tracks: tracks,
selectedTrack: selectedTrack,
handleChange: handleTrackChange,
),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
MaterialButton(
child: Text('Reset'),
onPressed: handleReset,
),
MaterialButton(
child: Text('Load Demo'),
onPressed: handleLoadDemo,
),
]),
DrumMachineWidget(
track: selectedTrack!,
stepCount: stepCount,
currentStep: position.floor(),
rowLabels: isDrumTrackSelected ? ROW_LABELS_DRUMS : ROW_LABELS_PIANO,
columnPitches:
isDrumTrackSelected ? ROW_PITCHES_DRUMS : ROW_PITCHES_PIANO,
volume: trackVolumes[selectedTrack!.id] ?? 0.0,
stepSequencerState: trackStepSequencerStates[selectedTrack!.id],
handleVolumeChange: handleVolumeChange,
handleVelocitiesChange: handleVelocitiesChange,
),
]),
);
}
[@override](/user/override)
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.dark(),
textTheme:
Theme.of(context).textTheme.apply(bodyColor: Colors.white)),
home: Scaffold(
appBar: AppBar(title: const Text('Drum machine example')),
body: _getMainView(),
),
);
}
}
更多关于Flutter音频序列控制插件flutter_sequencer的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html