0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flet拡張機能でオーディオストリームを実装する

Last updated at Posted at 2025-09-03

概要

Fletの拡張機能を利用して、任意のFlutterパッケージをFlet内に組み込むことができる。
Flutterの mp_audio_stream を利用してリアルタイムにPCMデータを再生するコントロールを実装した。

背景

Fletでピアノ演奏アプリを作成しており、選択した音階のピアノ音を鳴らすのに苦戦した。
flet-audioに用意されたAudioコントロールは音声ファイルを事前に読み込む必要があるため、リアルタイムな音声再生には適さなかった。

解決策

公式ページを参考に、Flutterパッケージ mp_audio_stream をFletに組み込む。

まず、拡張機能用のディレクトリを作成し、テンプレートからプロジェクトを作成する。

$ mkdir flet-audio-stream
$ cd flet-audio-stream
$ flet create --template extension --project-name flet-audio-stream

アプリ本体から使えるようにするため、次のコマンドを実行する。

$ pip install -e .

-e (editable)オプションをつけることで、パッケージの変更がすぐに反映される。

Dart側

src/flutter/flet-audio-stream/lib/src 下にある flet_audio_stream.dartaudio_stream.dart にリネームし、flet-audioFlutterパッケージ を参考にして以下のように実装する。

audio_stream.dart
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';

import 'package:mp_audio_stream/mp_audio_stream.dart';
import 'package:flet/flet.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

class AudioStreamControl extends StatefulWidget {
  final Control? parent;
  final Control control;
  final Widget? nextChild;
  final FletControlBackend backend;

  const AudioStreamControl({
    super.key,
    required this.parent,
    required this.control,
    required this.nextChild,
    required this.backend,
  });

  @override
  State<AudioStreamControl> createState() => _AudioStreamControlState();
}

class _AudioStreamControlState extends State<AudioStreamControl> with FletStoreMixin {
  AudioStream? stream;

  @override
  void initState() {
    debugPrint("AudioStream.initState($hashCode)");
    stream = widget.control.state["stream"];
    if (stream == null) {
      stream = getAudioStream();
      stream?.init();
      widget.control.state["stream"] = stream;
    }
    widget.control.onRemove.clear();
    widget.control.onRemove.add(_onRemove);
    super.initState();
  }

  @override
  void _onRemove() {
    debugPrint("AudioStream.remove($hashCode)");
    widget.control.state["stream"]?.uninit();
    widget.backend.unsubscribeMethods(widget.control.id);
  }

  @override
  Widget build(BuildContext context) {
    debugPrint("AudioStream build: ${widget.control.id} (${widget.control.hashCode})");

    int? bufferMs = widget.control.attrInt("buffer_ms", null);
    int? waitingBufferMs = widget.control.attrInt("waiting_buffer_ms", null);
    int? channels = widget.control.attrInt("channels", null);
    int? sampleRate = widget.control.attrInt("sample_rate", null);

    final int? prevBufferMs = widget.control.state["buffer_ms"];
    final int? prevWaitingBufferMs = widget.control.state["waiting_buffer_ms"];
    final int? prevChannels = widget.control.state["channels"];
    final int? prevSampleRate = widget.control.state["sample_rate"];

    return withPageArgs((context, pageArgs) {
      bool paramsChanged = false;

      if (bufferMs != null &&
          bufferMs != prevBufferMs &&
          bufferMs > 0) {
        widget.control.state["buffer_ms"] = bufferMs;
        paramsChanged = true;
      }

      if (waitingBufferMs != null &&
          waitingBufferMs != prevWaitingBufferMs &&
          waitingBufferMs > 0) {
        widget.control.state["waiting_buffer_ms"] = waitingBufferMs;
        paramsChanged = true;
      }

      if (channels != null &&
          channels != prevChannels &&
          channels > 0) {
        widget.control.state["channels"] = channels;
        paramsChanged = true;
      }
      
      if (sampleRate != null &&
          sampleRate != prevSampleRate &&
          sampleRate > 0) {
        widget.control.state["sample_rate"] = sampleRate;
        paramsChanged = true;
      }

      if (paramsChanged) {
        stream?.init(
          bufferMilliSec: widget.control.state["buffer_ms"] ?? 3000,
          waitingBufferMilliSec: widget.control.state["waiting_buffer_ms"] ?? 100,
          channels: widget.control.state["channels"] ?? 1,
          sampleRate: widget.control.state["sample_rate"] ?? 44100,
        );
      }
      
      widget.backend.subscribeMethods(widget.control.id,
          (methodName, args) async {
        switch (methodName) {
          case "resume":
            stream?.resume();
            break;
          case "push":
            final String? pcmB64 = args["pcm_b64"] as String?;
            if (pcmB64 != null) {
              final Uint8List pcmBytes = base64Decode(pcmB64);
              return stream?.push(Float32List.view(pcmBytes.buffer)).toString();
            }
            break;
          case "stat":
            final AudioStreamStat? stat = stream?.stat();
            if (stat != null) {
              return jsonEncode({"full": stat.full, "exhaust": stat.exhaust});
            }
            break;
          case "reset_stat":
            stream?.resetStat();
            break;
        }
        return null;
      });

      return const SizedBox.shrink();
    });
  }
}
  • FletControlBackend.subscribeMethods は Future<String?> 型の戻り値を期待するため、asyncを使いつつ toString() して文字列を返す
  • 登録したメソッドはPython側の invoke_method から呼び出すことができる

