Repository: zauberzeug/nicegui
Pull request #2861 (2024-04-09 23:58:53)

audio recording example
Hello ! 

I added this example that allows to record audio from the browser then play it back or download it as a file.
I used a custom Vue component and pass the audio data to the backend once the recording is done.

Let me know what you think !

---

Related feature request: #1610


Patch:
diff --git a/examples/audio_recorder/audio_recorder.py b/examples/audio_recorder/audio_recorder.py
new file mode 100644
index 00000000..482b4010
--- /dev/null
+++ b/examples/audio_recorder/audio_recorder.py
@@ -0,0 +1,26 @@
+from typing import Callable, Optional
+import base64
+
+from nicegui import ui, events
+
+
+class AudioRecorder(ui.element, component='audio_recorder.vue'):
+
+    def __init__(self, *, on_audio_ready: Optional[Callable] = None) -> None:
+        super().__init__()
+        self.recording = b''
+
+        def handle_audio(e: events.GenericEventArguments) -> None:
+            self.recording = base64.b64decode(e.args['audioBlobBase64'].encode())
+            if on_audio_ready:
+                on_audio_ready(self.recording)
+        self.on('audio_ready', handle_audio)
+
+    def start_recording(self) -> None:
+        self.run_method('startRecording')
+
+    def stop_recording(self) -> None:
+        self.run_method('stopRecording')
+
+    def play_recorded_audio(self) -> None:
+        self.run_method('playRecordedAudio')
diff --git a/examples/audio_recorder/audio_recorder.vue b/examples/audio_recorder/audio_recorder.vue
new file mode 100644
index 00000000..578a9ed1
--- /dev/null
+++ b/examples/audio_recorder/audio_recorder.vue
@@ -0,0 +1,103 @@
+<template>
+  <div>
+    <button class="record-button" @mousedown="startRecording" @mouseup="stopRecording">Hold to Record</button>
+    <audio ref="audioPlayer"></audio>
+  </div>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      isRecording: false,
+      audioChunks: [],
+      mediaRecorder: null,
+      stream: null,
+      audioURL: null,
+      audioBlob: null,
+    };
+  },
+  mounted() {
+    this.requestMicrophonePermission();
+  },
+  watch: {
+    audioBlob(newBlob, oldBlob) {
+      if (newBlob && newBlob !== oldBlob) {
+        this.emitBlob(); // Emit the blob when it's non-null and changes
+      }
+    },
+  },
+  methods: {
+    async requestMicrophonePermission() {
+      try {
+        this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+      } catch (error) {
+        console.error("Error accessing microphone:", error);
+      }
+    },
+    async startRecording() {
+      try {
+        if (!this.stream) {
+          await this.requestMicrophonePermission();
+        }
+        this.audioChunks = [];
+        this.mediaRecorder = new MediaRecorder(this.stream);
+        this.mediaRecorder.addEventListener("dataavailable", (event) => {
+          if (event.data.size > 0) {
+            this.audioChunks.push(event.data);
+          }
+        });
+        this.mediaRecorder.start();
+        this.isRecording = true;
+      } catch (error) {
+        console.error("Error accessing microphone:", error);
+      }
+    },
+    stopRecording() {
+      if (this.isRecording) {
+        this.mediaRecorder.addEventListener("stop", () => {
+          this.isRecording = false;
+          this.saveBlob();
+          // this.playRecordedAudio();
+        });
+        this.mediaRecorder.stop();
+      }
+    },
+    async playRecordedAudio() {
+      this.audioURL = window.URL.createObjectURL(this.audioBlob);
+      this.$refs.audioPlayer.src = this.audioURL;
+      this.$refs.audioPlayer.play();
+    },
+    saveBlob() {
+      this.audioBlob = new Blob(this.audioChunks, { type: "audio/wav" });
+    },
+    emitBlob() {
+      const reader = new FileReader();
+      reader.onload = () => {
+        const base64Data = reader.result.split(",")[1]; // Extracting base64 data from the result
+        this.$emit("audio_ready", { audioBlobBase64: base64Data });
+      };
+      reader.readAsDataURL(this.audioBlob);
+    },
+  },
+};
+</script>
+
+<style scoped>
+.record-button {
+  width: 100px;
+  height: 100px;
+  border-radius: 50%;
+  background-color: red;
+  color: white;
+  border: 2px solid white;
+  font-size: 16px;
+  box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.3);
+  transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.record-button:active {
+  transform: translateY(2px);
+  box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3);
+}
+</style>
diff --git a/examples/audio_recorder/main.py b/examples/audio_recorder/main.py
new file mode 100755
index 00000000..c38bad2c
--- /dev/null
+++ b/examples/audio_recorder/main.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python3
+from audio_recorder import AudioRecorder
+from nicegui import ui
+
+with ui.row().classes('w-full justify-center'):
+    audio_recorder = AudioRecorder(on_audio_ready=lambda data: ui.notify(f'Recorded {len(data)} bytes'))
+
+with ui.row().classes('w-full justify-center'):
+    ui.button('Play', on_click=audio_recorder.play_recorded_audio) \
+        .bind_enabled_from(audio_recorder, 'recording')
+    ui.button('Download', on_click=lambda: ui.download(audio_recorder.recording, 'audio.ogx')) \
+        .bind_enabled_from(audio_recorder, 'recording')
+
+ui.run()
diff --git a/nicegui/elements/timer.py b/nicegui/elements/timer.py
index 550fa510..b9b624b5 100644
--- a/nicegui/elements/timer.py
+++ b/nicegui/elements/timer.py
@@ -1,9 +1,10 @@
 import asyncio
 import time
 from contextlib import nullcontext
