Merge branch 'main' of ssh+git://github.com/Chroma-Case/Chromacase into front/play-page
This commit is contained in:
@@ -7,3 +7,7 @@ trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = tab
|
||||
|
||||
[{*.yaml,*.yml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
@@ -14,6 +14,16 @@ services:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
scorometer:
|
||||
build:
|
||||
context: ./scorometer
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "6543:6543"
|
||||
volumes:
|
||||
- ./scorometer:/app
|
||||
- ./musics:/musics
|
||||
|
||||
db:
|
||||
container_name: db
|
||||
|
||||
@@ -10,6 +10,12 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./musics:/musics
|
||||
scorometer:
|
||||
build: ./scorometer
|
||||
ports:
|
||||
- "6543:6543"
|
||||
volumes:
|
||||
- ./musics:/musics
|
||||
db:
|
||||
container_name: db
|
||||
image: postgres:alpine3.14
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Dockerfile
|
||||
Dockerfile.dev
|
||||
.dockerignore
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:latest
|
||||
RUN wget -q -O /tmp/websocketd.zip \
|
||||
https://github.com/joewalnes/websocketd/releases/download/v0.4.1/websocketd-0.4.1-linux_amd64.zip \
|
||||
&& unzip /tmp/websocketd.zip -d /tmp/websocketd && mv /tmp/websocketd/websocketd /usr/bin \
|
||||
&& chmod +x /usr/bin/websocketd
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./requirements.txt .
|
||||
RUN pip install -r ./requirements.txt
|
||||
|
||||
COPY . .
|
||||
CMD ["/usr/bin/websocketd", "--port=6543", "--staticdir=." "./main.py"]
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM python:latest
|
||||
RUN wget -q -O /tmp/websocketd.zip \
|
||||
https://github.com/joewalnes/websocketd/releases/download/v0.4.1/websocketd-0.4.1-linux_amd64.zip \
|
||||
&& unzip /tmp/websocketd.zip -d /tmp/websocketd && mv /tmp/websocketd/websocketd /usr/bin \
|
||||
&& chmod +x /usr/bin/websocketd
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./requirements.txt .
|
||||
RUN pip install -r ./requirements.txt
|
||||
|
||||
CMD ["/usr/bin/websocketd", "--port=6543", "--devconsole", "--", "python3", "./main.py"]
|
||||
@@ -0,0 +1,178 @@
|
||||
asyncapi: "2.5.0"
|
||||
info:
|
||||
title: Scorometer
|
||||
version: "1.0.0"
|
||||
channels:
|
||||
/start:
|
||||
publish:
|
||||
summary: "To start a song, send the start message with name of the song"
|
||||
message:
|
||||
summary: "Start message"
|
||||
payload:
|
||||
type: "object"
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
properties:
|
||||
type:
|
||||
type: "string"
|
||||
enum: ["start"]
|
||||
name:
|
||||
type: "string"
|
||||
description: "The name of the song"
|
||||
operationId: "startSong"
|
||||
/midi:
|
||||
publish:
|
||||
summary: "Every time a note is played, a midi message should be sent from the client to the server."
|
||||
message:
|
||||
summary: "Midi message"
|
||||
payload:
|
||||
type: "object"
|
||||
required:
|
||||
- type
|
||||
- time
|
||||
- note
|
||||
- intensity
|
||||
properties:
|
||||
id:
|
||||
type: "object"
|
||||
description: "An arbitrary data that will be sent back on the score message."
|
||||
type:
|
||||
type: "string"
|
||||
enum: ["note_on", "note_off"]
|
||||
description: "note_on => on_key_down, note_off => on_key_up"
|
||||
time:
|
||||
type: "number"
|
||||
description: "The event time in the midi message, I don't know the unit. A timestamp with the start as the epoch could work"
|
||||
note:
|
||||
type: "number"
|
||||
description: "The note played (between 21 and 108, C5 is 60)"
|
||||
intensity:
|
||||
type: "number"
|
||||
description: "How strong the key was pressed. On some MIDI libraries, this is named velocity. Should be between 30 and 130ish. If the piano does not support this, send null instead."
|
||||
operationId: "sendMidi"
|
||||
/score:
|
||||
subscribe:
|
||||
summary: "At each tempo (containing notes), a or multiple scores message will be sent. Note that this is not a 1/1 mapping with midi messages, chords will be grouped together."
|
||||
message:
|
||||
summary: "Score message"
|
||||
payload:
|
||||
type: "object"
|
||||
required:
|
||||
- type
|
||||
- time
|
||||
- timingScore
|
||||
- timingInformation
|
||||
- notes
|
||||
properties:
|
||||
ids:
|
||||
type: "array"
|
||||
items:
|
||||
type: "object"
|
||||
description: "The list of IDs of the events sent."
|
||||
type:
|
||||
type: "string"
|
||||
enum: ["note_on", "note_off"]
|
||||
description: "note_on => on_key_down, note_off => on_key_up"
|
||||
time:
|
||||
type: "number"
|
||||
description: "The event time in the midi message , I don't know the unit. A timestamp with the start as the epoch could work"
|
||||
timingScore:
|
||||
type: "string"
|
||||
enum: ["perfect", "great", "good", "miss"]
|
||||
description: "The score attributed to the timing."
|
||||
timingInformation:
|
||||
type: "string"
|
||||
enum: ["late", "perfect", "fast"]
|
||||
description: "Detailed informations on the timing. This information can be useful to the player."
|
||||
notes:
|
||||
type: "object"
|
||||
required:
|
||||
- good
|
||||
- missing
|
||||
- bad
|
||||
properties:
|
||||
good:
|
||||
type: "array"
|
||||
items:
|
||||
type: "object"
|
||||
required:
|
||||
- note
|
||||
properties:
|
||||
id:
|
||||
type: "object"
|
||||
description: "The id of the events sent."
|
||||
note:
|
||||
type: "number"
|
||||
description: "The note played (between 21 and 108, C5 is 60)"
|
||||
missing:
|
||||
type: "array"
|
||||
items:
|
||||
type: "object"
|
||||
required:
|
||||
- note
|
||||
properties:
|
||||
id:
|
||||
type: "object"
|
||||
description: "The id of the events sent."
|
||||
note:
|
||||
type: "number"
|
||||
description: "The note played (between 21 and 108, C5 is 60)"
|
||||
bad:
|
||||
type: "array"
|
||||
items:
|
||||
type: "object"
|
||||
required:
|
||||
- note
|
||||
properties:
|
||||
id:
|
||||
type: "object"
|
||||
description: "The id of the events sent."
|
||||
note:
|
||||
type: "number"
|
||||
description: "The note played (between 21 and 108, C5 is 60)"
|
||||
operationId: "receiveScore"
|
||||
/pause:
|
||||
publish:
|
||||
summary: "When the client pause/resume the playback, this message should be sent."
|
||||
message:
|
||||
summary: "Pause message"
|
||||
payload:
|
||||
type: "object"
|
||||
required:
|
||||
- type
|
||||
- paused
|
||||
- time
|
||||
properties:
|
||||
type:
|
||||
type: "string"
|
||||
enum: ["pause"]
|
||||
paused:
|
||||
type: "boolean"
|
||||
description: "True if the new state is paused, false if it is resumed"
|
||||
time:
|
||||
type: "number"
|
||||
description: "The timing at witch this event was sent. This is used to prevent network trafics to offset future notes and be sure the server/client are well timed together."
|
||||
operationId: "pauseSong"
|
||||
/end:
|
||||
subscribe:
|
||||
summary: "When the music has ended, the server will send this message with a recap of the whole score."
|
||||
message:
|
||||
summary: "End message"
|
||||
payload:
|
||||
type: "object"
|
||||
required:
|
||||
- type
|
||||
- overrallScore
|
||||
- score
|
||||
properties:
|
||||
type:
|
||||
type: "string"
|
||||
enum: ["end"]
|
||||
overralScore:
|
||||
type: number
|
||||
description: "An overrall score between 0 and 100."
|
||||
score:
|
||||
type: object
|
||||
description: "An object containing every difficulties as a key and a score between 0 and 100 for each."
|
||||
operationId: "endSong"
|
||||
@@ -0,0 +1,10 @@
|
||||
class Key:
|
||||
def __init__(self, key: int, start: int, duration: int):
|
||||
self.key = key
|
||||
self.start = start
|
||||
self.duration = duration
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.key} ({self.start} - {self.duration})"
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class Note:
|
||||
def __init__(self, start_time, data) -> None:
|
||||
|
||||
self.__start_time = start_time
|
||||
self.__data = data
|
||||
|
||||
def get_start_time(self):
|
||||
return self.__start_time
|
||||
|
||||
def get_data(self):
|
||||
return self.__data
|
||||
@@ -0,0 +1,15 @@
|
||||
from .Key import Key
|
||||
|
||||
class Partition:
|
||||
|
||||
def __init__(self, name:str, notes:list[Key]) -> None:
|
||||
|
||||
self.__name = name
|
||||
self.notes = notes
|
||||
|
||||
def __repr__(self):
|
||||
r = f"{self.__name}\n"
|
||||
for i in self.notes:
|
||||
r += f"{i.__repr__()}\n"
|
||||
return r
|
||||
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/python3
|
||||
import logging
|
||||
from chroma_case.Partition import Partition
|
||||
from chroma_case.Key import Key
|
||||
import sys
|
||||
import select
|
||||
import json
|
||||
from mido import MidiFile
|
||||
|
||||
RATIO = float(sys.argv[2] if len(sys.argv) > 2 else 1)
|
||||
OCTAVE = 5
|
||||
OCTAVE_AMOUNT_KEYS = 12
|
||||
|
||||
class Scorometer():
|
||||
def __init__(self, midiFile) -> None:
|
||||
self.partition = self.getPartition(midiFile)
|
||||
self.keys_down = []
|
||||
pass
|
||||
def getPartition(self, midiFile):
|
||||
notes = []
|
||||
s = 3500
|
||||
notes_on = {}
|
||||
prev_note_on = {}
|
||||
for msg in MidiFile(midiFile):
|
||||
d = msg.dict()
|
||||
s += d['time'] * 1000 * RATIO
|
||||
|
||||
if d["type"] == "note_on":
|
||||
prev_note_on[d["note"]] = 0
|
||||
if d["note"] in notes_on:
|
||||
prev_note_on[d["note"]] = notes_on[d["note"]] # 500
|
||||
notes_on[d["note"]] = s # 0
|
||||
|
||||
if d["type"] == "note_off":
|
||||
#duration = s - notes_on[d["note"]]
|
||||
duration = s - notes_on[d["note"]]
|
||||
note_start = notes_on[d["note"]]
|
||||
# time value is only used during debug
|
||||
notes.append(Key(d["note"], note_start, duration - 10))
|
||||
notes_on[d["note"]] = s # 500
|
||||
return Partition(midiFile, notes)
|
||||
|
||||
def handleNote(self, obj):
|
||||
_key = obj["note"]
|
||||
status = obj["type"]
|
||||
timestamp = obj["time"]
|
||||
is_down = any(x[0] == _key for x in self.keys_down)
|
||||
key = None
|
||||
if status == "note_on" and not is_down:
|
||||
self.keys_down.append((_key, timestamp))
|
||||
print(json.dumps({"note": _key}), flush=True)
|
||||
# print(f"Midi: {status} - {key} - {intensity} - {data3} at {timestamp}")
|
||||
elif status == "note_off" or is_down:
|
||||
down_since = next(since for (h_key, since) in self.keys_down if h_key == _key)
|
||||
self.keys_down.remove((_key, down_since))
|
||||
key = Key(_key, down_since, (timestamp - down_since))
|
||||
if key is None:
|
||||
return
|
||||
n = self.partition.notes
|
||||
to_play = next((i for i in n if i.key == key.key and self.is_timing_close(key, i)), None)
|
||||
if to_play == None:
|
||||
pass
|
||||
## TODO handle invalid key
|
||||
#points -= 50
|
||||
#print(f"Invalid key.")
|
||||
else:
|
||||
# 500 / 490 0.9 - 1
|
||||
tempo_percent = abs((key.duration / to_play.duration) - 1)
|
||||
#points += tempo_percent * 50
|
||||
if tempo_percent < .3 :
|
||||
timingScore = "perfect"
|
||||
elif tempo_percent < .5:
|
||||
timingScore = f"great"
|
||||
else:
|
||||
timingScore = "good"
|
||||
|
||||
timingInformation = "fast" if key.start < to_play.start else "late"
|
||||
if abs(key.start - to_play.start) < 200: timingInformation = "perfect"
|
||||
self.sendScore(obj["id"], timingScore, timingInformation)
|
||||
|
||||
|
||||
|
||||
def is_timing_close(self, key: Key, i):
|
||||
return abs(i.start - key.start) < 500
|
||||
|
||||
def handleMessage(self, message: str):
|
||||
obj = json.loads(message)
|
||||
if "type" not in obj.keys():
|
||||
self.sendError(message)
|
||||
return
|
||||
if obj["type"] == "note_on" or obj["type"] == "note_off":
|
||||
self.handleNote(obj)
|
||||
if obj["type"] == "pause":
|
||||
pass
|
||||
|
||||
def sendEnd(self, overall, difficulties):
|
||||
print(json.dumps({"overallScore": overall, "score": difficulties}), flush=True)
|
||||
|
||||
def sendError(self, message):
|
||||
print(json.dumps({"error": f"Could not handle message {message}"}), flush=True)
|
||||
|
||||
def sendScore(self, id, timingScore, timingInformation):
|
||||
print(json.dumps({"id": id, "timingScore": timingScore, "timingInformation": timingInformation}), flush=True)
|
||||
|
||||
def gameLoop(self):
|
||||
while True:
|
||||
if select.select([sys.stdin, ], [], [], 0.0)[0]:
|
||||
line = input()
|
||||
if not line:
|
||||
break
|
||||
logging.info("handling message")
|
||||
self.handleMessage(line.rstrip())
|
||||
else:
|
||||
pass
|
||||
self.sendEnd(0, {})
|
||||
|
||||
def main():
|
||||
try:
|
||||
start_message = json.loads(input())
|
||||
if "type" not in start_message.keys() or start_message["type"] != "start" or "name" not in start_message.keys():
|
||||
print(json.dumps({"error": "Error with the start message"}), flush=True)
|
||||
exit()
|
||||
song_name = start_message["name"]
|
||||
logging.info(f"started {song_name}")
|
||||
print(json.dumps({"song_launched": song_name}), flush=True)
|
||||
sc = Scorometer(f"partitions/{song_name}.midi")
|
||||
sc.gameLoop()
|
||||
except Exception as error:
|
||||
print({ "error": error }, flush=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
mido
|
||||
Reference in New Issue
Block a user