同じディレクトリ内にあるcreate_control.dartを以下のように変更する。
case文内の文字列と、Python側の _get_control_name() で返す文字列を一致させる必要があることに注意する。

create_control.dart
import 'package:flet/flet.dart';

import 'audio_stream.dart';

CreateControlFactory createControl = (CreateControlArgs args) {
  switch (args.control.type) {
    case "audio_stream":
      return AudioStreamControl(
          parent: args.parent,
          control: args.control,
          nextChild: args.nextChild,
          backend: args.backend);
    default:
      return null;
  }
};

void ensureInitialized() {
  // nothing to initialize
}

一つ上の階層 (src/flutter/flet-audio-stream/lib) に以下のようなflet-audio-stream.dartがあることを確認する。

flet_audio_stream.dart
library flet_audio_stream;

export "../src/create_control.dart" show createControl, ensureInitialized;

さらに一つ上の階層 (src/flutter/flet-audio-stream) 下の pubspec.yaml に依存関係を追加する。

pubspec.yaml
# Other Settings ...

dependencies:
  flutter:
    sdk: flutter
  flet: ^0.25.2
  mp_audio_stream: ^0.2.2

Python側

src/flet-audio-stream 下にPython側の処理を記述する。
pushメソッドでは与えられたnumpy配列をbytes->base64文字列へと変換し、invoke_methodでメソッド名とともに渡している。

audio_stream.py
import json
import base64
from dataclasses import dataclass
from typing import Any, Optional

import numpy as np
from flet.core.control import Control, OptionalNumber
from flet.core.ref import Ref


@dataclass
class AudioStreamStat:
    full: int
    exhaust: int