-from typing import Any, Callable, Optional
+from typing import Any, Callable, Optional, Awaitable
 
-from .. import background_tasks, core, helpers
+from .. import background_tasks, core
+from ..awaitable_response import AwaitableResponse
 from ..binding import BindableProperty
 from ..client import Client
 from ..element import Element
@@ -91,7 +92,7 @@ class Timer(Element, component='timer.js'):
         try:
             assert self.callback is not None
             result = self.callback()
-            if helpers.is_coroutine_function(self.callback):
+            if isinstance(result, Awaitable) and not isinstance(result, AwaitableResponse):
                 await result
         except Exception as e:
             core.app.handle_exception(e)
diff --git a/website/examples.py b/website/examples.py
index e5d16c34..86f83adf 100644
--- a/website/examples.py
+++ b/website/examples.py
@@ -64,4 +64,5 @@ examples: List[Example] = [
     Example('Pyserial', 'communicate with a serial device'),
     Example('Webserial', 'communicate with a serial device using the WebSerial API'),
     Example('Websockets', 'use [websockets library](https://websockets.readthedocs.io/) to start a websocket server'),
+    Example('Audio Recorder', 'Record audio, play it back or download it'),
 ]


Test patch:
diff --git a/tests/test_timer.py b/tests/test_timer.py
index cd678309..cced9804 100644
--- a/tests/test_timer.py
+++ b/tests/test_timer.py
@@ -77,20 +77,6 @@ def test_setting_visibility(screen: Screen, once: bool):
     screen.should_not_contain('Some Label')
 
 
-def test_awaiting_coroutine(screen: Screen):
-    user = {'name': 'John Doe'}
-
-    async def update_user():
-        await asyncio.sleep(0.1)
-        user['name'] = 'Jane Doe'
-
-    ui.timer(1, update_user)
-
-    screen.open('/')
-    screen.wait(1)
-    assert user['name'] == 'Jane Doe'
-
-
 def test_timer_on_deleted_container(screen: Screen):
     state = {'count': 0}
     with ui.row() as outer_container:
@@ -105,3 +91,27 @@ def test_timer_on_deleted_container(screen: Screen):
     count = state['count']
     screen.wait(0.5)
     assert state['count'] == count, 'timer is not running anymore after deleting the container'
+
+
+def test_different_callbacks(screen: Screen):
+    def sync_function():
+        ui.label('a synchronous function')
+
+    async def async_function():
+        await asyncio.sleep(0.1)
+        ui.label('an asynchronous function')
+
+    async def async_lambda(msg: str):
+        await asyncio.sleep(0.1)
+        ui.label(f'an asynchronous lambda: {msg}')
+
+    ui.timer(0.1, sync_function, once=True)
+    ui.timer(0.1, async_function, once=True)
+    ui.timer(0.1, lambda: ui.label('a synchronous lambda'), once=True)
+    ui.timer(0.1, lambda: async_lambda('Hi!'), once=True)
+
+    screen.open('/')
+    screen.should_contain('a synchronous function')
+    screen.should_contain('an asynchronous function')
+    screen.should_contain('a synchronous lambda')
+    screen.should_contain('an asynchronous lambda: Hi!')