class AudioStream(Control):
    """
    A control to push wave data into audio stream.
    Works on macOS, Linux, Windows, iOS, Android and web.
    Based on mp_audio_stream Flutter widget (https://pub.dev/packages/mp_audio_stream).
    
    AudioStream control is non-visual and should be added to `page.overlay` list.

    """

    def __init__(
        self,
        buffer_ms: OptionalNumber = None,
        waiting_buffer_ms: OptionalNumber = None,
        channels: OptionalNumber = None,
        sample_rate: OptionalNumber = None,
        #
        # Control
        #
        ref: Optional[Ref] = None,
        data: Any = None,
    ):
        Control.__init__(
            self,
            ref=ref,
            data=data,
        )

        self.buffer_ms = buffer_ms
        self.waiting_buffer_ms = waiting_buffer_ms
        self.channels = channels
        self.sample_rate = sample_rate
        
    def _get_control_name(self):
        return "audio_stream"

    def resume(self):
        """
        You should call this from some user-action to activate `AudioContext`.
        Ignored on platforms other than web.

        """
        self.invoke_method("resume")
        
    def push(self, samples: np.ndarray, wait_timeout: Optional[float] = 5) -> Optional[int]:
        """
        Pushes wave data (float32, -1.0 to 1.0) into audio stream.
        When buffer is full, the input is ignored.

        Parameters
        ----------
        samples : np.ndarray
            Float32 PCM samples in shape (N * channels).
            mono: [f0, f1, ...], stereo: [L0, R0, L1, R1, ...]

        Returns
        -------
        status_code : Optional[int]
            0 then success.

        """
        pcm_bytes = samples.tobytes()
        pcm_b64 = base64.b64encode(pcm_bytes).decode("ascii")
        sr = self.invoke_method(
            "push", {"pcm_b64": pcm_b64},
            wait_for_result=True,
            wait_timeout=wait_timeout,
        )
        return int(sr) if sr else None

    def get_stat(self, wait_timeout: Optional[float] = 5) -> Optional[AudioStreamStat]:
        sr = self.invoke_method(
            "stat",
            wait_for_result=True,
            wait_timeout=wait_timeout,
        )
        if sr is None:
            return None
        data = json.loads(sr)
        return AudioStreamStat(
            full=int(data["full"]),
            exhaust=int(data["exhaust"]),
        )

    def reset_stat(self):
        self.invoke_method("reset_stat")

    # buffer_ms
    @property
    def buffer_ms(self):
        return self._get_attr("buffer_ms")

    @buffer_ms.setter
    def buffer_ms(self, value: OptionalNumber):
        self._set_attr("buffer_ms", value)

    # waiting_buffer_ms
    @property
    def waiting_buffer_ms(self):
        return self._get_attr("waiting_buffer_ms")

    @waiting_buffer_ms.setter
    def waiting_buffer_ms(self, value: OptionalNumber):
        self._set_attr("waiting_buffer_ms", value)

    # channels
    @property
    def channels(self):
        return self._get_attr("channels")

    @channels.setter
    def channels(self, value: OptionalNumber):
        self._set_attr("channels", value)

    # sample_rate
    @property
    def sample_rate(self):
        return self._get_attr("sample_rate")

    @sample_rate.setter
    def sample_rate(self, value: OptionalNumber):
        self._set_attr("sample_rate", value)
__init__.py
from flet_audio_stream.audio_stream import AudioStream

動作確認

簡単なサンプルアプリケーションを作る。

main.py
import math
import time
import numpy as np
import flet as ft

from flet_audio_stream import AudioStream


def main(page: ft.Page):
    page.title = "Flet AudioStream demo"

    audio = AudioStream(
        buffer_ms=3000,
        waiting_buffer_ms=100,
        channels=2,
        sample_rate=44100,
    )
    page.overlay.append(audio)

    def play_tone(e):
        audio.resume()

        rate = 44100
        freqL = 440.0
        freqR = 660.0
        dur_sec = 10
        buffer_size = 2048

        total_frames = int(rate * dur_sec)

        t = np.arange(total_frames, dtype=np.float32)
        left  = np.sin(2 * math.pi * freqL * t / rate).astype(np.float32)
        right = np.sin(2 * math.pi * freqR * t / rate).astype(np.float32)

        stereo = np.empty(total_frames * 2, dtype=np.float32)
        stereo[0::2] = left
        stereo[1::2] = right

        frame_idx = 0
        frame_sec = buffer_size / rate
        deadline = time.perf_counter() + frame_sec

        while frame_idx < total_frames:
            now = time.perf_counter()
            if now < deadline:
                time.sleep(min(1e-3, deadline - now))
                continue
            end = min(frame_idx + buffer_size, total_frames)
            samples = stereo[2 * frame_idx:2 * end]
            audio.push(samples)
            frame_idx = end
            deadline += frame_sec

    page.add(ft.Text("Click the button then enjoy a 10s stereo tone."))
    page.add(ft.ElevatedButton("Play 10s tone", on_click=play_tone))

    page.update()


ft.app(main)

time.sleep() だけで実装すると処理にかかる時間だけズレが生じるため、タイマーを使って実装している
実際は、FluidSynthなどで合成したサンプルをそのまま流し込めばよい

アプリケーション本体の pyproject.toml に依存関係を追加する。

pyproject.toml
# Other Settings ...

dependencies = [
    "flet-audio-stream @ file:///absolute/path/to/flet-audio-stream",
    # Other dependencies ...
]

ここまで準備できたら、以下のコマンドを実行してビルドする。

$ flet build linux -v

linux の部分は動かしたいプラットフォームに設定する
macos, windows, web, apk ... (詳細はDocsを参照)

ビルドが終わったら、実行ファイルを起動するか

$ flet run main.py

として、画面上のボタンを押すと音が鳴る。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?